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.
164 lines
7 KiB
JavaScript
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;
|