51c541ad22
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
81 lines
2.4 KiB
TypeScript
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);
|
|
}
|
|
}
|