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