const express = require('express'); const path = require('path'); const fs = require('fs'); const { requireAuth, requireGroup } = require('../middleware/auth'); const logAudit = require('../helpers/auditLog'); function createKnowledgeBaseRouter(db, upload) { const router = express.Router(); // Helper to sanitize filename function sanitizePathSegment(segment) { if (!segment || typeof segment !== 'string') return ''; return segment .replace(/\0/g, '') .replace(/\.\./g, '') .replace(/[\/\\]/g, '') .trim(); } // Helper to generate slug from title function generateSlug(title) { return title .toLowerCase() .replace(/[^a-z0-9]+/g, '-') .replace(/^-+|-+$/g, '') .substring(0, 200); } // Helper to validate file type const ALLOWED_EXTENSIONS = new Set([ '.pdf', '.md', '.txt', '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx', '.html', '.htm', '.json', '.yaml', '.yml', '.png', '.jpg', '.jpeg', '.gif' ]); function isValidFileType(filename) { const ext = path.extname(filename).toLowerCase(); return ALLOWED_EXTENSIONS.has(ext); } /** * POST /api/knowledge-base/upload * Upload a new knowledge base document. * * @body {string} title - Article title (required) * @body {string} [description] - Article description * @body {string} [category] - Article category (defaults to 'General') * @body {File} file - The document file to upload (multipart/form-data) * * @response 200 - { success: true, id: number, title: string, slug: string, category: string } * @response 400 - { error: string } - Missing title, no file, or invalid file type * @response 500 - { error: string } - Database or filesystem error */ router.post('/upload', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res, next) => { upload.single('file')(req, res, (err) => { if (err) { console.error('[KB Upload] Multer error:', err); return res.status(400).json({ error: err.message || 'File upload failed' }); } next(); }); }, async (req, res) => { console.log('[KB Upload] Request received:', { hasFile: !!req.file, body: req.body, contentType: req.headers['content-type'] }); const uploadedFile = req.file; const { title, description, category } = req.body; // Validate required fields if (!title || !title.trim()) { console.error('[KB Upload] Error: Title is missing'); if (uploadedFile) fs.unlinkSync(uploadedFile.path); return res.status(400).json({ error: 'Title is required' }); } if (!uploadedFile) { return res.status(400).json({ error: 'No file uploaded' }); } // Validate file type if (!isValidFileType(uploadedFile.originalname)) { fs.unlinkSync(uploadedFile.path); return res.status(400).json({ error: 'File type not allowed' }); } const timestamp = Date.now(); const sanitizedName = sanitizePathSegment(uploadedFile.originalname); const slug = generateSlug(title); const kbDir = path.join(__dirname, '..', 'uploads', 'knowledge_base'); const filename = `${timestamp}_${sanitizedName}`; const filePath = path.join(kbDir, filename); try { // Keep file in temp location until DB insert succeeds // Check if slug already exists db.get('SELECT id FROM knowledge_base WHERE slug = ?', [slug], (err, row) => { if (err) { fs.unlinkSync(uploadedFile.path); console.error('Error checking slug:', err); return res.status(500).json({ error: 'Database error' }); } // If slug exists, append timestamp to make it unique const finalSlug = row ? `${slug}-${timestamp}` : slug; // Insert new knowledge base entry const insertSql = ` INSERT INTO knowledge_base ( title, slug, description, category, file_path, file_name, file_type, file_size, created_by ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) `; db.run( insertSql, [ title.trim(), finalSlug, description || null, category || 'General', filePath, sanitizedName, uploadedFile.mimetype, uploadedFile.size, req.user.id ], function (err) { if (err) { fs.unlinkSync(uploadedFile.path); console.error('Error inserting knowledge base entry:', err); return res.status(500).json({ error: 'Failed to save document metadata' }); } // DB insert succeeded — now move file to permanent location try { if (!fs.existsSync(kbDir)) { fs.mkdirSync(kbDir, { recursive: true }); } fs.renameSync(uploadedFile.path, filePath); } catch (moveErr) { console.error('Error moving file to permanent location:', moveErr); // File is orphaned in temp but DB record exists — log and continue } // Log audit entry logAudit(db, { userId: req.user.id, username: req.user.username, action: 'CREATE_KB_ARTICLE', entityType: 'knowledge_base', entityId: String(this.lastID), details: { title: title.trim(), filename: sanitizedName }, ipAddress: req.ip }); res.json({ success: true, id: this.lastID, title: title.trim(), slug: finalSlug, category: category || 'General' }); } ); }); } catch (error) { // Clean up temp file on error if (uploadedFile && fs.existsSync(uploadedFile.path)) fs.unlinkSync(uploadedFile.path); console.error('Error uploading knowledge base document:', error); res.status(500).json({ error: error.message || 'Failed to upload document' }); } }); /** * GET /api/knowledge-base * List all knowledge base articles. * * @response 200 - Array of article objects: [{ id, title, slug, description, category, file_name, file_type, file_size, created_at, updated_at, created_by_username }] * @response 500 - { error: string } */ router.get('/', requireAuth(db), (req, res) => { const sql = ` SELECT kb.id, kb.title, kb.slug, kb.description, kb.category, kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at, u.username as created_by_username FROM knowledge_base kb LEFT JOIN users u ON kb.created_by = u.id ORDER BY kb.created_at DESC `; db.all(sql, [], (err, rows) => { if (err) { console.error('Error fetching knowledge base articles:', err); return res.status(500).json({ error: 'Failed to fetch articles' }); } res.json(rows); }); }); /** * GET /api/knowledge-base/:id * Get a single article's details by ID. * * @param {string} id - Article ID (route parameter) * * @response 200 - { id, title, slug, description, category, file_name, file_type, file_size, created_at, updated_at, created_by_username } * @response 404 - { error: 'Article not found' } * @response 500 - { error: string } */ router.get('/:id', requireAuth(db), (req, res) => { const { id } = req.params; const sql = ` SELECT kb.id, kb.title, kb.slug, kb.description, kb.category, kb.file_name, kb.file_type, kb.file_size, kb.created_at, kb.updated_at, u.username as created_by_username FROM knowledge_base kb LEFT JOIN users u ON kb.created_by = u.id WHERE kb.id = ? `; db.get(sql, [id], (err, row) => { if (err) { console.error('Error fetching article:', err); return res.status(500).json({ error: 'Failed to fetch article' }); } if (!row) { return res.status(404).json({ error: 'Article not found' }); } res.json(row); }); }); /** * GET /api/knowledge-base/:id/content * Get document content for inline display. Returns the raw file with appropriate * Content-Type headers. Markdown and text files are served as text/plain. * * @param {string} id - Article ID (route parameter) * * @response 200 - Raw file content with Content-Type and Content-Disposition headers * @response 404 - { error: string } - Article or file not found * @response 500 - { error: string } */ router.get('/:id/content', requireAuth(db), (req, res) => { const { id } = req.params; const sql = 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = ?'; db.get(sql, [id], (err, row) => { if (err) { console.error('Error fetching document:', err); return res.status(500).json({ error: 'Failed to fetch document' }); } if (!row) { return res.status(404).json({ error: 'Document not found' }); } if (!fs.existsSync(row.file_path)) { return res.status(404).json({ error: 'File not found on disk' }); } // Log audit entry logAudit(db, { userId: req.user.id, username: req.user.username, action: 'VIEW_KB_ARTICLE', entityType: 'knowledge_base', entityId: String(id), details: { filename: row.file_name }, ipAddress: req.ip }); // Determine content type for inline display let contentType = row.file_type || 'application/octet-stream'; // For markdown files, send as plain text so frontend can parse it if (row.file_name.endsWith('.md')) { contentType = 'text/plain; charset=utf-8'; } else if (row.file_name.endsWith('.txt')) { contentType = 'text/plain; charset=utf-8'; } const safeFileName = row.file_name.replace(/["\r\n\\]/g, ''); res.setHeader('Content-Type', contentType); // Use inline instead of attachment to allow browser to display res.setHeader('Content-Disposition', `inline; filename="${safeFileName}"`); // Allow iframe embedding from frontend origin res.removeHeader('X-Frame-Options'); const corsOrigins = process.env.CORS_ORIGINS ? process.env.CORS_ORIGINS.split(',').join(' ') : 'http://localhost:3000'; res.setHeader('Content-Security-Policy', `frame-ancestors 'self' ${corsOrigins}`); res.sendFile(row.file_path); }); }); /** * GET /api/knowledge-base/:id/download * Download a knowledge base document as an attachment. * * @param {string} id - Article ID (route parameter) * * @response 200 - File download with Content-Disposition: attachment header * @response 404 - { error: string } - Article or file not found * @response 500 - { error: string } */ router.get('/:id/download', requireAuth(db), (req, res) => { const { id } = req.params; const sql = 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = ?'; db.get(sql, [id], (err, row) => { if (err) { console.error('Error fetching document:', err); return res.status(500).json({ error: 'Failed to fetch document' }); } if (!row) { return res.status(404).json({ error: 'Document not found' }); } if (!fs.existsSync(row.file_path)) { return res.status(404).json({ error: 'File not found on disk' }); } // Log audit entry logAudit(db, { userId: req.user.id, username: req.user.username, action: 'DOWNLOAD_KB_ARTICLE', entityType: 'knowledge_base', entityId: String(id), details: { filename: row.file_name }, ipAddress: req.ip }); const safeDownloadName = row.file_name.replace(/["\r\n\\]/g, ''); res.setHeader('Content-Type', row.file_type || 'application/octet-stream'); res.setHeader('Content-Disposition', `attachment; filename="${safeDownloadName}"`); res.sendFile(row.file_path); }); }); /** * DELETE /api/knowledge-base/:id * Delete a knowledge base article and its associated file. * Standard_User can only delete articles they created. Admin can delete any article. * * @param {string} id - Article ID (route parameter) * * @response 200 - { success: true } * @response 403 - { error: string } - Ownership check failed for Standard_User * @response 404 - { error: 'Article not found' } * @response 500 - { error: string } */ router.delete('/:id', requireAuth(db), requireGroup('Admin', 'Standard_User'), (req, res) => { const { id } = req.params; const sql = 'SELECT file_path, title, created_by FROM knowledge_base WHERE id = ?'; db.get(sql, [id], (err, row) => { if (err) { console.error('Error fetching article for deletion:', err); return res.status(500).json({ error: 'Failed to fetch article' }); } if (!row) { return res.status(404).json({ error: 'Article not found' }); } // Ownership check: Standard_User can only delete articles they created if (req.user.group === 'Standard_User' && row.created_by !== req.user.id) { return res.status(403).json({ error: 'You can only delete resources you created' }); } // Delete database record db.run('DELETE FROM knowledge_base WHERE id = ?', [id], (err) => { if (err) { console.error('Error deleting article:', err); return res.status(500).json({ error: 'Failed to delete article' }); } // Delete file if (fs.existsSync(row.file_path)) { fs.unlinkSync(row.file_path); } // Log audit entry logAudit(db, { userId: req.user.id, username: req.user.username, action: 'DELETE_KB_ARTICLE', entityType: 'knowledge_base', entityId: String(id), details: { title: row.title }, ipAddress: req.ip }); res.json({ success: true }); }); }); }); return router; } module.exports = createKnowledgeBaseRouter;