feat(s3): dynamic DB-driven config with rebuildS3Client + Proxy export

This commit is contained in:
Zac Gaetano 2026-05-20 15:47:40 -04:00
parent beb58d3cd6
commit b1457f0aad

View file

@ -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 }));
};