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