diff --git a/services/mam-api/src/s3/client.js b/services/mam-api/src/s3/client.js index 5e8767c..b7f8829 100644 --- a/services/mam-api/src/s3/client.js +++ b/services/mam-api/src/s3/client.js @@ -1,66 +1,126 @@ -import { S3Client, GetObjectCommand, DeleteObjectCommand } from '@aws-sdk/client-s3'; +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'; -export const S3_BUCKET = process.env.S3_BUCKET || 'mam'; +// ── 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', +}; -const s3Client = new S3Client({ - region: process.env.S3_REGION || 'us-east-1', - endpoint: process.env.S3_ENDPOINT, - credentials: { - accessKeyId: process.env.S3_ACCESS_KEY, - secretAccessKey: process.env.S3_SECRET_KEY, - }, - forcePathStyle: true, - requestChecksumCalculation: "WHEN_REQUIRED", - responseChecksumValidation: "WHEN_REQUIRED", -}); +// ── 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', + }); +} -export { s3Client }; +let _client = buildClient(_cfg); -export const getSignedUrlForObject = async (key, expiresIn = 3600, contentType = null) => { +// ── 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) { return Reflect.get(_client, prop, _client); } } +); + +// ── Bucket getter — always returns the current bucket name ─────────────────── +export function getS3Bucket() { return _cfg.bucket; } + +// Legacy named export kept for any code that captured it at import time. +// Prefer getS3Bucket() in new code. +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 params = { Bucket: S3_BUCKET, Key: key }; - if (contentType) params.ResponseContentType = contentType; - const command = new GetObjectCommand(params); - const url = await getSignedUrl(s3Client, command, { expiresIn }); - return url; + 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('Error generating signed URL:', err); - throw 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) => { - try { - const upload = new Upload({ - client: s3Client, - params: { - Bucket: S3_BUCKET, - Key: key, - Body: stream, - ContentType: contentType, - }, - }); - - const result = await upload.done(); - return result; - } catch (err) { - console.error('Error uploading to S3:', err); - throw err; - } + const upload = new Upload({ + client: _client, + params: { Bucket: _cfg.bucket, Key: key, Body: stream, ContentType: contentType }, + }); + return upload.done(); }; export const deleteObject = async (key) => { - try { - const command = new DeleteObjectCommand({ - Bucket: S3_BUCKET, - Key: key, - }); - const result = await s3Client.send(command); - return result; - } catch (err) { - console.error('Error deleting from S3:', err); - throw err; - } + return _client.send(new DeleteObjectCommand({ Bucket: _cfg.bucket, Key: key })); };