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
115 lines
4.8 KiB
JavaScript
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 };
|