573 lines
16 KiB
JavaScript
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;
|