/** * @factory/storage — object key layout (PURE). * * The key layout is THE contract between every pipeline stage * (TASK-0101 Requirement 2, docs/tasks/phase-09-files.md): * * ``` * files/{workspaceId}/{fileId}/{version}/{safeName} * thumbs/{workspaceId}/{fileId}/{size}/{safeName} * ``` * * - `files/` — the private uploads bucket (`S3_BUCKET_FILES`). * - `thumbs/` — the read-only-serving bucket (`S3_BUCKET_THUMBS`); serving * is still server-authorized (signed URLs only, no public bucket). * * Rules: * - Deterministic — no clock, no randomness (same input ⇒ same key); the * ONLY non-determinism is the collision suffix (`-<8 hex>`), appended by * the caller when two files in the same workspace + file slot share a * sanitized name (see {@link withCollisionSuffix}). * - Workspace isolation is by KEY PREFIX + permission checks, NOT by bucket * ACL (one bucket per environment, all workspaces share it) — documented * decision, TASK-0101 Requirement 2. * - Names are sanitized ({@link sanitizeFilename}) so no path component, * control char, or unsafe byte can survive into a key — path traversal is * impossible by construction. */ /** Max length of the sanitized name segment (extension included). */ export const MAX_KEY_NAME_LENGTH = 180; /** Thumbnail sizes (px wide). 480 is the primary UI thumbnail. */ export const THUMBNAIL_SIZES = ["160", "480", "1280"] as const; export type ThumbnailSize = (typeof THUMBNAIL_SIZES)[number]; export const THUMBNAIL_SIZE_WIDTH: Record = { "160": 160, "480": 480, "1280": 1280, }; /** * Sanitize a user-supplied file name for use as a key segment * (TASK-0101 Requirement 2). * * 1. Strip ANY path components — both `/` and `\` separators and Windows * drives (`C:\` collapses to the basename), so `../../etc/passwd` → * `passwd`. * 2. Replace every char in `[\x00-\x1f\x7f<>:"\\/*?|]` with `_` * (control chars, DEL, and the filesystem-forbidden set). * 3. Cap the STEM at {@link MAX_KEY_NAME_LENGTH} WITHOUT losing the * extension (the extension is re-attached after truncation, and is * preserved case-insensitively — `UPPER.PNG` keeps `.PNG`). * * Pure and deterministic; never throws on hostile input. */ export function sanitizeFilename(name: string): string { let base = name; // 1) basename across BOTH path separators — traversal is impossible. const lastSep = Math.max(base.lastIndexOf("/"), base.lastIndexOf("\\")); if (lastSep !== -1) base = base.slice(lastSep + 1); // 2) control chars + the filesystem-forbidden set → underscore. Backslash // is in the forbidden set (replaced) — it never survives a key. base = base.replace(/[\x00-\x1f\x7f<>:"\\/*?|]/g, "_"); // 2b) empty or dots-only (hidden dotfiles stripped) → 'file'. if (base === "" || /^\.+$/.test(base)) base = "file"; // 3) cap the TOTAL (stem + extension) at 180 chars, extension preserved // (case-insensitive). const dot = base.lastIndexOf("."); if (dot > 0 && dot < base.length - 1) { const stem = base.slice(0, dot); const ext = base.slice(dot); if (stem.length + ext.length > MAX_KEY_NAME_LENGTH) { base = stem.slice(0, Math.max(1, MAX_KEY_NAME_LENGTH - ext.length)) + ext; } } else if (base.length > MAX_KEY_NAME_LENGTH) { base = base.slice(0, MAX_KEY_NAME_LENGTH); } return base === "" ? "file" : base; } /** * Append a collision suffix (`-<8 hex>`) to a sanitized name when two files * would otherwise map to the same key. The hex fragment comes from the * caller (e.g. a slice of the file id) so the result stays deterministic * for a given file. The stem is re-capped so the segment stays ≤ 180 chars * INCLUDING the suffix + extension. */ export function withCollisionSuffix(name: string, hex: string): string { const suffix = `-${hex.slice(0, 8).padEnd(8, "0")}`; const dot = name.lastIndexOf("."); if (dot > 0 && dot < name.length - 1) { const stem = name.slice(0, dot); const ext = name.slice(dot); const maxStem = Math.max(1, MAX_KEY_NAME_LENGTH - suffix.length - ext.length); return stem.slice(0, maxStem) + suffix + ext; } const max = Math.max(1, MAX_KEY_NAME_LENGTH - suffix.length); return name.slice(0, max) + suffix; } /** Parts of a file object key. */ export interface FileKeyParts { workspaceId: string; fileId: string; /** Version number (1 for the first upload). */ version: number; /** Original file name (sanitized here). */ name: string; } /** * Build the S3 key of a file object in the `files` bucket. * * `buildFileKey({ workspaceId: "wsp_1", fileId: "fil_1", version: 1, name: "a.png" })` * → `files/wsp_1/fil_1/1/a.png` */ export function buildFileKey(parts: FileKeyParts): string { if (!Number.isInteger(parts.version) || parts.version < 1) { throw new Error(`version must be a positive integer, got ${parts.version}`); } const safe = sanitizeFilename(parts.name); return `files/${parts.workspaceId}/${parts.fileId}/${parts.version}/${safe}`; } /** Parts of a thumbnail key. */ export interface ThumbKeyParts { workspaceId: string; fileId: string; /** Size tag: "160" | "480" | "1280" (px wide). */ size: ThumbnailSize; /** Original file name (sanitized here). */ name: string; /** * Output extension of the artifact. Images keep the source format * (`png`/`jpeg`/`webp`); PDF/video artifacts are `png`. */ ext: string; } /** * Build the S3 key of a thumbnail artifact in the `thumbs` bucket. * * `buildThumbKey({ workspaceId: "wsp_1", fileId: "fil_1", size: "480", name: "a.png", ext: "png" })` * → `thumbs/wsp_1/fil_1/480/a.png` */ export function buildThumbKey(parts: ThumbKeyParts): string { if (!THUMBNAIL_SIZES.includes(parts.size)) { throw new Error(`unknown thumbnail size: ${parts.size}`); } const safe = sanitizeFilename(parts.name); const ext = parts.ext.replace(/^\./, "").toLowerCase(); return `thumbs/${parts.workspaceId}/${parts.fileId}/${parts.size}/${safe}.${ext}`; } /** The `files/` key prefix (one workspace's whole file tree). */ export function workspaceFilePrefix(workspaceId: string): string { return `files/${workspaceId}/`; } /** The `thumbs/` key prefix (one workspace's whole thumbnail tree). */ export function workspaceThumbPrefix(workspaceId: string): string { return `thumbs/${workspaceId}/`; } /** Parsed form of a file key (the inverse of {@link buildFileKey}). */ export interface ParsedFileKey { workspaceId: string; fileId: string; version: number; /** Sanitized file name. */ name: string; } const PARSE_FILE_KEY_RE = /^files\/([^/]+)\/([^/]+)\/(\d+)\/(.+)$/; /** * Parse a `files/` object key back into its parts. Throws on anything * malformed — never guesses. */ export function parseFileKey(key: string): ParsedFileKey { const m = PARSE_FILE_KEY_RE.exec(key); if (!m) throw new Error(`malformed file key: ${key}`); const [, workspaceId, fileId, versionStr, name] = m; const version = Number(versionStr); if (!Number.isSafeInteger(version) || version < 1) { throw new Error(`malformed file key version: ${key}`); } if (name === undefined || name === "" || name.includes("/") || name.includes("\\")) { throw new Error(`malformed file key name: ${key}`); } return { workspaceId: workspaceId!, fileId: fileId!, version, name: name! }; } /** Parsed form of a thumbnail key (the inverse of {@link buildThumbKey}). */ export interface ParsedThumbKey { workspaceId: string; fileId: string; size: ThumbnailSize; /** Sanitized source file name. */ name: string; ext: string; } const PARSE_THUMB_KEY_RE = /^thumbs\/([^/]+)\/([^/]+)\/(160|480|1280)\/(.+)$/; /** * Parse a `thumbs/` object key back into its parts. Throws on anything * malformed — never guesses. */ export function parseThumbKey(key: string): ParsedThumbKey { const m = PARSE_THUMB_KEY_RE.exec(key); if (!m) throw new Error(`malformed thumbnail key: ${key}`); const nameDot = m[4]!.lastIndexOf("."); const name = nameDot > 0 ? m[4]!.slice(0, nameDot) : m[4]!; const ext = nameDot > 0 ? m[4]!.slice(nameDot + 1) : ""; return { workspaceId: m[1]!, fileId: m[2]!, size: m[3] as ThumbnailSize, name, ext, }; } /** * True if `key` lies within `prefix` (prefix must end with `/` for a * directory-style check; bare key equality is also allowed). * * Used for workspace-scoped listing/deletion: * `isWithinBucketPrefix(key, workspaceFilePrefix("wsp_1"))` → the workspace's * whole file tree. */ export function isWithinBucketPrefix(key: string, prefix: string): boolean { if (key === prefix) return true; if (!prefix.endsWith("/")) return key.startsWith(prefix + "/"); return key.startsWith(prefix) && key.length > prefix.length; }