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 * * Uploads a new knowledge base document. * * @body {FormData} file - The file to upload (max 10MB) * @body {string} title - Document title (required) * @body {string} [description] - Optional description * @body {string} [category] - Category name (defaults to 'General') * * @returns {object} 200 - { success: true, id, title, slug, category } * @returns {object} 400 - { error } if title missing, no file, or invalid file type * @returns {object} 500 - { error } on server failure * * @requires Admin | Standard_User */ 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 * * Lists all knowledge base articles, ordered by creation date descending. * * @returns {Array} 200 - Array of articles with fields: * id, title, slug, description, category, file_name, file_type, * file_size, created_at, updated_at, created_by_username * @returns {object} 500 - { error } on server failure * * @requires Authenticated user (any group) */ 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 * * Retrieves metadata for a single knowledge base article. * * @param {number} id - Article ID (route param) * * @returns {object} 200 - Article metadata: id, title, slug, description, * category, file_name, file_type, file_size, created_at, updated_at, * created_by_username * @returns {object} 404 - { error: 'Article not found' } * @returns {object} 500 - { error } on server failure * * @requires Authenticated user (any group) */ 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 * * Serves the raw file content inline for rendering in the browser. * Sets Content-Security-Policy frame-ancestors for iframe embedding. * Logs a VIEW_KB_ARTICLE audit event. * * @param {number} id - Article ID (route param) * * @returns {file} 200 - Raw file content with appropriate Content-Type * @returns {object} 404 - { error } if article or file not found * @returns {object} 500 - { error } on server failure * * @requires Authenticated user (any group) */ 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' }); } // Resolve relative paths against the backend directory const absoluteFilePath = path.isAbsolute(row.file_path) ? row.file_path : path.resolve(path.join(__dirname, '..'), row.file_path); if (!fs.existsSync(absoluteFilePath)) { 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(absoluteFilePath); } catch (err) { console.error('Error fetching document:', err); res.status(500).json({ error: 'Failed to fetch document' }); } }); /** * GET /api/knowledge-base/:id/download * * Downloads the file as an attachment (Content-Disposition: attachment). * Logs a DOWNLOAD_KB_ARTICLE audit event. * * @param {number} id - Article ID (route param) * * @returns {file} 200 - File content with attachment disposition * @returns {object} 404 - { error } if article or file not found * @returns {object} 500 - { error } on server failure * * @requires Authenticated user (any group) */ 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' }); } // Resolve relative paths against the backend directory const absoluteFilePath = path.isAbsolute(row.file_path) ? row.file_path : path.resolve(path.join(__dirname, '..'), row.file_path); if (!fs.existsSync(absoluteFilePath)) { 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(absoluteFilePath); } catch (err) { console.error('Error fetching document:', err); res.status(500).json({ error: 'Failed to fetch document' }); } }); /** * DELETE /api/knowledge-base/:id * * Deletes a knowledge base article and its associated file from disk. * Standard_User can only delete articles they created; Admin can delete any. * Logs a DELETE_KB_ARTICLE audit event. * * @param {number} id - Article ID (route param) * * @returns {object} 200 - { success: true } * @returns {object} 403 - { error } if Standard_User tries to delete another user's article * @returns {object} 404 - { error: 'Article not found' } * @returns {object} 500 - { error } on server failure * * @requires Admin | Standard_User */ 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;