Add button and custom modal flow implemented

This commit is contained in:
adilallo
2026-05-07 21:15:27 -06:00
parent dee2dd800e
commit 26bcd61ea3
43 changed files with 1444 additions and 81 deletions
+59
View File
@@ -0,0 +1,59 @@
import { constants as fsConstants } from "node:fs";
import { access, readdir } from "node:fs/promises";
import path from "node:path";
import { getUploadRootFromEnv } from "./uploadRoot";
import { isValidUploadFileId } from "./uploadConstants";
export type ResolvedUploadFile = {
absolutePath: string;
/** MIME inferred from extension (no sidecar file in v1). */
contentType: string;
};
function contentTypeForFilename(fileName: string): string {
const ext = path.extname(fileName).toLowerCase();
switch (ext) {
case ".jpg":
case ".jpeg":
return "image/jpeg";
case ".png":
return "image/png";
case ".webp":
return "image/webp";
case ".gif":
return "image/gif";
case ".pdf":
return "application/pdf";
default:
return "application/octet-stream";
}
}
/**
* Resolves `id` (UUID stem) to the single matching file `{id}.*` under UPLOAD_ROOT.
* Returns null if missing, ambiguous, or invalid id.
*/
export async function resolveUploadedFileById(
id: string,
): Promise<ResolvedUploadFile | null> {
if (!isValidUploadFileId(id)) return null;
const root = getUploadRootFromEnv();
if (!root) return null;
const entries = await readdir(root);
const prefix = `${id}.`;
const matches = entries.filter((e) => e.startsWith(prefix));
if (matches.length !== 1) return null;
const absolutePath = path.join(root, matches[0]!);
try {
await access(absolutePath, fsConstants.R_OK);
} catch {
return null;
}
return {
absolutePath,
contentType: contentTypeForFilename(matches[0]!),
};
}
@@ -0,0 +1,57 @@
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { randomUUID } from "node:crypto";
import type { CreateFlowUploadPurpose } from "./uploadConstants";
import {
extensionForMime,
isAllowedMime,
maxBytesForPurpose,
} from "./uploadConstants";
import { ensureUploadRootExists, getUploadRootFromEnv } from "./uploadRoot";
export type SaveCreateFlowUploadResult = {
/** Filename stem (UUID) without extension — used in GET URL. */
id: string;
/** Full relative URL path for clients, e.g. `/api/uploads/abc`. */
urlPath: string;
mimeType: string;
byteLength: number;
};
/**
* Writes bytes under `UPLOAD_ROOT/{id}{ext}` and returns a stable app URL path.
*/
export async function saveCreateFlowUpload(params: {
purpose: CreateFlowUploadPurpose;
buffer: Buffer;
/** Declared MIME from the client `File.type` (validated server-side). */
mimeType: string;
}): Promise<SaveCreateFlowUploadResult | { error: "misconfigured" | "validation" }> {
const root = getUploadRootFromEnv();
if (!root) {
return { error: "misconfigured" };
}
const { purpose, buffer, mimeType } = params;
if (buffer.length > maxBytesForPurpose(purpose)) {
return { error: "validation" };
}
if (!isAllowedMime(purpose, mimeType)) {
return { error: "validation" };
}
const id = randomUUID();
const ext = extensionForMime(mimeType);
const fileName = `${id}${ext}`;
const absolutePath = path.join(root, fileName);
await ensureUploadRootExists(root);
await writeFile(absolutePath, buffer, { mode: 0o644 });
return {
id,
urlPath: `/api/uploads/${id}`,
mimeType: mimeType.toLowerCase().split(";")[0]?.trim() ?? "application/octet-stream",
byteLength: buffer.length,
};
}
+62
View File
@@ -0,0 +1,62 @@
import type { CreateFlowUploadPurpose } from "../../create/createFlowUploadPurpose";
export type { CreateFlowUploadPurpose };
export { CREATE_FLOW_UPLOAD_PURPOSES } from "../../create/createFlowUploadPurpose";
/** Max body size for multipart upload (bytes). */
export const CREATE_FLOW_UPLOAD_MAX_BYTES = 12 * 1024 * 1024;
const COMMUNITY_MAX = 5 * 1024 * 1024;
const CUSTOM_MAX = 10 * 1024 * 1024;
const IMAGE_MIMES = new Set([
"image/jpeg",
"image/png",
"image/webp",
"image/gif",
]);
const CUSTOM_EXTRA_MIMES = new Set(["application/pdf"]);
export function maxBytesForPurpose(purpose: CreateFlowUploadPurpose): number {
return purpose === "communityAvatar" ? COMMUNITY_MAX : CUSTOM_MAX;
}
export function isAllowedMime(
purpose: CreateFlowUploadPurpose,
mime: string,
): boolean {
const m = mime.toLowerCase().split(";")[0]?.trim() ?? "";
if (IMAGE_MIMES.has(m)) return true;
if (purpose === "customMethodAttachment" && CUSTOM_EXTRA_MIMES.has(m)) {
return true;
}
return false;
}
/** Extension including dot, from normalized mime (lowercase). */
export function extensionForMime(mime: string): string {
const m = mime.toLowerCase().split(";")[0]?.trim() ?? "";
switch (m) {
case "image/jpeg":
return ".jpg";
case "image/png":
return ".png";
case "image/webp":
return ".webp";
case "image/gif":
return ".gif";
case "application/pdf":
return ".pdf";
default:
return ".bin";
}
}
/** Strict id: uuid v4 filename stem (no extension in id param for GET). */
const UPLOAD_ID_RE =
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
export function isValidUploadFileId(id: string): boolean {
return UPLOAD_ID_RE.test(id);
}
+16
View File
@@ -0,0 +1,16 @@
import { mkdir } from "node:fs/promises";
import path from "node:path";
/**
* Directory for persisted user uploads (Cloudron localstorage mount in prod).
* When unset, upload routes return `server_misconfigured`.
*/
export function getUploadRootFromEnv(): string | null {
const raw = process.env.UPLOAD_ROOT?.trim();
if (!raw) return null;
return path.resolve(raw);
}
export async function ensureUploadRootExists(root: string): Promise<void> {
await mkdir(root, { recursive: true });
}