- App-layer AES-256-GCM crypto helper (src/utils/crypto.js) - Nextcloud tokens encrypted at rest; transparent migration on next use - Audio backups encrypted at rest (version byte 0x01 envelope); legacy rows still decrypt as-is until overwritten - argon2id password hashing via src/utils/passwords.js with bcrypt fallback; bcrypt hashes rehashed to argon2id on next successful login. argon2 package is optional — server keeps running with bcrypt only until npm install adds the native dep - PHI redactor for audit log details (src/utils/redact.js) — strips SSN, phone, email, DoB, long IDs; caps at 500 chars; detects note bodies - DOMPurify (cdnjs, SRI-pinned) replaces custom regex sanitizer in Learning Hub content rendering - SRI integrity hashes added for Font Awesome CSS and Chart.js - Magic-byte file-type verification on document uploads (src/utils/fileType.js) - Generic 500 error responses via src/utils/errors.js applied to nextcloud and audioBackups; full detail still logged server-side - DATA_ENCRYPTION_KEY env documented in .env.example Deploy: requires rebuild of the container image to pick up the new files and `npm install` (adds argon2). Existing users keep working because bcrypt stays available and crypto helpers pass through plaintext when the key is not yet set in dev.
98 lines
4.7 KiB
JavaScript
98 lines
4.7 KiB
JavaScript
// ============================================================
|
|
// AUDIO BACKUPS ROUTES — Server-side audio backup storage
|
|
// Stores compressed audio in PostgreSQL, auto-deletes after 24h
|
|
// ============================================================
|
|
|
|
var express = require('express');
|
|
var router = express.Router();
|
|
var zlib = require('zlib');
|
|
var multer = require('multer');
|
|
var db = require('../db/database');
|
|
var { authMiddleware } = require('../middleware/auth');
|
|
var cryptoUtil = require('../utils/crypto');
|
|
var { serverError } = require('../utils/errors');
|
|
|
|
// 25MB upload limit (same as transcribe)
|
|
var upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 25 * 1024 * 1024 } });
|
|
|
|
router.use(authMiddleware);
|
|
|
|
// ── POST save audio backup (compressed) ──────────────────────────────────
|
|
router.post('/audio-backups', upload.single('audio'), async function(req, res) {
|
|
console.log('[AudioBackup] POST received, hasFile=' + !!req.file + ', user=' + (req.user ? req.user.id : 'none'));
|
|
try {
|
|
if (!req.file) return res.status(400).json({ error: 'No audio file' });
|
|
|
|
var module = req.body.module || 'encounter';
|
|
var originalSize = req.file.size;
|
|
|
|
// Gzip compress, then AES-256-GCM encrypt the audio data.
|
|
var compressed = await new Promise(function(resolve, reject) {
|
|
zlib.gzip(req.file.buffer, { level: 6 }, function(err, result) {
|
|
if (err) reject(err); else resolve(result);
|
|
});
|
|
});
|
|
var stored = cryptoUtil.encryptBuffer(compressed);
|
|
|
|
var result = 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)',
|
|
[req.user.id, module, req.file.mimetype || 'audio/webm', originalSize, stored.length, stored]
|
|
);
|
|
|
|
var ratio = originalSize > 0 ? Math.round((1 - compressed.length / originalSize) * 100) : 0;
|
|
console.log('[AudioBackup] Saved ' + (originalSize / 1024).toFixed(0) + 'KB → ' + (compressed.length / 1024).toFixed(0) + 'KB (' + ratio + '% compression) id=' + result.lastInsertRowid + ' user=' + req.user.id);
|
|
|
|
res.json({ success: true, id: result.lastInsertRowid, originalSize: originalSize, compressedSize: compressed.length });
|
|
} catch (e) {
|
|
return serverError(res, 'AudioBackup save', e, 'Could not save audio backup');
|
|
}
|
|
});
|
|
|
|
// ── GET list audio backups ───────────────────────────────────────────────
|
|
router.get('/audio-backups', async function(req, res) {
|
|
try {
|
|
var rows = await db.all(
|
|
"SELECT id, module, mime_type, size_bytes, compressed_bytes, created_at, expires_at FROM audio_backups WHERE user_id = $1 AND expires_at > NOW() ORDER BY created_at DESC",
|
|
[req.user.id]
|
|
);
|
|
res.json({ success: true, backups: rows });
|
|
} catch (e) { return serverError(res, 'AudioBackup list', e, 'Could not list backups'); }
|
|
});
|
|
|
|
// ── GET download audio backup (decompressed) ─────────────────────────────
|
|
router.get('/audio-backups/:id/audio', async function(req, res) {
|
|
try {
|
|
var row = await db.get(
|
|
'SELECT audio_data, mime_type, size_bytes FROM audio_backups WHERE id = $1 AND user_id = $2 AND expires_at > NOW()',
|
|
[req.params.id, req.user.id]
|
|
);
|
|
if (!row) return res.status(404).json({ error: 'Backup not found or expired' });
|
|
|
|
// Decrypt (if encrypted row) then gunzip. Legacy rows are passed through.
|
|
var ciphertext = row.audio_data;
|
|
var gzipped = cryptoUtil.isEncryptedBuffer(ciphertext) ? cryptoUtil.decryptBuffer(ciphertext) : ciphertext;
|
|
var decompressed = await new Promise(function(resolve, reject) {
|
|
zlib.gunzip(gzipped, function(err, result) {
|
|
if (err) reject(err); else resolve(result);
|
|
});
|
|
});
|
|
|
|
res.setHeader('Content-Type', row.mime_type || 'audio/webm');
|
|
res.setHeader('Content-Length', decompressed.length);
|
|
res.send(decompressed);
|
|
} catch (e) { return serverError(res, 'AudioBackup read', e, 'Could not retrieve audio backup'); }
|
|
});
|
|
|
|
// ── DELETE audio backup ──────────────────────────────────────────────────
|
|
router.delete('/audio-backups/:id', async function(req, res) {
|
|
try {
|
|
var result = await db.run(
|
|
'DELETE FROM audio_backups WHERE id = $1 AND user_id = $2',
|
|
[req.params.id, req.user.id]
|
|
);
|
|
if (result.changes === 0) return res.status(404).json({ error: 'Not found' });
|
|
res.json({ success: true });
|
|
} catch (e) { return serverError(res, 'AudioBackup delete', e, 'Could not delete backup'); }
|
|
});
|
|
|
|
module.exports = router;
|