pediatric-ai-scribe-v3/src/utils/objectStorage.js
Daniel f89dc01729 refactor: one place decides which bucket, on which S3, with which credentials
Three S3 configurations had grown separately — S3_* for documents,
GENERATED_IMAGES_S3_* for images, and AUDIO_BACKUPS_S3_* after them — with
different key names and their own client construction. That is why moving
storage meant hunting through several files.

src/utils/objectStorage.js now resolves settings for any purpose: its own
variables first, then the shared S3_* ones, with a per-purpose bucket name
(S3_BUCKET_AUDIO_BACKUPS). One endpoint plus three bucket names is enough
for the whole app, and a purpose that needs its own account still overrides
everything. Audio backups and documents use it; generated images keeps its
own tested storage module, whose variable names the resolver already
understands.

Nothing existing has to change: S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY and
the AWS_* fallbacks still resolve, and path-style addressing keeps each
purpose's previous default — off for documents, so a Backblaze endpoint
behaves as before, on where a custom endpoint implies MinIO. A _FILE
credential now always beats an inline one, so a mounted secret cannot be
shadowed by an inherited environment variable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-10 17:09:13 +02:00

115 lines
4.8 KiB
JavaScript

// ============================================================
// OBJECT STORAGE SETTINGS
// One place that answers "which bucket, on which S3, with which credentials",
// so pointing the whole app at a different MinIO — or at a real S3 — is one set
// of variables rather than three schemes that grew separately.
//
// Each purpose (documents, generated images, audio backups) reads, in order:
//
// 1. its own variables AUDIO_BACKUPS_S3_ENDPOINT, ...
// 2. the shared ones S3_ENDPOINT, S3_REGION, S3_ACCESS_KEY, ...
// 3. a per-purpose bucket S3_BUCKET_AUDIO_BACKUPS
//
// So one external endpoint plus three bucket names is enough, while a purpose
// that needs its own account keeps overriding everything. Credentials can be
// given inline or, preferably, as a path to a file — a file keeps them out of
// the process environment, which is where `docker inspect` reads from.
// ============================================================
var fs = require('fs');
// Historic names, kept working so no deployment has to be edited to upgrade.
var LEGACY = {
documents: {
endpoint: ['S3_ENDPOINT'],
region: ['S3_REGION', 'AWS_BEDROCK_REGION'],
bucket: ['S3_BUCKET'],
accessKey: ['S3_ACCESS_KEY_ID', 'AWS_ACCESS_KEY_ID'],
secretKey: ['S3_SECRET_ACCESS_KEY', 'AWS_SECRET_ACCESS_KEY'],
forcePathStyle: ['S3_FORCE_PATH_STYLE']
}
};
function prefixOf(purpose) {
return String(purpose).toUpperCase().replace(/[^A-Z0-9]+/g, '_') + '_S3_';
}
function firstValue(env, names) {
for (var i = 0; i < names.length; i++) {
var value = env[names[i]];
if (value !== undefined && value !== null && String(value) !== '') return String(value);
}
return '';
}
// A *_FILE variant always wins over an inline value: if someone has gone to the
// trouble of mounting a secret, an inherited environment variable must not
// quietly take precedence over it.
function readCredential(env, names) {
var fileNames = names.map(function (name) { return name + '_FILE'; });
var file = firstValue(env, fileNames);
if (file) {
try { return fs.readFileSync(file, 'utf8').trim(); }
catch (e) { throw new Error('Cannot read credential file ' + file + ': ' + e.message); }
}
return firstValue(env, names);
}
/**
* Resolve the settings for one purpose. Returns null when no endpoint and no
* bucket can be found, which callers read as "not configured" — never as an
* error, because every one of these is optional.
*/
function settingsFor(purpose, env) {
env = env || process.env;
var own = prefixOf(purpose);
var legacy = LEGACY[purpose] || {};
var endpoint = firstValue(env, [own + 'ENDPOINT'].concat(legacy.endpoint || [], ['S3_ENDPOINT']));
var bucket = firstValue(env, [own + 'BUCKET', 'S3_BUCKET_' + own.replace(/_S3_$/, '')].concat(legacy.bucket || []));
if (!bucket) return null;
var region = firstValue(env, [own + 'REGION'].concat(legacy.region || [], ['S3_REGION'])) || 'us-east-1';
var accessKey = readCredential(env, [own + 'ACCESS_KEY'].concat(legacy.accessKey || [], ['S3_ACCESS_KEY']));
var secretKey = readCredential(env, [own + 'SECRET_KEY'].concat(legacy.secretKey || [], ['S3_SECRET_KEY']));
// MinIO needs path-style addressing; hosted S3 does not care, and Backblaze
// prefers virtual-hosted. An explicit setting always wins. Where a purpose
// already had its own default, that default is kept: documents has always
// been off unless asked for, so a Backblaze endpoint keeps working.
var forced = firstValue(env, [own + 'FORCE_PATH_STYLE'].concat(legacy.forcePathStyle || [], ['S3_FORCE_PATH_STYLE']));
var forcePathStyle = forced ? forced === 'true' : (legacy.forcePathStyle ? false : !!endpoint);
return {
purpose: purpose,
endpoint: endpoint || undefined,
region: region,
bucket: bucket,
forcePathStyle: forcePathStyle,
credentials: (accessKey && secretKey) ? { accessKeyId: accessKey, secretAccessKey: secretKey } : undefined
};
}
function isConfigured(purpose, env) { return !!settingsFor(purpose, env); }
// Built per call site; callers cache their own client.
function createClient(settings) {
var { S3Client } = require('@aws-sdk/client-s3');
var config = {
region: settings.region,
maxAttempts: 2,
requestHandler: { connectionTimeout: 3000, requestTimeout: 20000 }
};
if (settings.endpoint) { config.endpoint = settings.endpoint; config.forcePathStyle = settings.forcePathStyle; }
if (settings.credentials) config.credentials = settings.credentials;
return new S3Client(config);
}
// Convenience: settings plus a client, or null when the purpose is unconfigured.
function storeFor(purpose, env) {
var settings = settingsFor(purpose, env);
if (!settings) return null;
return { settings: settings, Bucket: settings.bucket, client: createClient(settings) };
}
module.exports = { settingsFor, isConfigured, createClient, storeFor, prefixOf };