Files
Leon Serfaty 19a403b431 fix(storage): serve assets via app routes when MEDIA_PUBLIC_BASE_URL is unset
publicUrl() defaulted MEDIA_BASE to "/media" even with no env set, so cover
art on public share pages pointed at a /media path that only the old nginx
(Plesk) box served. In the containerized Coolify/Docker deploy nothing serves
/media, breaking those images. Return null when MEDIA_PUBLIC_BASE_URL is unset
so callers fall back to the app's own /api/public/.../cover route (the deploy
README already documents assets as app-served). Backward compatible: behavior
is unchanged when the env var is set.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-20 21:10:52 -04:00

85 lines
2.8 KiB
TypeScript

import { promises as fs, createReadStream as fsCreateReadStream } from "node:fs";
import path from "node:path";
import type { StorageProvider } from "./types";
const STORAGE_DIR = path.resolve(process.env.STORAGE_DIR ?? "./storage");
// Only when an external static server (e.g. nginx on the Plesk box) serves the
// art directory is MEDIA_PUBLIC_BASE_URL set. In the containerized deploy it is
// unset and the app serves assets itself via /api routes — so publicUrl() must
// return null then, letting callers fall back instead of pointing at a dead /media.
const MEDIA_BASE = process.env.MEDIA_PUBLIC_BASE_URL?.trim() || null;
// Cover art is the only asset class served publicly by nginx from /media.
const PUBLIC_PREFIXES = ["art/"];
function resolveSafe(key: string): string {
// Prevent path traversal: the resolved path must stay inside STORAGE_DIR.
const full = path.resolve(STORAGE_DIR, key);
if (full !== STORAGE_DIR && !full.startsWith(STORAGE_DIR + path.sep)) {
throw new Error(`Invalid storage key: ${key}`);
}
return full;
}
/** Local-disk storage for the single-VPS Plesk deployment. */
export class LocalStorageProvider implements StorageProvider {
async put(key: string, data: Buffer | Uint8Array): Promise<void> {
const full = resolveSafe(key);
await fs.mkdir(path.dirname(full), { recursive: true });
await fs.writeFile(full, data);
}
async get(key: string): Promise<Buffer> {
return fs.readFile(resolveSafe(key));
}
/**
* Stream a file (optionally an inclusive byte range) without buffering it all
* into memory. Goes through resolveSafe so path-traversal protection holds.
*/
createReadStream(key: string, range?: { start: number; end: number }): NodeJS.ReadableStream {
const full = resolveSafe(key);
return range
? fsCreateReadStream(full, { start: range.start, end: range.end })
: fsCreateReadStream(full);
}
async exists(key: string): Promise<boolean> {
try {
await fs.access(resolveSafe(key));
return true;
} catch {
return false;
}
}
async delete(key: string): Promise<void> {
try {
await fs.unlink(resolveSafe(key));
} catch (err: unknown) {
if ((err as NodeJS.ErrnoException).code !== "ENOENT") throw err;
}
}
async size(key: string): Promise<number | null> {
try {
const stat = await fs.stat(resolveSafe(key));
return stat.size;
} catch {
return null;
}
}
publicUrl(key: string): string | null {
if (MEDIA_BASE && PUBLIC_PREFIXES.some((p) => key.startsWith(p))) {
return `${MEDIA_BASE}/${key}`;
}
return null;
}
/** Absolute filesystem path for a key — used by the worker (ffmpeg) and the asset route. */
absolutePath(key: string): string {
return resolveSafe(key);
}
}