res.sendFile requires an absolute path. Article #7 was stored with a relative path which caused the TypeError. Now both the content and download endpoints resolve relative paths against the backend directory before calling existsSync and sendFile.
417 lines
14 KiB
JavaScript
417 lines
14 KiB
JavaScript
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<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;
|