- Complete web-based STL file storage and 3D viewer - Express.js backend with SQLite database - Interactive Three.js 3D viewer with orbit controls - File upload with drag-and-drop support - Security features: rate limiting, input validation, helmet - Container deployment with Docker/Podman - Production-ready configuration management - Comprehensive logging and monitoring 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
365 lines
11 KiB
JavaScript
365 lines
11 KiB
JavaScript
const express = require('express');
|
|
const multer = require('multer');
|
|
const cors = require('cors');
|
|
const path = require('path');
|
|
const fs = require('fs').promises;
|
|
const { v4: uuidv4 } = require('uuid');
|
|
const { validationResult } = require('express-validator');
|
|
|
|
// Import custom modules
|
|
const config = require('./config/config');
|
|
const logger = require('./config/logger');
|
|
const {
|
|
apiLimiter,
|
|
uploadLimiter,
|
|
securityHeaders,
|
|
fileValidation,
|
|
fileIdValidation,
|
|
searchValidation
|
|
} = require('./config/security');
|
|
const STLDatabase = require('./database');
|
|
|
|
const app = express();
|
|
|
|
// Security middleware
|
|
app.use(securityHeaders);
|
|
app.use(cors({
|
|
origin: config.nodeEnv === 'production' ? false : true,
|
|
credentials: true
|
|
}));
|
|
|
|
// Rate limiting
|
|
app.use('/api/', apiLimiter);
|
|
app.use('/api/upload', uploadLimiter);
|
|
|
|
// Body parsing
|
|
app.use(express.json({ limit: '10mb' }));
|
|
app.use(express.urlencoded({ extended: true, limit: '10mb' }));
|
|
|
|
// Static files
|
|
app.use(express.static('public'));
|
|
|
|
const db = new STLDatabase(config.database.path, config.database.uploadDir);
|
|
|
|
// Enhanced file validation
|
|
const validateSTLFile = async (buffer) => {
|
|
// Check if it's a valid STL file by looking for STL signatures
|
|
const text = buffer.toString('utf8', 0, 80);
|
|
const isBinarySTL = buffer.length > 80 && buffer.readUInt32LE(80) > 0;
|
|
const isAsciiSTL = text.toLowerCase().includes('solid');
|
|
|
|
return isBinarySTL || isAsciiSTL;
|
|
};
|
|
|
|
const storage = multer.diskStorage({
|
|
destination: async (req, file, cb) => {
|
|
const uploadPath = path.join(__dirname, config.upload.destination);
|
|
try {
|
|
await fs.access(uploadPath);
|
|
} catch {
|
|
await fs.mkdir(uploadPath, { recursive: true });
|
|
}
|
|
cb(null, uploadPath);
|
|
},
|
|
filename: (req, file, cb) => {
|
|
// Sanitize filename
|
|
const sanitizedName = file.originalname.replace(/[^a-zA-Z0-9.-]/g, '_');
|
|
const uniqueName = `${uuidv4()}-${sanitizedName}`;
|
|
cb(null, uniqueName);
|
|
}
|
|
});
|
|
|
|
const fileFilter = async (req, file, cb) => {
|
|
const fileExt = path.extname(file.originalname).toLowerCase();
|
|
|
|
if (!config.upload.allowedExtensions.includes(fileExt)) {
|
|
return cb(new Error('Only STL files are allowed'), false);
|
|
}
|
|
|
|
// Additional MIME type check
|
|
const allowedMimeTypes = ['application/octet-stream', 'application/sla', 'text/plain'];
|
|
if (file.mimetype && !allowedMimeTypes.includes(file.mimetype)) {
|
|
return cb(new Error('Invalid file type'), false);
|
|
}
|
|
|
|
cb(null, true);
|
|
};
|
|
|
|
const upload = multer({
|
|
storage: storage,
|
|
fileFilter: fileFilter,
|
|
limits: {
|
|
fileSize: config.upload.maxFileSize,
|
|
files: config.upload.maxFiles
|
|
}
|
|
});
|
|
|
|
// Validation helper
|
|
const handleValidationErrors = (req, res, next) => {
|
|
const errors = validationResult(req);
|
|
if (!errors.isEmpty()) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Validation failed',
|
|
details: errors.array()
|
|
});
|
|
}
|
|
next();
|
|
};
|
|
|
|
app.post('/api/upload', fileValidation, handleValidationErrors, upload.single('stlFile'), async (req, res) => {
|
|
try {
|
|
if (!req.file) {
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'No file uploaded'
|
|
});
|
|
}
|
|
|
|
// Additional file content validation
|
|
const fileBuffer = await fs.readFile(req.file.path);
|
|
if (!await validateSTLFile(fileBuffer)) {
|
|
await fs.unlink(req.file.path);
|
|
return res.status(400).json({
|
|
success: false,
|
|
error: 'Invalid STL file format'
|
|
});
|
|
}
|
|
|
|
const fileData = {
|
|
filename: req.file.filename,
|
|
originalName: req.file.originalname,
|
|
filePath: req.file.path,
|
|
fileSize: req.file.size,
|
|
description: req.body.description || null,
|
|
tags: req.body.tags || null,
|
|
printSettings: req.body.printSettings ? JSON.parse(req.body.printSettings) : null,
|
|
dimensions: req.body.dimensions ? JSON.parse(req.body.dimensions) : null
|
|
};
|
|
|
|
const fileId = await db.addSTLFile(fileData);
|
|
const savedFile = await db.getSTLFile(fileId);
|
|
|
|
logger.info('File uploaded successfully', {
|
|
fileId,
|
|
filename: req.file.originalname,
|
|
size: req.file.size,
|
|
ip: req.ip
|
|
});
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'File uploaded successfully',
|
|
file: savedFile
|
|
});
|
|
} catch (error) {
|
|
logger.error('Upload error', { error: error.message, stack: error.stack });
|
|
|
|
if (req.file) {
|
|
try {
|
|
await fs.unlink(req.file.path);
|
|
} catch (unlinkError) {
|
|
logger.error('Error cleaning up file', { error: unlinkError.message });
|
|
}
|
|
}
|
|
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Upload failed',
|
|
message: config.nodeEnv === 'development' ? error.message : 'Internal server error'
|
|
});
|
|
}
|
|
});
|
|
|
|
app.get('/api/files', searchValidation, handleValidationErrors, async (req, res) => {
|
|
try {
|
|
const limit = Math.min(parseInt(req.query.limit) || 50, 100);
|
|
const offset = Math.max(parseInt(req.query.offset) || 0, 0);
|
|
const search = req.query.search;
|
|
|
|
let files;
|
|
if (search) {
|
|
files = await db.searchSTLFiles(search);
|
|
} else {
|
|
files = await db.getAllSTLFiles(limit, offset);
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
files: files,
|
|
pagination: {
|
|
limit: limit,
|
|
offset: offset,
|
|
hasMore: files.length === limit
|
|
}
|
|
});
|
|
} catch (error) {
|
|
logger.error('Get files error', { error: error.message, stack: error.stack });
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Failed to retrieve files',
|
|
message: config.nodeEnv === 'development' ? error.message : 'Internal server error'
|
|
});
|
|
}
|
|
});
|
|
|
|
app.get('/api/files/:id', fileIdValidation, handleValidationErrors, async (req, res) => {
|
|
try {
|
|
const fileId = parseInt(req.params.id);
|
|
const file = await db.getSTLFile(fileId);
|
|
|
|
if (!file) {
|
|
return res.status(404).json({
|
|
success: false,
|
|
error: 'File not found'
|
|
});
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
file: file
|
|
});
|
|
} catch (error) {
|
|
logger.error('Get file error', { error: error.message, fileId: req.params.id });
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Failed to retrieve file',
|
|
message: config.nodeEnv === 'development' ? error.message : 'Internal server error'
|
|
});
|
|
}
|
|
});
|
|
|
|
app.put('/api/files/:id', async (req, res) => {
|
|
try {
|
|
const fileId = parseInt(req.params.id);
|
|
const updates = {};
|
|
|
|
if (req.body.description !== undefined) updates.description = req.body.description;
|
|
if (req.body.tags !== undefined) updates.tags = req.body.tags;
|
|
if (req.body.printSettings !== undefined) updates.print_settings = req.body.printSettings;
|
|
if (req.body.dimensions !== undefined) updates.dimensions = req.body.dimensions;
|
|
|
|
const updated = await db.updateSTLFile(fileId, updates);
|
|
|
|
if (!updated) {
|
|
return res.status(404).json({ error: 'File not found' });
|
|
}
|
|
|
|
const updatedFile = await db.getSTLFile(fileId);
|
|
res.json({
|
|
success: true,
|
|
message: 'File updated successfully',
|
|
file: updatedFile
|
|
});
|
|
} catch (error) {
|
|
console.error('Update file error:', error);
|
|
res.status(500).json({
|
|
error: 'Failed to update file',
|
|
message: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
app.delete('/api/files/:id', async (req, res) => {
|
|
try {
|
|
const fileId = parseInt(req.params.id);
|
|
const deleted = await db.deleteSTLFile(fileId);
|
|
|
|
if (!deleted) {
|
|
return res.status(404).json({ error: 'File not found' });
|
|
}
|
|
|
|
res.json({
|
|
success: true,
|
|
message: 'File deleted successfully'
|
|
});
|
|
} catch (error) {
|
|
console.error('Delete file error:', error);
|
|
res.status(500).json({
|
|
error: 'Failed to delete file',
|
|
message: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
app.get('/api/files/:id/download', async (req, res) => {
|
|
try {
|
|
const fileId = parseInt(req.params.id);
|
|
const file = await db.getSTLFile(fileId);
|
|
|
|
if (!file) {
|
|
return res.status(404).json({ error: 'File not found' });
|
|
}
|
|
|
|
try {
|
|
await fs.access(file.file_path);
|
|
} catch {
|
|
return res.status(404).json({ error: 'Physical file not found' });
|
|
}
|
|
|
|
res.download(file.file_path, file.original_name);
|
|
} catch (error) {
|
|
console.error('Download error:', error);
|
|
res.status(500).json({
|
|
error: 'Failed to download file',
|
|
message: error.message
|
|
});
|
|
}
|
|
});
|
|
|
|
app.use('/uploads', express.static(path.join(__dirname, 'uploads'), {
|
|
setHeaders: (res, filePath) => {
|
|
if (path.extname(filePath).toLowerCase() === '.stl') {
|
|
res.setHeader('Content-Type', 'application/octet-stream');
|
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
}
|
|
}
|
|
}));
|
|
|
|
app.use((error, req, res, next) => {
|
|
if (error instanceof multer.MulterError) {
|
|
if (error.code === 'LIMIT_FILE_SIZE') {
|
|
return res.status(400).json({ error: 'File too large (max 100MB)' });
|
|
}
|
|
}
|
|
|
|
console.error('Unhandled error:', error);
|
|
res.status(500).json({
|
|
error: 'Internal server error',
|
|
message: error.message
|
|
});
|
|
});
|
|
|
|
async function startServer() {
|
|
try {
|
|
await db.initialize();
|
|
logger.info('Database initialized successfully');
|
|
|
|
app.listen(config.port, config.host, () => {
|
|
logger.info('STL Storage server started', {
|
|
host: config.host,
|
|
port: config.port,
|
|
nodeEnv: config.nodeEnv
|
|
});
|
|
console.log(`🚀 STL Storage server running on ${config.host}:${config.port}`);
|
|
console.log(`📁 Upload endpoint: http://${config.host}:${config.port}/api/upload`);
|
|
console.log(`🔍 Browse files: http://${config.host}:${config.port}/api/files`);
|
|
});
|
|
} catch (error) {
|
|
logger.error('Failed to start server', { error: error.message, stack: error.stack });
|
|
console.error('❌ Failed to start server:', error);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
process.on('SIGINT', async () => {
|
|
console.log('\n🛑 Shutting down server...');
|
|
await db.close();
|
|
process.exit(0);
|
|
});
|
|
|
|
if (require.main === module) {
|
|
startServer();
|
|
}
|
|
|
|
module.exports = app; |