424 lines
12 KiB
JavaScript

import dotenv from 'dotenv';
import { fileModel } from '../../schemas/management/file.schema.js';
import log4js from 'log4js';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import mongoose from 'mongoose';
import {
deleteObject,
listObjects,
getObject,
editObject,
newObject,
listObjectsByProperties,
flushFile,
} from '../../database/database.js';
import {
uploadFile,
downloadFile,
deleteFile as deleteCephFile,
BUCKETS,
} from '../storage/ceph.js';
import { getFileMeta } from '../../utils.js';
dotenv.config();
const logger = log4js.getLogger('Files');
logger.level = process.env.LOG_LEVEL;
// Set storage engine to memory for Ceph upload
const fileStorage = multer.memoryStorage();
// Initialise upload
const fileUpload = multer({
storage: fileStorage,
limits: { fileSize: 500000000 }, // 500MB limit
fileFilter: function (req, file, cb) {
checkFileType(file, cb);
},
}).single('file'); // The name attribute of the file input in the HTML form
export const listFilesRouteHandler = async (
req,
res,
page = 1,
limit = 25,
property = '',
filter = {},
search = '',
sort = '',
order = 'ascend'
) => {
const result = await listObjects({
model: fileModel,
page,
limit,
property,
filter,
search,
sort,
order,
});
if (result?.error) {
logger.error('Error listing files.');
res.status(result.code).send(result);
return;
}
logger.debug(`List of files (Page ${page}, Limit ${limit}). Count: ${result.length}`);
res.send(result);
};
export const listFilesByPropertiesRouteHandler = async (
req,
res,
properties = '',
filter = {},
masterFilter = {}
) => {
const result = await listObjectsByProperties({
model: fileModel,
properties,
filter,
masterFilter,
});
if (result?.error) {
logger.error('Error listing files.');
res.status(result.code).send(result);
return;
}
logger.debug(`List of files. Count: ${result.length}`);
res.send(result);
};
export const getFileRouteHandler = async (req, res) => {
const id = req.params.id;
const result = await getObject({
model: fileModel,
id,
});
if (result?.error) {
logger.warn(`File not found with supplied id.`);
return res.status(result.code).send(result);
}
logger.debug(`Retreived file with ID: ${id}`);
res.send(result);
};
export const flushFileRouteHandler = async (req, res) => {
const id = req.params.id;
const result = await flushFile({ user: req.user, id });
res.send(result);
};
export const editFileRouteHandler = async (req, res) => {
// Get ID from params
const id = new mongoose.Types.ObjectId(req.params.id);
logger.trace(`File with ID: ${id}`);
const updateData = {
updatedAt: new Date(),
name: req.body.name,
};
const result = await editObject({
model: fileModel,
id,
updateData,
user: req.user,
});
if (result.error) {
logger.error('Error editing file:', result.error);
res.status(result).send(result);
return;
}
logger.debug(`Edited file with ID: ${id}`);
res.send(result);
};
export const newFileRouteHandler = async (req, res) => {
try {
fileUpload(req, res, async (err) => {
if (err) {
return res.status(500).send({
error: err,
});
}
if (req.file == undefined) {
return res.send({
message: 'No file selected!',
});
}
try {
// Create DB entry first without storage fields
const extension = path.extname(req.file.originalname);
const baseName = path.parse(req.file.originalname).name;
const meta = await getFileMeta(req.file);
const newData = {
name: baseName,
type: req.file.mimetype,
extension,
size: req.file.size,
updatedAt: new Date(),
metaData: {
originalName: req.file.originalname,
...meta,
},
};
const created = await newObject({
model: fileModel,
newData,
user: req.user,
});
if (created.error) {
logger.error('No file created:', created.error);
return res.status(created.code).send(created);
}
// Use created document _id to generate Ceph key
const cephKey = `files/${created._id}${extension}`;
// Upload file to Ceph
await uploadFile(BUCKETS.FILES, cephKey, req.file.buffer, req.file.mimetype, {
originalName: req.file.originalname,
uploadedBy: req.user?.username || 'unknown',
});
// Do not update DB with Ceph location. Return created DB record.
logger.debug(`New file with ID: ${created._id} created and uploaded to Ceph`);
res.send(created);
} catch (createError) {
logger.error('Error creating file record or uploading to storage:', createError);
// If we created the DB entry but upload failed, remove the DB entry to avoid orphaned records
try {
if (created && created._id) {
await deleteObject({ model: fileModel, id: created._id, user: req.user });
}
} catch (cleanupDbError) {
logger.error('Error cleaning up DB record after upload failure:', cleanupDbError);
}
res.status(500).send({ error: createError.message });
}
});
} catch (error) {
logger.error('Error in newFileRouteHandler:', error);
res.status(500).send({ error: error.message });
}
};
export const deleteFileRouteHandler = async (req, res) => {
// Get ID from params
const id = new mongoose.Types.ObjectId(req.params.id);
logger.trace(`File with ID: ${id}`);
try {
// First get the file to retrieve Ceph information
const file = await getObject({
model: fileModel,
id,
});
if (!file) {
return res.status(404).send({ error: 'File not found' });
}
// Delete from Ceph if it exists there
if (file.cephBucket && file.cephKey) {
try {
await deleteCephFile(file.cephBucket, file.cephKey);
logger.debug(`Deleted file from Ceph: ${file.cephKey}`);
} catch (cephError) {
logger.warn(`Failed to delete file from Ceph: ${cephError.message}`);
// Continue with database deletion even if Ceph deletion fails
}
}
const result = await deleteObject({
model: fileModel,
id,
user: req.user,
});
if (result.error) {
logger.error('No file deleted:', result.error);
return res.status(result.code).send(result);
}
logger.debug(`Deleted file with ID: ${result._id}`);
res.send(result);
} catch (error) {
logger.error('Error in deleteFileRouteHandler:', error);
res.status(500).send({ error: error.message });
}
};
// Check file type
function checkFileType(file, cb) {
// Allow all file types for general file management
// You can customize this to restrict specific file types if needed
const allowedTypes = /.*/; // Allow all file types
if (allowedTypes.test(file.mimetype)) {
console.log(file);
return cb(null, true);
} else {
cb('Error: File type not allowed!');
}
}
export const getFileContentRouteHandler = async (req, res) => {
try {
const id = req.params.id;
const file = await getObject({
model: fileModel,
id,
});
if (!file) {
logger.warn(`File not found with supplied id.`);
return res.status(404).send({ error: 'File not found.' });
}
logger.trace(`Returning file contents with ID: ${id}:`);
// Check if file is stored in Ceph
if (file._id && file.extension) {
const cephKey = `files/${id}${file.extension}`;
try {
const body = await downloadFile(BUCKETS.FILES, cephKey);
// Set appropriate content type and disposition
res.set('Content-Type', file.type || 'application/octet-stream');
res.set('Content-Disposition', `attachment; filename="${file.name}${file.extension}"`);
// Expose file size so clients can compute download progress
if (typeof file.size === 'number' && !Number.isNaN(file.size)) {
res.set('Content-Length', String(file.size));
}
// Stream or send buffer
if (body && typeof body.pipe === 'function') {
// Handle stream errors
body.on('error', (err) => {
logger.error('Error streaming file from Ceph:', err);
// If headers not sent, send a 500; otherwise destroy the response
if (!res.headersSent) {
try {
res.status(500).send({ error: 'Error streaming file from storage.' });
} catch (_) {
// Ignore secondary errors
}
} else {
res.destroy(err);
}
});
// If client disconnects, stop reading from source
res.on('close', () => {
if (typeof body.destroy === 'function') {
body.destroy();
}
});
body.pipe(res);
logger.debug('Retrieved:', cephKey);
return;
}
// Unknown body type
logger.error('Unknown Ceph body type; cannot send response');
return res.status(500).send({ error: 'Error reading file from storage.' });
} catch (cephError) {
logger.error('Error downloading file from Ceph:', cephError);
// Fall through to local filesystem fallback below
}
// Fallback to local file system for backward compatibility
const filePath = path.join(
process.env.FILE_STORAGE || './uploads',
file.fileName || file.name
);
// Read the file
fs.readFile(filePath, (err, data) => {
if (err) {
if (err.code === 'ENOENT') {
return res.status(404).send({ error: 'File not found!' });
}
return res.status(500).send({ error: 'Error reading file.' });
}
res.set('Content-Type', file.type || 'application/octet-stream');
res.set('Content-Disposition', `inline; filename="${file.name}${file.extension || ''}"`);
// Ensure Content-Length is set for progress events if possible.
const length =
typeof file.size === 'number' && !Number.isNaN(file.size) ? file.size : data.length;
res.set('Content-Length', String(length));
return res.send(data);
});
} else {
logger.error('Error fetching file:', 'No ceph bucket or key supplied.');
res.status(500).send({ error: 'No ceph bucket or key supplied.' });
}
} catch (error) {
logger.error('Error fetching file:', error);
res.status(500).send({ error: error.message });
}
};
export const parseFileHandler = async (req, res) => {
try {
// Use the same upload middleware as the uploadFileContentRouteHandler
fileUpload(req, res, async (err) => {
if (err) {
return res.status(500).send({
error: err,
});
}
if (req.file == undefined) {
return res.send({
message: 'No file selected!',
});
}
try {
// Read the file content from memory buffer
const fileContent = req.file.buffer.toString('utf8');
// Return basic file info as JSON
const fileInfo = {
filename: req.file.originalname,
originalName: req.file.originalname,
size: req.file.size,
mimetype: req.file.mimetype,
content: fileContent.substring(0, 1000), // First 1000 characters
};
res.json(fileInfo);
} catch (parseError) {
logger.error('Error parsing file:', parseError);
res.status(500).send({ error: parseError.message });
}
});
} catch (error) {
logger.error('Error in parseFileHandler:', error);
res.status(500).send({ error: error.message });
}
};