Files
Spanglish/backend/src/lib/storage.ts
T
MichilisandCursor 613bd7be1d Refactor monolithic modules and harden booking, email, and auth infrastructure.
Split oversized frontend API client, email service, and admin/booking pages into focused modules while preserving import surfaces, and add Redis-backed queues, stale booking cleanup, stronger auth, and scale deployment configs.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-25 07:12:59 +00:00

137 lines
4.2 KiB
TypeScript

// Media storage abstraction with two implementations:
// - local: writes to the ./uploads directory and serves via /uploads/* (the
// original behavior, and the zero-config default)
// - s3: stores objects in an S3-compatible bucket (e.g. Garage), so uploads are
// shared across instances instead of living on one container's local disk
//
// S3 is enabled only when S3_ENDPOINT and S3_BUCKET are set. With it unset the
// app behaves exactly as before. A "key" is the object name (e.g. "abc123.jpg").
import { writeFile, mkdir, unlink } from 'fs/promises';
import { existsSync } from 'fs';
import { join } from 'path';
const UPLOAD_DIR = './uploads';
export interface Storage {
readonly backend: 'local' | 's3';
put(key: string, buffer: Buffer, contentType: string): Promise<void>;
delete(key: string): Promise<void>;
// Public URL to persist as the media record's fileUrl.
publicUrl(key: string): string;
}
/** Whether S3-compatible storage is configured. */
export function isS3Enabled(): boolean {
return !!(process.env.S3_ENDPOINT && process.env.S3_BUCKET);
}
/** Extract the storage key (object name) from a stored fileUrl. */
export function keyFromUrl(fileUrl: string): string {
return fileUrl.split('/').pop() || fileUrl;
}
// ==================== Local implementation ====================
class LocalStorage implements Storage {
readonly backend = 'local' as const;
private async ensureDir(): Promise<void> {
if (!existsSync(UPLOAD_DIR)) {
await mkdir(UPLOAD_DIR, { recursive: true });
}
}
async put(key: string, buffer: Buffer): Promise<void> {
await this.ensureDir();
await writeFile(join(UPLOAD_DIR, key), buffer);
}
async delete(key: string): Promise<void> {
const filepath = join(UPLOAD_DIR, key);
if (existsSync(filepath)) {
await unlink(filepath);
}
}
publicUrl(key: string): string {
return `/uploads/${key}`;
}
}
// ==================== S3 implementation ====================
// Imported lazily so the AWS SDK is only loaded when S3 is actually configured.
type S3ClientType = import('@aws-sdk/client-s3').S3Client;
class S3Storage implements Storage {
readonly backend = 's3' as const;
private client: S3ClientType | null = null;
private bucket = process.env.S3_BUCKET as string;
private async getClient(): Promise<S3ClientType> {
if (this.client) return this.client;
const { S3Client } = await import('@aws-sdk/client-s3');
const forcePathStyle = (process.env.S3_FORCE_PATH_STYLE || 'true') !== 'false';
this.client = new S3Client({
endpoint: process.env.S3_ENDPOINT,
region: process.env.S3_REGION || 'us-east-1',
forcePathStyle,
credentials:
process.env.S3_ACCESS_KEY_ID && process.env.S3_SECRET_ACCESS_KEY
? {
accessKeyId: process.env.S3_ACCESS_KEY_ID,
secretAccessKey: process.env.S3_SECRET_ACCESS_KEY,
}
: undefined,
});
return this.client;
}
async put(key: string, buffer: Buffer, contentType: string): Promise<void> {
const client = await this.getClient();
const { PutObjectCommand } = await import('@aws-sdk/client-s3');
await client.send(
new PutObjectCommand({
Bucket: this.bucket,
Key: key,
Body: buffer,
ContentType: contentType,
})
);
}
async delete(key: string): Promise<void> {
const client = await this.getClient();
const { DeleteObjectCommand } = await import('@aws-sdk/client-s3');
await client.send(
new DeleteObjectCommand({
Bucket: this.bucket,
Key: key,
})
);
}
publicUrl(key: string): string {
// Prefer an explicit public base URL (e.g. a CDN or Garage web endpoint).
const base = process.env.S3_PUBLIC_URL;
if (base) {
return `${base.replace(/\/$/, '')}/${key}`;
}
// Fall back to a path-style URL against the configured endpoint.
const endpoint = (process.env.S3_ENDPOINT || '').replace(/\/$/, '');
return `${endpoint}/${this.bucket}/${key}`;
}
}
// ==================== Selection ====================
let instance: Storage | null = null;
export function getStorage(): Storage {
if (!instance) {
instance = isS3Enabled() ? new S3Storage() : new LocalStorage();
}
return instance;
}