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>
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
// 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;
|
||||
}
|
||||
Reference in New Issue
Block a user