Fix KB content/download failing for relative file paths

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.
This commit is contained in:
Jordan Ramos
2026-06-16 13:23:20 -06:00
parent a8877728e0
commit 93efb70d1c

View File

@@ -40,7 +40,22 @@ function createKnowledgeBaseRouter(upload) {
return ALLOWED_EXTENSIONS.has(ext);
}
// POST /api/knowledge-base/upload
/**
* 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) {
@@ -144,7 +159,18 @@ function createKnowledgeBaseRouter(upload) {
}
});
// GET /api/knowledge-base
/**
* 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(`
@@ -163,7 +189,21 @@ function createKnowledgeBaseRouter(upload) {
}
});
// GET /api/knowledge-base/:id
/**
* 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;
@@ -189,7 +229,21 @@ function createKnowledgeBaseRouter(upload) {
}
});
// GET /api/knowledge-base/:id/content
/**
* 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;
@@ -203,7 +257,12 @@ function createKnowledgeBaseRouter(upload) {
return res.status(404).json({ error: 'Document not found' });
}
if (!fs.existsSync(row.file_path)) {
// 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' });
}
@@ -230,14 +289,27 @@ function createKnowledgeBaseRouter(upload) {
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);
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
/**
* 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;
@@ -251,7 +323,12 @@ function createKnowledgeBaseRouter(upload) {
return res.status(404).json({ error: 'Document not found' });
}
if (!fs.existsSync(row.file_path)) {
// 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' });
}
@@ -268,14 +345,29 @@ function createKnowledgeBaseRouter(upload) {
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);
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
/**
* 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;