Files
podcastdistributiona/lib/storage/local.ts
T

81 lines
2.4 KiB
TypeScript
Raw Normal View History

2026-06-20 20:59:03 -04:00
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");
const MEDIA_BASE = process.env.MEDIA_PUBLIC_BASE_URL ?? "/media";
// 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));
}
2026-06-20 20:59:03 -04:00
/**
* 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 (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);
}
}