pediatric-ai-scribe-v3/src/utils/audioBackupStore.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

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