Some checks failed
farmcontrol/farmcontrol-api/pipeline/head There was a failure building this commit
537 lines
15 KiB
JavaScript
537 lines
15 KiB
JavaScript
import config from '../../config.js';
|
|
import { fileModel } from '../../database/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,
|
|
getModelStats,
|
|
getModelHistory,
|
|
searchObjects,
|
|
getPropertyValues,
|
|
} from '../../database/database.js';
|
|
import {
|
|
uploadFile,
|
|
downloadFile,
|
|
deleteFile as deleteCephFile,
|
|
BUCKETS,
|
|
} from '../../database/ceph.js';
|
|
import { getFileMeta } from '../../utils.js';
|
|
|
|
const logger = log4js.getLogger('Files');
|
|
logger.level = config.server.logLevel;
|
|
|
|
const getDownloadContentType = (file) => {
|
|
// G-code is text, but it must be transferred byte-for-byte. Some slicers
|
|
// report text/plain, causing proxies to compress an otherwise fixed-length
|
|
// response and browsers to reject it with a content-length network error.
|
|
if (file.extension?.toLowerCase() === '.gcode') {
|
|
return 'application/octet-stream';
|
|
}
|
|
|
|
return file.type || 'application/octet-stream';
|
|
};
|
|
|
|
// 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 getFilePropertyValuesRouteHandler = async (req, res, property) => {
|
|
const result = await getPropertyValues({
|
|
model: fileModel,
|
|
property,
|
|
});
|
|
res.send(result);
|
|
};
|
|
|
|
export const searchFilesRouteHandler = async (req, res, search) => {
|
|
const result = await searchObjects({
|
|
model: fileModel,
|
|
search,
|
|
});
|
|
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)) {
|
|
return cb(null, true);
|
|
} else {
|
|
cb('Error: File type not allowed!');
|
|
}
|
|
}
|
|
|
|
export const getFileContentRouteHandler = async (req, res) => {
|
|
const id = req.params.id;
|
|
let bytesSent = 0;
|
|
let sourceEnded = false;
|
|
|
|
logger.info(`File download requested: ${id}`);
|
|
|
|
req.once('aborted', () => {
|
|
logger.warn(`File download request aborted by client: ${id} (${bytesSent} bytes sent)`);
|
|
});
|
|
|
|
res.once('finish', () => {
|
|
logger.info(
|
|
`File download response finished: ${id} (status ${res.statusCode}, ${bytesSent} bytes sent)`
|
|
);
|
|
});
|
|
|
|
res.once('close', () => {
|
|
if (!res.writableFinished) {
|
|
logger.warn(
|
|
`File download response closed early: ${id} (${bytesSent} bytes sent, source ended: ${sourceEnded})`
|
|
);
|
|
}
|
|
});
|
|
|
|
try {
|
|
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}:`);
|
|
logger.debug(`File record loaded for download: ${id}`, {
|
|
name: file.name,
|
|
extension: file.extension,
|
|
recordedSize: file.size,
|
|
type: file.type,
|
|
});
|
|
|
|
// Check if file is stored in Ceph
|
|
if (file._id && file.extension) {
|
|
const cephKey = `files/${id}${file.extension}`;
|
|
logger.info(`Starting Ceph download: ${cephKey} (recorded size: ${file.size ?? 'unknown'})`);
|
|
|
|
try {
|
|
const body = await downloadFile(BUCKETS.FILES, cephKey);
|
|
logger.debug(`Ceph response stream opened: ${cephKey}`, {
|
|
readable: body?.readable,
|
|
destroyed: body?.destroyed,
|
|
});
|
|
|
|
// Preserve the stored bytes across reverse proxies. This also repairs
|
|
// downloads of existing slicer uploads recorded as text/plain.
|
|
res.set('Content-Type', getDownloadContentType(file));
|
|
res.set('Content-Disposition', `attachment; filename="${file.name}${file.extension}"`);
|
|
res.set('Cache-Control', 'private, no-transform');
|
|
|
|
// Stream or send buffer
|
|
if (body && typeof body.pipe === 'function') {
|
|
body.on('data', (chunk) => {
|
|
bytesSent += chunk.length;
|
|
});
|
|
|
|
body.on('end', () => {
|
|
sourceEnded = true;
|
|
logger.info(`Ceph stream ended: ${cephKey} (${bytesSent} bytes read)`);
|
|
|
|
if (
|
|
typeof file.size === 'number' &&
|
|
!Number.isNaN(file.size) &&
|
|
bytesSent !== file.size
|
|
) {
|
|
logger.warn(
|
|
`File size mismatch for ${cephKey}: database=${file.size}, streamed=${bytesSent}`
|
|
);
|
|
}
|
|
});
|
|
|
|
body.on('close', () => {
|
|
logger.debug(
|
|
`Ceph stream closed: ${cephKey} (${bytesSent} bytes read, ended: ${sourceEnded})`
|
|
);
|
|
});
|
|
|
|
// Handle stream errors
|
|
body.on('error', (err) => {
|
|
logger.error(
|
|
`Error streaming file from Ceph: ${cephKey} (${bytesSent} bytes read)`,
|
|
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(`Ceph stream piped to response: ${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(config.storage.fileStorage, file.fileName || file.name);
|
|
logger.info(`Falling back to local file download: ${filePath}`);
|
|
|
|
// 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.' });
|
|
}
|
|
|
|
bytesSent = data.length;
|
|
sourceEnded = true;
|
|
logger.info(`Local file read complete: ${filePath} (${data.length} bytes)`);
|
|
|
|
res.set('Content-Type', getDownloadContentType(file));
|
|
res.set('Content-Disposition', `inline; filename="${file.name}${file.extension || ''}"`);
|
|
res.set('Cache-Control', 'private, no-transform');
|
|
|
|
// 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 });
|
|
}
|
|
};
|
|
|
|
export const getFileStatsRouteHandler = async (req, res) => {
|
|
const result = await getModelStats({ model: fileModel });
|
|
if (result?.error) {
|
|
logger.error('Error fetching file stats:', result.error);
|
|
return res.status(result.code).send(result);
|
|
}
|
|
logger.trace('File stats:', result);
|
|
res.send(result);
|
|
};
|
|
|
|
export const getFileHistoryRouteHandler = async (req, res) => {
|
|
const from = req.query.from;
|
|
const to = req.query.to;
|
|
const result = await getModelHistory({ model: fileModel, from, to });
|
|
if (result?.error) {
|
|
logger.error('Error fetching file history:', result.error);
|
|
return res.status(result.code).send(result);
|
|
}
|
|
logger.trace('File history:', result);
|
|
res.send(result);
|
|
};
|