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;