Files
cve-dashboard/backend/routes/knowledgeBase.js

417 lines
14 KiB
JavaScript
Raw Permalink Normal View History

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) => {
2026-02-17 08:52:26 -07:00
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()) {
2026-02-17 08:52:26 -07:00
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<object>} 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;