Some checks failed
Forgejo Android APK / Root app tests (push) Successful in 52s
Forgejo Docker Build / Root app tests (push) Successful in 52s
Forgejo Android APK / Build signed APK (push) Successful in 2m5s
Forgejo Docker Build / Build Docker image (push) Successful in 17s
Forgejo Docker Build / Deploy to the host (push) Failing after 0s
Eleven routers are mounted on '/api' and called router.use(authMiddleware) with no path. Mounted that way, the gate applies to every /api request that reaches the router — including routes belonging to routers mounted further down server.js. extensions.js did it from line 295; the assistant is mounted at 305. So a signed-out request to /api/clinical-assistant/status was refused ten lines before the preview middleware could look at it, whatever the admin setting said. server.js line 250 already warned about this shape. Each gate now names its own prefix, so a router protects its own routes and nothing else. Verified afterwards that every namespace which must stay shut still answers 401 signed out: extensions, encounters, memories, notes, diagrams, generated images, image jobs, documents, audio backups, ED encounters, don't-miss, patient education, billing, well visit, admin, transcribe and the rest. Two of these routers were gating routes nobody realised they were gating. Second defect in the same path: authMiddleware only ever looks for a token, so calling it unconditionally after the preview identity had been assigned rejected exactly the requests preview exists to serve. Only that identity may skip it; authMiddleware stays strict everywhere else. Preview now answers with a real cited answer, and stays as narrow as it was designed to be — four allow-listed paths, no identity, nothing ownable. A test now walks every /api router and fails on a blanket gate, which is how the last six were found. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
152 lines
6.7 KiB
JavaScript
152 lines
6.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');
|
|
|
|
// Scoped to this router's own prefix. Mounted on /api, a bare
|
|
// router.use(authMiddleware) gated every /api path — including routes owned by
|
|
// routers mounted after it in server.js.
|
|
router.use('/documents', 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;
|
|
// Settings come from src/utils/objectStorage.js, so documents, generated images
|
|
// and audio backups all resolve the same way: the purpose's own variables, then
|
|
// the shared S3_* ones. Every name this route has ever accepted still works.
|
|
function getS3Client() {
|
|
if (_s3Client) return _s3Client;
|
|
try {
|
|
var store = require('../utils/objectStorage').storeFor('documents');
|
|
if (!store) return null;
|
|
_s3Client = store.client;
|
|
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 document id:' + result.lastInsertRowid, 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;
|