Intelligence
Artifacts
Browse the repository, read documents, and manage the governance folders. Source, runtime, and infrastructure are read-only.
Repository
repositories/aaf-holdings/hq01/lib/uploads/upload.ts
4.9 KB
import fs from "node:fs";
import path from "node:path";
import { CONTENT_ROOT } from "@/lib/content/config";
import {
ALLOWED_EXTENSIONS,
MAX_UPLOAD_BYTES,
getCategory,
} from "./config";
/**
* Server-side artifact upload. This module performs every security check and is
* the only thing that writes uploaded bytes to disk. It runs server-side only.
*
* Defence in depth:
* 1. The destination is chosen from a fixed category allowlist — never a path.
* 2. The filename is reduced to a basename and sanitized (no traversal).
* 3. The extension must be in the allowlist.
* 4. The size is capped at 25 MB.
* 5. The resolved target is asserted to live inside the category directory.
* 6. Writes use the exclusive flag so an upload never overwrites an existing
* file; collisions are de-duplicated with a numeric suffix.
*/
export class UploadError extends Error {
constructor(
message: string,
readonly status = 400,
) {
super(message);
this.name = "UploadError";
}
}
/**
* Reduce an arbitrary client-supplied name to a safe filename.
* Strips directory components (defeating `../` traversal), removes control and
* reserved characters, and preserves a lower-cased extension.
*/
export function sanitizeFilename(raw: string): string {
// Keep only the final path segment — this alone defeats path traversal.
const base = (raw ?? "").split(/[/\\]/).pop() ?? "";
const extRaw = path.extname(base);
const ext = extRaw.toLowerCase();
let stem = base.slice(0, base.length - extRaw.length);
stem = stem
.normalize("NFKD")
.replace(/[^A-Za-z0-9._-]+/g, "_") // collapse anything unsafe
.replace(/_{2,}/g, "_")
.replace(/^[._-]+/, "") // no leading dot/dash/underscore (no hidden files)
.replace(/[._-]+$/, "");
if (!stem) stem = "file";
stem = stem.slice(0, 120);
return stem + ext;
}
export interface UploadResult {
/** Filename actually written (after sanitize + de-dup). */
name: string;
/** Category key the file was routed to. */
category: string;
/** Human label of the category. */
categoryLabel: string;
/** Absolute path written. */
absolutePath: string;
/** Path relative to the Artifacts explorer root, when inside it. */
relativePath: string | null;
/** Bytes written. */
size: number;
}
export interface SaveUploadInput {
categoryKey: string;
filename: string;
size: number;
data: Buffer;
}
/** Validate and persist an uploaded file. Throws {@link UploadError} on any rejection. */
export function saveUpload(input: SaveUploadInput): UploadResult {
const category = getCategory(input.categoryKey);
if (!category) {
throw new UploadError(`Unknown destination category: ${input.categoryKey}`);
}
if (input.size <= 0 || input.data.length === 0) {
throw new UploadError("The file is empty.");
}
if (input.size > MAX_UPLOAD_BYTES || input.data.length > MAX_UPLOAD_BYTES) {
throw new UploadError("File exceeds the 25MB limit.", 413);
}
const safeName = sanitizeFilename(input.filename);
const ext = path.extname(safeName).toLowerCase();
if (!ALLOWED_EXTENSIONS.has(ext)) {
throw new UploadError(
`Unsupported file type "${ext || "(none)"}". Allowed: ${[...ALLOWED_EXTENSIONS].join(", ")}.`,
415,
);
}
// Ensure the destination exists.
fs.mkdirSync(category.dir, { recursive: true });
// Defence in depth: the resolved target must stay inside the category dir.
const dirWithSep = category.dir.endsWith(path.sep)
? category.dir
: category.dir + path.sep;
const stem = safeName.slice(0, safeName.length - ext.length);
let attempt = 0;
let written: string | null = null;
let finalName = safeName;
while (attempt < 1000) {
finalName = attempt === 0 ? safeName : `${stem}-${attempt}${ext}`;
const target = path.join(category.dir, finalName);
if (target !== category.dir && !target.startsWith(dirWithSep)) {
throw new UploadError("Resolved path escapes the destination.", 400);
}
try {
// "wx": create + exclusive — fails if the file already exists.
const fd = fs.openSync(target, "wx");
try {
fs.writeFileSync(fd, input.data);
} finally {
fs.closeSync(fd);
}
written = target;
break;
} catch (err) {
if ((err as NodeJS.ErrnoException)?.code === "EEXIST") {
attempt += 1;
continue;
}
throw new UploadError(
`Failed to write file: ${err instanceof Error ? err.message : String(err)}`,
500,
);
}
}
if (!written) {
throw new UploadError("Could not find a free filename for the upload.", 409);
}
const rel = path.relative(CONTENT_ROOT, written);
const relativePath =
rel && !rel.startsWith("..") && !path.isAbsolute(rel)
? rel.split(path.sep).join("/")
: null;
return {
name: finalName,
category: category.key,
categoryLabel: category.label,
absolutePath: written,
relativePath,
size: input.data.length,
};
}
root · /srv/aaf