Initial commit: PodcastYes — AI podcast platform
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
import { LocalStorageProvider } from "./local";
|
||||
import type { StorageProvider } from "./types";
|
||||
|
||||
export * from "./types";
|
||||
|
||||
// Registry: today only local disk. To add S3/R2 later, implement StorageProvider
|
||||
// in lib/storage/s3.ts and switch on an env flag here — no call-site changes.
|
||||
let provider: StorageProvider | null = null;
|
||||
|
||||
export function storage(): StorageProvider {
|
||||
if (!provider) provider = new LocalStorageProvider();
|
||||
return provider;
|
||||
}
|
||||
|
||||
/** Convenience for the worker / asset route which need the on-disk path. */
|
||||
export function localStorage(): LocalStorageProvider {
|
||||
const s = storage();
|
||||
if (s instanceof LocalStorageProvider) return s;
|
||||
throw new Error("Local filesystem path requested but active storage is not local");
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { promises as fs } 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));
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
/**
|
||||
* Storage abstraction. The app only ever references a relative `key`
|
||||
* (e.g. "mp3/<episodeId>.mp3" or "art/<episodeId>.png"); the provider resolves
|
||||
* where it actually lives. Swapping local disk for S3/R2 later means writing a
|
||||
* new provider, with no changes at the call sites or in the database.
|
||||
*/
|
||||
export interface StorageProvider {
|
||||
/** Write bytes at `key`, creating parent "directories" as needed. */
|
||||
put(key: string, data: Buffer | Uint8Array, contentType?: string): Promise<void>;
|
||||
/** Read the full object as a Buffer. */
|
||||
get(key: string): Promise<Buffer>;
|
||||
/** Whether an object exists at `key`. */
|
||||
exists(key: string): Promise<boolean>;
|
||||
/** Remove the object (no-op if missing). */
|
||||
delete(key: string): Promise<void>;
|
||||
/** Size in bytes, or null if missing. */
|
||||
size(key: string): Promise<number | null>;
|
||||
/**
|
||||
* A directly-fetchable URL for *public* assets (e.g. cover art served by nginx
|
||||
* from /media). Returns null for providers/keys that must go through the
|
||||
* authenticated asset route (e.g. private MP3s).
|
||||
*/
|
||||
publicUrl(key: string): string | null;
|
||||
}
|
||||
|
||||
export type AssetKind = "mp3" | "art" | "exports" | "tmp";
|
||||
|
||||
/** Build a conventional storage key for an asset. */
|
||||
export function assetKey(kind: AssetKind, name: string): string {
|
||||
return `${kind}/${name}`;
|
||||
}
|
||||
Reference in New Issue
Block a user