Files
NextChair/src/lib/media/media-storage.ts
T
Stefan Lazic d9a253a974
CI / ci (push) Canceled after 0s
Request fix
- Auth.js handlers require NextRequest.
- The wrapper accepted plain Request.
Changed both GET and POST handlers to accept NextRequest.
2026-09-04 21:59:44 +01:00

149 lines
5.8 KiB
TypeScript

import { randomUUID } from "node:crypto";
import { access, mkdir, readFile, stat, unlink, writeFile } from "node:fs/promises";
import path from "node:path";
/**
* Local-disk storage for the Media Manager (see src/services/media).
*
* Files live under a storage root OUTSIDE `public/` (`storage/media` by
* default — `/app/storage/media` in the Docker image, matching the
* `next-chair-media-data` volume in docker-compose.local.yaml). This was
* previously `public/media/`, served by Next's own static file handling
* — that turned out not to work: verified directly against a real
* production build/container that Next.js 16's `next start` does NOT
* dynamically serve files added to `public/` after the build (a
* build-time-present file served 200; a file written into `public/media`
* at runtime served 404, even though it existed on disk with correct
* permissions). See src/app/api/media/[...path]/route.ts, the dedicated
* route that now serves these files itself instead of relying on Next's
* public-asset resolution.
*
* SVG is deliberately not in the allow-list — an uploaded SVG can embed
* <script>, making it a stored-XSS vector if ever served inline.
*
* Kept provider-neutral by design: every caller only ever sees a
* MediaAsset.url string and calls storeMediaFile/readStoredMediaFile/
* deleteMediaFile — swapping this file for an S3-compatible backend
* later doesn't require touching any caller.
*/
const ALLOWED_MIME_EXTENSIONS: Record<string, string> = {
"image/png": "png",
"image/jpeg": "jpg",
"image/webp": "webp",
"image/gif": "gif",
};
const EXTENSION_MIME_TYPES: Record<string, string> = {
png: "image/png",
jpg: "image/jpeg",
jpeg: "image/jpeg",
webp: "image/webp",
gif: "image/gif",
};
export const MEDIA_MAX_UPLOAD_BYTES = Number(process.env.MEDIA_MAX_UPLOAD_BYTES ?? 5_000_000);
/** Overridable so a non-Docker deployment can point this somewhere else; defaults to a directory next to the app, outside public/. */
const MEDIA_ROOT = path.resolve(process.env.MEDIA_STORAGE_ROOT || "storage/media");
export const MEDIA_URL_PREFIX = "/api/media/";
export function isAllowedMediaMimeType(mimeType: string): boolean {
return mimeType in ALLOWED_MIME_EXTENSIONS;
}
export interface StoredMediaFile {
/** Public path — what gets saved on MediaAsset.url and rendered directly. Resolved by src/app/api/media/[...path]/route.ts, not Next's static handling. */
url: string;
}
function sanitizeCategory(category: string): string {
return category.replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "other";
}
/**
* Resolves a `/api/media/<...segments>` route param array to an absolute
* path, refusing anything that would escape MEDIA_ROOT — the only
* defense that actually matters against path traversal (`..` segments,
* absolute-path segments, encoded separators once decoded by Next's own
* routing). Returns null for anything unsafe rather than throwing, so
* callers can turn that straight into a 404 without distinguishing
* "malicious" from "malformed."
*/
export function resolveMediaFilePath(segments: string[]): string | null {
if (segments.length === 0) return null;
if (segments.some((segment) => !segment || segment === "." || segment === ".." || segment.includes("/") || segment.includes("\\"))) {
return null;
}
const absolutePath = path.resolve(MEDIA_ROOT, ...segments);
if (absolutePath !== MEDIA_ROOT && !absolutePath.startsWith(MEDIA_ROOT + path.sep)) return null;
return absolutePath;
}
export function mimeTypeForFilename(filename: string): string | null {
const extension = filename.split(".").pop()?.toLowerCase();
if (!extension) return null;
return EXTENSION_MIME_TYPES[extension] ?? null;
}
export interface StoredMediaFileContent {
buffer: Buffer;
mimeType: string;
}
/** Used only by GET /api/media/[...path] — returns null for "doesn't exist" or "not a regular file," never throws, so the route can turn that into a clean 404. */
export async function readStoredMediaFile(segments: string[]): Promise<StoredMediaFileContent | null> {
const absolutePath = resolveMediaFilePath(segments);
if (!absolutePath) return null;
const mimeType = mimeTypeForFilename(absolutePath);
if (!mimeType) return null;
try {
const fileStat = await stat(absolutePath);
if (!fileStat.isFile()) return null;
const buffer = await readFile(absolutePath);
return { buffer, mimeType };
} catch {
return null;
}
}
export async function storeMediaFile(params: {
buffer: Buffer;
mimeType: string;
category: string;
}): Promise<StoredMediaFile> {
const extension = ALLOWED_MIME_EXTENSIONS[params.mimeType];
if (!extension) throw new Error(`Unsupported media type: ${params.mimeType}`);
const safeCategory = sanitizeCategory(params.category);
const filename = `${randomUUID()}.${extension}`;
const dir = path.join(MEDIA_ROOT, safeCategory);
const absolutePath = path.join(dir, filename);
await mkdir(dir, { recursive: true });
await writeFile(absolutePath, params.buffer);
// Never report success on a write we can't actually read back — see
// this file's header. A silent permission/filesystem issue on write
// (e.g. a volume mounted read-only) should surface as a failed upload,
// not a broken URL discovered later.
try {
await access(absolutePath);
} catch {
throw new Error("Upload failed: the file could not be verified after writing.");
}
return { url: `${MEDIA_URL_PREFIX}${safeCategory}/${filename}` };
}
/** Best-effort — a missing file (already deleted, or a URL that was never ours) never blocks removing the DB row. */
export async function deleteMediaFile(url: string): Promise<void> {
if (!url.startsWith(MEDIA_URL_PREFIX)) return;
const relative = url.slice(MEDIA_URL_PREFIX.length);
const absolutePath = resolveMediaFilePath(relative.split("/"));
if (!absolutePath) return;
await unlink(absolutePath).catch(() => {});
}