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
125 lines
5.2 KiB
JavaScript
125 lines
5.2 KiB
JavaScript
// ============================================================
|
|
// AUDIO BACKUP STORE
|
|
// One place that knows how a recording is kept, so /api/transcribe and
|
|
// /api/audio-backups cannot drift apart. A recording is significant clinical
|
|
// material: every one is kept for 24 hours, whether its transcription
|
|
// succeeded or not, and it is always compressed and encrypted at rest.
|
|
//
|
|
// Payload goes to object storage when one is configured for the audio-backups
|
|
// purpose (see src/utils/objectStorage.js — its own AUDIO_BACKUPS_S3_* or the
|
|
// shared S3_* plus a bucket name), and to Postgres otherwise. Metadata (owner,
|
|
// module, sizes, expiry) always lives in Postgres, so listing, ownership and
|
|
// expiry work the same either way.
|
|
// ============================================================
|
|
|
|
var zlib = require('zlib');
|
|
var db = require('../db/database');
|
|
var cryptoUtil = require('./crypto');
|
|
|
|
var RETENTION_HOURS = 24;
|
|
|
|
function gzip(buffer) {
|
|
return new Promise(function(resolve, reject) {
|
|
zlib.gzip(buffer, { level: 6 }, function(err, result) { if (err) reject(err); else resolve(result); });
|
|
});
|
|
}
|
|
function gunzip(buffer) {
|
|
return new Promise(function(resolve, reject) {
|
|
zlib.gunzip(buffer, function(err, result) { if (err) reject(err); else resolve(result); });
|
|
});
|
|
}
|
|
|
|
var objectStorage = require('./objectStorage');
|
|
|
|
var _client = null;
|
|
function objectStore(env) {
|
|
if (_client) return _client;
|
|
_client = objectStorage.storeFor('audio-backups', env);
|
|
return _client;
|
|
}
|
|
|
|
function isObjectStoreConfigured(env) { return objectStorage.isConfigured('audio-backups', env); }
|
|
|
|
// Keyed by owner so one user's recordings can never be addressed by another,
|
|
// even if an id leaks.
|
|
function objectKey(userId, stamp) {
|
|
return 'recordings/' + userId + '/' + stamp + '-' + Math.random().toString(36).slice(2, 10);
|
|
}
|
|
|
|
// Returns the new row id. `module` records what produced it, and doubles as the
|
|
// marker for a recording whose transcription failed.
|
|
async function save(userId, module, buffer, mimeType) {
|
|
if (!buffer || !buffer.length) throw new Error('No audio to store');
|
|
var compressed = await gzip(buffer);
|
|
var stored = cryptoUtil.encryptBuffer(compressed);
|
|
|
|
var store = objectStore();
|
|
if (store) {
|
|
var { PutObjectCommand } = require('@aws-sdk/client-s3');
|
|
var key = objectKey(userId, Date.now());
|
|
await store.client.send(new PutObjectCommand({
|
|
Bucket: store.Bucket, Key: key, Body: stored, ContentType: 'application/octet-stream'
|
|
}));
|
|
var placed = await db.run(
|
|
'INSERT INTO audio_backups (user_id, module, mime_type, size_bytes, compressed_bytes, audio_data, storage_key) VALUES ($1,$2,$3,$4,$5,$6,$7)',
|
|
[userId, module, mimeType || 'audio/webm', buffer.length, stored.length, Buffer.alloc(0), key]
|
|
);
|
|
return { id: placed.lastInsertRowid, originalSize: buffer.length, compressedSize: compressed.length, storage: 'object' };
|
|
}
|
|
|
|
var row = await db.run(
|
|
'INSERT INTO audio_backups (user_id, module, mime_type, size_bytes, compressed_bytes, audio_data) VALUES ($1,$2,$3,$4,$5,$6)',
|
|
[userId, module, mimeType || 'audio/webm', buffer.length, stored.length, stored]
|
|
);
|
|
return { id: row.lastInsertRowid, originalSize: buffer.length, compressedSize: compressed.length, storage: 'database' };
|
|
}
|
|
|
|
// Ownership and expiry are enforced in the query, so an expired or borrowed id
|
|
// reads as missing rather than as someone else's recording.
|
|
async function load(id, userId) {
|
|
var row = await db.get(
|
|
'SELECT audio_data, storage_key, mime_type, size_bytes FROM audio_backups WHERE id = $1 AND user_id = $2 AND expires_at > NOW()',
|
|
[id, userId]
|
|
);
|
|
if (!row) return null;
|
|
var stored = row.audio_data;
|
|
if (row.storage_key) {
|
|
var store = objectStore();
|
|
if (!store) throw new Error('Recording is in object storage, which is not configured');
|
|
var { GetObjectCommand } = require('@aws-sdk/client-s3');
|
|
var response = await store.client.send(new GetObjectCommand({ Bucket: store.Bucket, Key: row.storage_key }));
|
|
var chunks = [];
|
|
for await (var chunk of response.Body) chunks.push(chunk);
|
|
stored = Buffer.concat(chunks);
|
|
}
|
|
// Rows written before encryption was added hold plain gzip; pass those
|
|
// through rather than failing to decrypt something that was never encrypted.
|
|
var compressed = cryptoUtil.isEncryptedBuffer(stored) ? cryptoUtil.decryptBuffer(stored) : stored;
|
|
return { buffer: await gunzip(compressed), mimeType: row.mime_type, size: row.size_bytes };
|
|
}
|
|
|
|
// Called for a user's delete and by the expiry sweep. The object goes first;
|
|
// a row without its object is unreadable, which is worse than an orphan object
|
|
// that the next sweep would remove anyway.
|
|
async function removeObject(storageKey) {
|
|
if (!storageKey) return;
|
|
var store = objectStore();
|
|
if (!store) return;
|
|
var { DeleteObjectCommand } = require('@aws-sdk/client-s3');
|
|
try {
|
|
await store.client.send(new DeleteObjectCommand({ Bucket: store.Bucket, Key: storageKey }));
|
|
} catch (e) {
|
|
// An object that cannot be deleted must not block the row's removal; it
|
|
// expires with the bucket's own lifecycle rule.
|
|
console.warn('[AudioBackup] object delete failed:', e.message);
|
|
}
|
|
}
|
|
|
|
module.exports = {
|
|
RETENTION_HOURS,
|
|
isObjectStoreConfigured,
|
|
save,
|
|
load,
|
|
removeObject,
|
|
_resetClientForTests: function() { _client = null; }
|
|
};
|