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>
137 lines
4.2 KiB
TypeScript
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;
|
|
}
|