import { S3Client, GetObjectCommand, DeleteObjectCommand, HeadBucketCommand, ListObjectsV2Command } from '@aws-sdk/client-s3'; import { getSignedUrl } from '@aws-sdk/s3-request-presigner'; import { Upload } from '@aws-sdk/lib-storage'; import pool from '../db/pool.js'; // ── Mutable config ──────────────────────────────────────────────────────────── let _cfg = { endpoint: process.env.S3_ENDPOINT || '', bucket: process.env.S3_BUCKET || 'mam', accessKey: process.env.S3_ACCESS_KEY || '', secretKey: process.env.S3_SECRET_KEY || '', region: process.env.S3_REGION || 'us-east-1', }; // ── Client factory ──────────────────────────────────────────────────────────── function buildClient(cfg) { return new S3Client({ region: cfg.region, endpoint: cfg.endpoint || undefined, credentials: { accessKeyId: cfg.accessKey, secretAccessKey: cfg.secretKey, }, forcePathStyle: true, requestChecksumCalculation: 'WHEN_REQUIRED', responseChecksumValidation: 'WHEN_REQUIRED', }); } let _client = buildClient(_cfg); // ── Proxy export — always delegates to the live _client ─────────────────────── // Existing code that does `s3Client.send(cmd)` continues to work even after // rebuildS3Client() replaces the underlying instance. export const s3Client = new Proxy( {}, { get(_target, prop) { // Bug #61: bind method calls to the current _client so `this` is correct // even after rebuildS3Client() replaces the underlying instance. const val = Reflect.get(_client, prop, _client); return typeof val === 'function' ? val.bind(_client) : val; }, } ); // ── Bucket getter — always returns the current bucket name ─────────────────── export function getS3Bucket() { return _cfg.bucket; } // @deprecated — import binding captures the value at module load time and // will NOT reflect updates after rebuildS3Client(). Use getS3Bucket() instead. export let S3_BUCKET = _cfg.bucket; // ── Rebuild — swap in new config at runtime (called by settings API) ───────── export function rebuildS3Client(overrides = {}) { _cfg = { ..._cfg, ...overrides }; S3_BUCKET = _cfg.bucket; _client = buildClient(_cfg); console.log('[s3] client rebuilt — endpoint:', _cfg.endpoint || '(AWS)', ' bucket:', _cfg.bucket); } // ── Load config from DB on startup ─────────────────────────────────────────── // Called in index.js after migrations. DB values override env-var defaults. export async function loadS3ConfigFromDb() { try { const result = await pool.query( `SELECT key, value FROM settings WHERE key IN ('s3_endpoint','s3_bucket','s3_access_key','s3_secret_key','s3_region') AND value IS NOT NULL AND value <> ''` ); if (result.rows.length === 0) return; const overrides = {}; for (const { key, value } of result.rows) { switch (key) { case 's3_endpoint': overrides.endpoint = value; break; case 's3_bucket': overrides.bucket = value; break; case 's3_access_key': overrides.accessKey = value; break; case 's3_secret_key': overrides.secretKey = value; break; case 's3_region': overrides.region = value; break; } } rebuildS3Client(overrides); console.log('[s3] config loaded from DB settings'); } catch (err) { console.error('[s3] failed to load config from DB:', err.message); } } // ── One-shot test client (does not replace _client) ────────────────────────── export function buildTestClient(cfg) { return buildClient({ endpoint: cfg.endpoint ?? _cfg.endpoint, bucket: cfg.bucket ?? _cfg.bucket, accessKey: cfg.accessKey ?? _cfg.accessKey, secretKey: cfg.secretKey ?? _cfg.secretKey, region: cfg.region ?? _cfg.region, }); } // ── Connectivity test (used by /settings/s3/test) ──────────────────────────── export async function testS3Connection(client, bucket) { try { await client.send(new HeadBucketCommand({ Bucket: bucket })); return { ok: true, message: `Bucket "${bucket}" is accessible` }; } catch (headErr) { // Some S3-compatible stores don't implement HeadBucket — try a list try { await client.send(new ListObjectsV2Command({ Bucket: bucket, MaxKeys: 0 })); return { ok: true, message: `Bucket "${bucket}" is accessible` }; } catch (listErr) { throw new Error(listErr.message || headErr.message); } } } // ── Utility helpers (all reference the live _client / _cfg) ────────────────── export const getSignedUrlForObject = async (key, expiresIn = 3600, contentType = null) => { const params = { Bucket: _cfg.bucket, Key: key }; if (contentType) params.ResponseContentType = contentType; return getSignedUrl(_client, new GetObjectCommand(params), { expiresIn }); }; export const uploadStream = async (key, stream, contentType) => { const upload = new Upload({ client: _client, params: { Bucket: _cfg.bucket, Key: key, Body: stream, ContentType: contentType }, }); return upload.done(); }; export const deleteObject = async (key) => { return _client.send(new DeleteObjectCommand({ Bucket: _cfg.bucket, Key: key })); };