makerstash/server/routes/models.js

573 lines
16 KiB
JavaScript

import express from 'express';
import multer from 'multer';
import path from 'path';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
import fs from 'fs';
import { v4 as uuidv4 } from 'uuid';
import archiver from 'archiver';
import db from '../database.js';
import { authenticateToken, optionalAuth } from '../middleware/auth.js';
import { generateThumbnail, deleteThumbnail } from '../services/thumbnailGenerator.js';
import { calculateCost, estimateWeight, getMaterials } from '../services/costCalculator.js';
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const router = express.Router();
// Configure multer for file uploads
const storage = multer.diskStorage({
destination: (req, file, cb) => {
const baseDir = process.env.UPLOAD_DIR || './uploads';
const filesDir = path.join(baseDir, 'files');
if (!fs.existsSync(filesDir)) {
fs.mkdirSync(filesDir, { recursive: true });
}
cb(null, filesDir);
},
filename: (req, file, cb) => {
const uniqueName = `${uuidv4()}${path.extname(file.originalname)}`;
cb(null, uniqueName);
}
});
const upload = multer({
storage,
fileFilter: (req, file, cb) => {
const allowedExtensions = ['.stl', '.obj', '.3mf', '.gcode', '.zip'];
const ext = path.extname(file.originalname).toLowerCase();
if (allowedExtensions.includes(ext)) {
cb(null, true);
} else {
cb(new Error('Invalid file type. Only STL, OBJ, 3MF, GCODE, and ZIP files are allowed.'));
}
},
limits: {
fileSize: 100 * 1024 * 1024 // 100MB limit
}
});
// Get all models
router.get('/', authenticateToken, (req, res) => {
const {
search,
tag,
collection,
fileType,
minSize,
maxSize,
hasSupports,
license,
sortBy = 'created_at',
sortOrder = 'DESC',
page = 1,
limit = 20
} = req.query;
const offset = (page - 1) * limit;
let query = `
SELECT DISTINCT m.*,
c.name as collection_name,
GROUP_CONCAT(t.name) as tags
FROM models m
LEFT JOIN collections c ON m.collection_id = c.id
LEFT JOIN model_tags mt ON m.id = mt.model_id
LEFT JOIN tags t ON mt.tag_id = t.id
WHERE m.user_id = ?
`;
const params = [req.user.id];
// Full-text search across all metadata fields
if (search) {
query += ` AND (
m.name LIKE ? OR
m.description LIKE ? OR
m.creator LIKE ? OR
m.notes LIKE ? OR
m.source_url LIKE ? OR
m.license LIKE ?
)`;
const searchPattern = `%${search}%`;
params.push(searchPattern, searchPattern, searchPattern, searchPattern, searchPattern, searchPattern);
}
if (tag) {
query += ` AND m.id IN (
SELECT mt.model_id FROM model_tags mt
JOIN tags t ON mt.tag_id = t.id
WHERE t.name = ?
)`;
params.push(tag);
}
if (collection) {
query += ` AND m.collection_id = ?`;
params.push(collection);
}
if (fileType) {
query += ` AND m.file_type = ?`;
params.push(fileType);
}
if (minSize) {
query += ` AND m.file_size >= ?`;
params.push(parseInt(minSize));
}
if (maxSize) {
query += ` AND m.file_size <= ?`;
params.push(parseInt(maxSize));
}
if (hasSupports !== undefined) {
query += ` AND m.is_supported = ?`;
params.push(hasSupports === 'true' ? 1 : 0);
}
if (license) {
query += ` AND m.license = ?`;
params.push(license);
}
// Validate sortBy to prevent SQL injection
const allowedSortFields = ['name', 'created_at', 'updated_at', 'file_size'];
const sortField = allowedSortFields.includes(sortBy) ? sortBy : 'created_at';
const sortDir = sortOrder.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
query += ` GROUP BY m.id ORDER BY m.${sortField} ${sortDir} LIMIT ? OFFSET ?`;
params.push(parseInt(limit), offset);
db.all(query, params, (err, models) => {
if (err) {
return res.status(500).json({ error: err.message });
}
// Parse tags from concatenated string to array
const modelsWithTags = models.map(model => ({
...model,
tags: model.tags ? model.tags.split(',') : []
}));
res.json({ models: modelsWithTags, page: parseInt(page), limit: parseInt(limit) });
});
});
// Get single model
router.get('/:id', optionalAuth, (req, res) => {
const query = `
SELECT m.*,
c.name as collection_name,
GROUP_CONCAT(t.name) as tags
FROM models m
LEFT JOIN collections c ON m.collection_id = c.id
LEFT JOIN model_tags mt ON m.id = mt.model_id
LEFT JOIN tags t ON mt.tag_id = t.id
WHERE m.id = ?
GROUP BY m.id
`;
db.get(query, [req.params.id], (err, model) => {
if (err) {
return res.status(500).json({ error: err.message });
}
if (!model) {
return res.status(404).json({ error: 'Model not found' });
}
model.tags = model.tags ? model.tags.split(',') : [];
// Get associated files
db.all('SELECT * FROM model_files WHERE model_id = ?', [model.id], (err, files) => {
if (err) {
return res.status(500).json({ error: err.message });
}
model.files = files;
res.json(model);
});
});
});
// Upload new model (supports single or multiple files)
router.post('/', authenticateToken, upload.array('files', 10), async (req, res) => {
// Support both single file (old format) and multiple files (new format)
const files = req.files || [];
if (files.length === 0 && req.file) {
// Fallback for single file upload
files.push(req.file);
}
if (files.length === 0) {
return res.status(400).json({ error: 'No files uploaded' });
}
const {
name,
description,
creator,
source_url,
notes,
is_supported,
collection_id,
license,
tags
} = req.body;
// Use first file as primary
const primaryFile = files[0];
const fileType = path.extname(primaryFile.originalname).toLowerCase();
// Generate thumbnail for primary file
let thumbnailPath = null;
try {
console.log(`Generating thumbnail for ${primaryFile.originalname}...`);
thumbnailPath = await generateThumbnail(primaryFile.path, fileType);
if (thumbnailPath) {
console.log(`Thumbnail generated: ${thumbnailPath}`);
}
} catch (error) {
console.error('Error generating thumbnail:', error);
// Continue even if thumbnail generation fails
}
const modelData = {
name: name || path.basename(primaryFile.originalname, path.extname(primaryFile.originalname)),
description: description || null,
file_path: primaryFile.path,
file_name: primaryFile.originalname,
file_size: primaryFile.size,
file_type: fileType,
preview_image: thumbnailPath,
creator: creator || null,
source_url: source_url || null,
notes: notes || null,
is_supported: is_supported === 'true' ? 1 : 0,
license: license || 'Unknown',
user_id: req.user.id,
collection_id: collection_id || null
};
const query = `
INSERT INTO models (
name, description, file_path, file_name, file_size, file_type,
preview_image, creator, source_url, notes, is_supported, license, user_id, collection_id
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`;
db.run(
query,
Object.values(modelData),
function (err) {
if (err) {
return res.status(500).json({ error: err.message });
}
const modelId = this.lastID;
// Handle additional files (if multiple files uploaded)
if (files.length > 1) {
for (let i = 1; i < files.length; i++) {
const additionalFile = files[i];
db.run(
'INSERT INTO model_files (model_id, file_path, file_name, file_size, file_type, is_primary) VALUES (?, ?, ?, ?, ?, ?)',
[modelId, additionalFile.path, additionalFile.originalname, additionalFile.size, path.extname(additionalFile.originalname).toLowerCase(), 0]
);
}
}
// Handle tags if provided
if (tags) {
const tagArray = JSON.parse(tags);
tagArray.forEach(tagName => {
db.run('INSERT OR IGNORE INTO tags (name) VALUES (?)', [tagName], function () {
db.get('SELECT id FROM tags WHERE name = ?', [tagName], (err, tag) => {
if (tag) {
db.run('INSERT INTO model_tags (model_id, tag_id) VALUES (?, ?)', [modelId, tag.id]);
}
});
});
});
}
res.status(201).json({
message: files.length > 1 ? `Model uploaded with ${files.length} files` : 'Model uploaded successfully',
modelId,
model: { id: modelId, ...modelData },
fileCount: files.length
});
}
);
});
// Update model metadata
router.put('/:id', authenticateToken, (req, res) => {
const {
name,
description,
creator,
source_url,
notes,
is_supported,
collection_id,
tags
} = req.body;
const updates = [];
const params = [];
if (name !== undefined) {
updates.push('name = ?');
params.push(name);
}
if (description !== undefined) {
updates.push('description = ?');
params.push(description);
}
if (creator !== undefined) {
updates.push('creator = ?');
params.push(creator);
}
if (source_url !== undefined) {
updates.push('source_url = ?');
params.push(source_url);
}
if (notes !== undefined) {
updates.push('notes = ?');
params.push(notes);
}
if (is_supported !== undefined) {
updates.push('is_supported = ?');
params.push(is_supported ? 1 : 0);
}
if (collection_id !== undefined) {
updates.push('collection_id = ?');
params.push(collection_id);
}
updates.push('updated_at = CURRENT_TIMESTAMP');
params.push(req.params.id);
const query = `UPDATE models SET ${updates.join(', ')} WHERE id = ?`;
db.run(query, params, function (err) {
if (err) {
return res.status(500).json({ error: err.message });
}
if (this.changes === 0) {
return res.status(404).json({ error: 'Model not found' });
}
// Update tags if provided
if (tags !== undefined) {
db.run('DELETE FROM model_tags WHERE model_id = ?', [req.params.id], () => {
const tagArray = Array.isArray(tags) ? tags : JSON.parse(tags);
tagArray.forEach(tagName => {
db.run('INSERT OR IGNORE INTO tags (name) VALUES (?)', [tagName], function () {
db.get('SELECT id FROM tags WHERE name = ?', [tagName], (err, tag) => {
if (tag) {
db.run('INSERT INTO model_tags (model_id, tag_id) VALUES (?, ?)', [req.params.id, tag.id]);
}
});
});
});
});
}
res.json({ message: 'Model updated successfully' });
});
});
// Delete model
router.delete('/:id', authenticateToken, (req, res) => {
db.get('SELECT file_path, preview_image FROM models WHERE id = ?', [req.params.id], (err, model) => {
if (err) {
return res.status(500).json({ error: err.message });
}
if (!model) {
return res.status(404).json({ error: 'Model not found' });
}
// Delete file from disk
if (fs.existsSync(model.file_path)) {
fs.unlinkSync(model.file_path);
}
// Delete thumbnail if exists
if (model.preview_image) {
deleteThumbnail(model.preview_image);
}
// Delete from database
db.run('DELETE FROM models WHERE id = ?', [req.params.id], function (err) {
if (err) {
return res.status(500).json({ error: err.message });
}
res.json({ message: 'Model deleted successfully' });
});
});
});
// Download model file(s)
router.get('/:id/download', (req, res) => {
db.get('SELECT id, name, file_path, file_name FROM models WHERE id = ?', [req.params.id], (err, model) => {
if (err) {
return res.status(500).json({ error: err.message });
}
if (!model) {
return res.status(404).json({ error: 'Model not found' });
}
// Check if there are additional files
db.all('SELECT * FROM model_files WHERE model_id = ?', [model.id], (err, additionalFiles) => {
if (err) {
return res.status(500).json({ error: err.message });
}
// If only one file, download it directly
if (!additionalFiles || additionalFiles.length === 0) {
return res.download(model.file_path, model.file_name);
}
// Multiple files - create a ZIP
const zipName = `${model.name.replace(/[^a-z0-9]/gi, '_')}-files.zip`;
res.setHeader('Content-Type', 'application/zip');
res.setHeader('Content-Disposition', `attachment; filename="${zipName}"`);
const archive = archiver('zip', { zlib: { level: 9 } });
archive.pipe(res);
// Add primary file
if (fs.existsSync(model.file_path)) {
archive.file(model.file_path, { name: model.file_name });
}
// Add additional files
additionalFiles.forEach(file => {
if (fs.existsSync(file.file_path)) {
archive.file(file.file_path, { name: file.file_name });
}
});
archive.finalize();
});
});
});
// Serve primary file for viewing (not download)
router.get('/:id/file/primary', (req, res) => {
db.get('SELECT file_path, file_name, file_type FROM models WHERE id = ?', [req.params.id], (err, model) => {
if (err) {
return res.status(500).json({ error: err.message });
}
if (!model) {
return res.status(404).json({ error: 'Model not found' });
}
// Check if file exists
if (!fs.existsSync(model.file_path)) {
return res.status(404).json({ error: 'File not found on disk' });
}
// Send file with appropriate content type (no download prompt)
res.sendFile(path.resolve(model.file_path));
});
});
// Serve specific additional file for viewing (not download)
router.get('/:modelId/file/:fileId', (req, res) => {
db.get('SELECT * FROM model_files WHERE id = ? AND model_id = ?',
[req.params.fileId, req.params.modelId],
(err, file) => {
if (err) {
return res.status(500).json({ error: err.message });
}
if (!file) {
return res.status(404).json({ error: 'File not found' });
}
// Check if file exists
if (!fs.existsSync(file.file_path)) {
return res.status(404).json({ error: 'File not found on disk' });
}
// Send file with appropriate content type (no download prompt)
res.sendFile(path.resolve(file.file_path));
}
);
});
// Calculate filament/resin cost for a single model
router.get('/:id/cost', optionalAuth, (req, res) => {
const { materialType = 'pla' } = req.query;
db.get('SELECT * FROM models WHERE id = ?', [req.params.id], (err, model) => {
if (err) {
return res.status(500).json({ error: err.message });
}
if (!model) {
return res.status(404).json({ error: 'Model not found' });
}
const costEstimate = calculateCost(model.file_size, materialType);
res.json({
modelId: model.id,
name: model.name,
fileName: model.file_name,
fileSize: model.file_size,
...costEstimate
});
});
});
// Calculate costs for multiple models
router.post('/batch/cost', authenticateToken, (req, res) => {
const { modelIds = [], materialType = 'pla' } = req.body;
if (!Array.isArray(modelIds) || modelIds.length === 0) {
return res.status(400).json({ error: 'modelIds must be a non-empty array' });
}
const placeholders = modelIds.map(() => '?').join(',');
const query = `SELECT id, name, file_name, file_size FROM models WHERE id IN (${placeholders}) AND user_id = ?`;
db.all(query, [...modelIds, req.user.id], (err, models) => {
if (err) {
return res.status(500).json({ error: err.message });
}
const costEstimates = models.map(model => ({
modelId: model.id,
name: model.name,
fileName: model.file_name,
fileSize: model.file_size,
...calculateCost(model.file_size, materialType)
}));
const totalCost = costEstimates.reduce((sum, item) => sum + item.estimatedCost, 0);
res.json({
materialType,
models: costEstimates,
totalCost: Math.round(totalCost * 100) / 100,
averageCost: Math.round((totalCost / costEstimates.length) * 100) / 100
});
});
});
// Get available materials and costs
router.get('/config/materials', (req, res) => {
const materials = getMaterials();
res.json({
materials: materials.map(m => ({
type: m.type,
costPerUnit: m.costPerUnit,
density: m.density
}))
});
});
export default router;