pediatric-ai-scribe-v3/src/routes/documents.js
Daniel 54552df795 Stop leaking e.message to clients across all routes
88 occurrences of res.status(500).json({ error: e.message }) (or
err.message) swept to generic 'Request failed'. Server-side
console.error / logger.error calls are untouched, so the full detail
still lands in logs and Grafana.

Covers: admin, adminConfig, adminMilestones, chartReview, documents,
encounters, hospitalCourse, hpi, learningAdmin, learningAI, learningHub,
logs, memories, milestones, oidc, refine, sessions, sickVisit, soap,
userPreferences, wellVisit.

Also extends .gitignore to exclude .env.backup-* files.
2026-04-14 03:04:24 +02:00

164 lines
7 KiB
JavaScript

// ============================================================
// DOCUMENTS ROUTES — S3-backed document upload & management
// ============================================================
var express = require('express');
var router = express.Router();
var multer = require('multer');
var crypto = require('crypto');
var db = require('../db/database');
var { authMiddleware } = require('../middleware/auth');
var logger = require('../utils/logger');
router.use(authMiddleware);
var upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024 } // 10 MB
});
var ALLOWED_TYPES = [
'application/pdf',
'image/jpeg', 'image/png', 'image/gif',
'application/msword',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'text/plain', 'text/csv'
];
// Lazy-load S3 client
var _s3Client = null;
function getS3Client() {
if (_s3Client) return _s3Client;
try {
var { S3Client } = require('@aws-sdk/client-s3');
var region = process.env.S3_REGION || process.env.AWS_BEDROCK_REGION || 'us-east-1';
var config = { region: region };
// Custom endpoint for S3-compatible providers (Backblaze B2, MinIO, etc.)
if (process.env.S3_ENDPOINT) {
config.endpoint = process.env.S3_ENDPOINT;
config.forcePathStyle = process.env.S3_FORCE_PATH_STYLE === 'true'; // Required for MinIO
}
// Credentials: use S3-specific keys first, fall back to AWS keys
var accessKey = process.env.S3_ACCESS_KEY_ID || process.env.AWS_ACCESS_KEY_ID;
var secretKey = process.env.S3_SECRET_ACCESS_KEY || process.env.AWS_SECRET_ACCESS_KEY;
if (accessKey && secretKey) {
config.credentials = {
accessKeyId: accessKey,
secretAccessKey: secretKey
};
}
_s3Client = new S3Client(config);
return _s3Client;
} catch (e) {
return null;
}
}
function isS3Configured() {
return !!process.env.S3_BUCKET && !!getS3Client();
}
// ── GET list user documents ────────────────────────────────────────────
router.get('/documents', async function(req, res) {
try {
if (!isS3Configured()) return res.json({ success: true, documents: [], s3_configured: false });
var rows = await db.all(
'SELECT id, filename, mime_type, size_bytes, description, created_at FROM user_documents WHERE user_id = $1 ORDER BY created_at DESC',
[req.user.id]
);
res.json({ success: true, documents: rows, s3_configured: true });
} catch (e) { logger.error('GET /documents', e.message); res.status(500).json({ error: 'Could not list documents' }); }
});
// ── POST upload document ───────────────────────────────────────────────
router.post('/documents/upload', upload.single('file'), async function(req, res) {
try {
if (!isS3Configured()) return res.status(400).json({ error: 'S3 not configured. Set S3_BUCKET and S3_REGION in .env' });
if (!req.file) return res.status(400).json({ error: 'No file uploaded' });
if (!ALLOWED_TYPES.includes(req.file.mimetype)) {
return res.status(400).json({ error: 'File type not allowed. Supported: PDF, images, Word docs, text, CSV' });
}
// Magic-byte verification — browser-reported MIME can be spoofed.
var fileType = require('../utils/fileType');
if (!fileType.matches(req.file.mimetype, req.file.buffer)) {
return res.status(400).json({ error: 'File contents do not match the declared type.' });
}
var { PutObjectCommand } = require('@aws-sdk/client-s3');
var prefix = process.env.S3_PREFIX || 'documents/';
var uuid = crypto.randomUUID();
var s3Key = prefix + req.user.id + '/' + uuid + '/' + req.file.originalname;
await getS3Client().send(new PutObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: s3Key,
Body: req.file.buffer,
ContentType: req.file.mimetype,
ServerSideEncryption: 'AES256'
}));
var result = await db.run(
'INSERT INTO user_documents (user_id, s3_key, filename, mime_type, size_bytes, description) VALUES ($1,$2,$3,$4,$5,$6)',
[req.user.id, s3Key, req.file.originalname, req.file.mimetype, req.file.size, req.body.description || '']
);
res.json({ success: true, id: result.lastInsertRowid, filename: req.file.originalname });
logger.audit(req.user.id, 'document_upload', 'Uploaded: ' + (req.file ? req.file.originalname : 'unknown'), req, { category: 'documents' });
} catch (e) { logger.error('POST /documents/upload', e.message); res.status(500).json({ error: 'Upload failed' }); }
});
// ── GET download document (presigned URL) ──────────────────────────────
router.get('/documents/:id/download', async function(req, res) {
try {
if (!isS3Configured()) return res.status(400).json({ error: 'S3 not configured' });
var doc = await db.get(
'SELECT * FROM user_documents WHERE id = $1 AND user_id = $2',
[req.params.id, req.user.id]
);
if (!doc) return res.status(404).json({ error: 'Document not found' });
var { GetObjectCommand } = require('@aws-sdk/client-s3');
var { getSignedUrl } = require('@aws-sdk/s3-request-presigner');
var command = new GetObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: doc.s3_key,
ResponseContentDisposition: 'attachment; filename="' + doc.filename + '"'
});
var url = await getSignedUrl(getS3Client(), command, { expiresIn: 300 }); // 5 min
res.json({ success: true, url: url });
logger.audit(req.user.id, 'document_download', 'Downloaded document ' + req.params.id, req, { category: 'documents' });
} catch (e) { logger.error('GET /documents/:id/download', e.message); res.status(500).json({ error: 'Download failed' }); }
});
// ── DELETE document ────────────────────────────────────────────────────
router.delete('/documents/:id', async function(req, res) {
try {
if (!isS3Configured()) return res.status(400).json({ error: 'S3 not configured' });
var doc = await db.get(
'SELECT * FROM user_documents WHERE id = $1 AND user_id = $2',
[req.params.id, req.user.id]
);
if (!doc) return res.status(404).json({ error: 'Document not found' });
try {
var { DeleteObjectCommand } = require('@aws-sdk/client-s3');
await getS3Client().send(new DeleteObjectCommand({
Bucket: process.env.S3_BUCKET,
Key: doc.s3_key
}));
} catch (s3err) {
logger.warn('S3 delete failed (continuing with DB delete):', s3err.message);
}
await db.run('DELETE FROM user_documents WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]);
res.json({ success: true });
logger.audit(req.user.id, 'document_delete', 'Deleted document ' + req.params.id, req, { category: 'documents' });
} catch (e) { logger.error('DELETE /documents/:id', e.message); res.status(500).json({ error: 'Delete failed' }); }
});
module.exports = router;