19a403b431
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>
85 lines
2.8 KiB
TypeScript
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);
|
|
}
|
|
}
|