const express = require('express'); const path = require('path'); const fs = require('fs'); const pool = require('../db'); const { requireAuth, requireGroup } = require('../middleware/auth'); const logAudit = require('../helpers/auditLog'); function createKnowledgeBaseRouter(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 router.post('/upload', requireAuth(), 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; 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' }); } 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 { // Check if slug already exists const { rows: existingRows } = await pool.query( 'SELECT id FROM knowledge_base WHERE slug = $1', [slug] ); const finalSlug = existingRows.length > 0 ? `${slug}-${timestamp}` : slug; // Insert new knowledge base entry const { rows } = await pool.query( `INSERT INTO knowledge_base ( title, slug, description, category, file_path, file_name, file_type, file_size, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) RETURNING id`, [ title.trim(), finalSlug, description || null, category || 'General', filePath, sanitizedName, uploadedFile.mimetype, uploadedFile.size, req.user.id ] ); // 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); } logAudit({ userId: req.user.id, username: req.user.username, action: 'CREATE_KB_ARTICLE', entityType: 'knowledge_base', entityId: String(rows[0].id), details: { title: title.trim(), filename: sanitizedName }, ipAddress: req.ip }); res.json({ success: true, id: rows[0].id, title: title.trim(), slug: finalSlug, category: category || 'General' }); } catch (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 router.get('/', requireAuth(), async (req, res) => { try { const { rows } = await pool.query(` 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 `); res.json(rows); } catch (err) { console.error('Error fetching knowledge base articles:', err); res.status(500).json({ error: 'Failed to fetch articles' }); } }); // GET /api/knowledge-base/:id router.get('/:id', requireAuth(), async (req, res) => { const { id } = req.params; try { const { rows } = await pool.query(` 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 = $1 `, [id]); if (!rows[0]) { return res.status(404).json({ error: 'Article not found' }); } res.json(rows[0]); } catch (err) { console.error('Error fetching article:', err); res.status(500).json({ error: 'Failed to fetch article' }); } }); // GET /api/knowledge-base/:id/content router.get('/:id/content', requireAuth(), async (req, res) => { const { id } = req.params; try { const { rows } = await pool.query( 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = $1', [id] ); const row = rows[0]; 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' }); } logAudit({ 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 }); let contentType = row.file_type || 'application/octet-stream'; 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); res.setHeader('Content-Disposition', `inline; filename="${safeFileName}"`); 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); } catch (err) { console.error('Error fetching document:', err); res.status(500).json({ error: 'Failed to fetch document' }); } }); // GET /api/knowledge-base/:id/download router.get('/:id/download', requireAuth(), async (req, res) => { const { id } = req.params; try { const { rows } = await pool.query( 'SELECT file_path, file_name, file_type FROM knowledge_base WHERE id = $1', [id] ); const row = rows[0]; 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' }); } logAudit({ 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); } catch (err) { console.error('Error fetching document:', err); res.status(500).json({ error: 'Failed to fetch document' }); } }); // DELETE /api/knowledge-base/:id router.delete('/:id', requireAuth(), requireGroup('Admin', 'Standard_User'), async (req, res) => { const { id } = req.params; try { const { rows } = await pool.query( 'SELECT file_path, title, created_by FROM knowledge_base WHERE id = $1', [id] ); const row = rows[0]; 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' }); } await pool.query('DELETE FROM knowledge_base WHERE id = $1', [id]); // Delete file if (fs.existsSync(row.file_path)) { fs.unlinkSync(row.file_path); } logAudit({ 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 }); } catch (err) { console.error('Error deleting article:', err); res.status(500).json({ error: 'Failed to delete article' }); } }); return router; } module.exports = createKnowledgeBaseRouter;