stl-storage/server.js
kris 3dff6b00d4 Initial commit: STL Storage Application
- 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>
2025-08-07 16:18:58 +00:00

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;