CI / ci (push) Canceled after 0s
- Auth.js handlers require NextRequest. - The wrapper accepted plain Request. Changed both GET and POST handlers to accept NextRequest.
149 lines
5.8 KiB
TypeScript
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(() => {});
|
|
}
|