Fixed an issue where media manager only shows files uploaded in specific sections.

Fixed an issue with icon picker names.

Removed jumbo rounding and a few other style tweaks.
This commit is contained in:
Stefan Lazic committed 2026-08-05 09:25:59 +01:00
1 parent 9e39908ea7
commit d3bbad09c6
74 files changed
+1622 -195

No files matched your search

+96 -16
View File
@@ -1,19 +1,30 @@
import { randomUUID } from "node:crypto";
import { mkdir, unlink, writeFile } from "node:fs/promises";
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 public/media/<category>/..., served for free by
* Next's own static file handling for public/ (no custom GET route
* needed). A deliberate v1 choice for this app's single-container Docker
* deployment model, not a cloud object-storage integration — see
* docker-compose.local.yaml's media volume for why this survives a
* container restart. Swapping to S3-compatible storage later only means
* changing this one file; every caller only ever sees a public URL.
* 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",
@@ -22,17 +33,27 @@ const ALLOWED_MIME_EXTENSIONS: Record<string, string> = {
"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);
const MEDIA_ROOT = path.join(process.cwd(), "public", "media");
const MEDIA_URL_PREFIX = "/media/";
/** 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 || path.join(process.cwd(), "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. */
/** 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;
}
@@ -40,6 +61,54 @@ 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;
@@ -51,8 +120,20 @@ export async function storeMediaFile(params: {
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(path.join(dir, filename), params.buffer);
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}` };
}
@@ -61,8 +142,7 @@ export async function storeMediaFile(params: {
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 = path.join(MEDIA_ROOT, relative);
// Path-traversal guard — a corrupted/crafted url should never resolve outside MEDIA_ROOT.
if (!absolutePath.startsWith(MEDIA_ROOT + path.sep)) return;
const absolutePath = resolveMediaFilePath(relative.split("/"));
if (!absolutePath) return;
await unlink(absolutePath).catch(() => {});
}