Files
podcastdistributiona/lib/storage/local.ts
T
Leon Serfaty 51c541ad22 Security & robustness hardening pass
Cross-cutting input-validation, isolation, and DoS-resistance fixes across
the app, API, billing, queue, and infra layers.

- Runtime validation (zod) for client-supplied admin actions (role/plan/
  limits), series generation index, and all pg-boss queue payloads
- Auth: require email verification before sign-in; reject weak/placeholder/
  short BETTER_AUTH_SECRET in production
- Billing: sanitize Stripe/PayPal errors (log server-side, generic to client);
  race-safe subscription upsert; only count "processed" webhook events as
  handled; verify org membership in getEffectivePlan to block plan escalation
- Series generation: reserve usage up front and refund on failure; bill the
  owning org, not the caller's active org
- Injection defenses: HTML-escape user fields in emails, strip CR/LF from
  subject/recipient, validate ElevenLabs voiceId before URL interpolation
- Media routes: stream off disk instead of buffering whole files; rate-limit
  anonymous public audio/cover endpoints by client IP
2026-06-20 20:59:03 -04:00

81 lines
2.4 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");
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));
}
/**
* 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);
}
}