Delivered: Super Administrator — env-var provisioned, no StaffProfile at all (structural exclusion from Team/barber-picker/appointments/staff-totals, not a filter). hasPermission short-circuit + new getActingBusinessContext() helper retrofitted across 16 owner pages so it can actually manage the business. Media Manager — one upload/library/external-URL modal + field picker, backing every image field app-wide. Local-disk storage + Docker volume, SVG rejected, size-capped. Welcome backgrounds — desktop/mobile images with CSS breakpoint swap. Business Branding settings — logo/cover/welcome/favicon/icon/colours all editable post-install; cover image finally rendered (was dead config). Service descriptions — now surfaced on Home and in the booking flow. Loyalty card — live preview, ColorPicker/MediaPicker/IconPicker, flip removed for a static premium-styled card, Loyalty added to bottom nav. Fixed a real pre-existing bug where the card's configured colour/image/icon were stored but never actually rendered. Home Screen Redesign — hero gallery + sticky Services/Portfolio/Reviews/Details tabs, backed by a new unified gallery model/service (no hero/portfolio duplication). Details tab surfaces social links and a map that were never shown before. Google Reviews — real sync pipeline (24h cron, CRON_SECRET-gated, mirrors the existing notification cron exactly), DB-only customer reads, owner hide/unhide + policy management page. Architecture cleanup — SettingsPageShell extracted from 6 duplicated page wrappers; fixed a second pre-existing bug where branding image fields could never actually be cleared once set. Verification: 418 tests passing (236 unit + 182 integration, all against a real MySQL database), full typecheck/lint clean across src, and a real Docker build+run cycle — which caught and fixed a genuine bug (scripts/ wasn't copied into the production image, so Super Admin sync would have crashed on every real deployment).
69 lines
2.7 KiB
TypeScript
69 lines
2.7 KiB
TypeScript
import { randomUUID } from "node:crypto";
|
|
import { mkdir, 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.
|
|
*
|
|
* SVG is deliberately not in the allow-list — an uploaded SVG can embed
|
|
* <script>, making it a stored-XSS vector if ever served inline.
|
|
*/
|
|
const ALLOWED_MIME_EXTENSIONS: Record<string, string> = {
|
|
"image/png": "png",
|
|
"image/jpeg": "jpg",
|
|
"image/webp": "webp",
|
|
"image/gif": "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/";
|
|
|
|
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. */
|
|
url: string;
|
|
}
|
|
|
|
function sanitizeCategory(category: string): string {
|
|
return category.replace(/[^a-z0-9_-]/gi, "").toLowerCase() || "other";
|
|
}
|
|
|
|
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);
|
|
await mkdir(dir, { recursive: true });
|
|
await writeFile(path.join(dir, filename), params.buffer);
|
|
|
|
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 = 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;
|
|
await unlink(absolutePath).catch(() => {});
|
|
}
|