first commit
This commit is contained in:
141
.gitignore
vendored
Normal file
141
.gitignore
vendored
Normal file
@@ -0,0 +1,141 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
lerna-debug.log*
|
||||
|
||||
# Diagnostic reports (https://nodejs.org/api/report.html)
|
||||
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
*.seed
|
||||
*.pid.lock
|
||||
|
||||
# Directory for instrumented libs generated by jscoverage/JSCover
|
||||
lib-cov
|
||||
|
||||
# Coverage directory used by tools like istanbul
|
||||
coverage
|
||||
*.lcov
|
||||
|
||||
# nyc test coverage
|
||||
.nyc_output
|
||||
|
||||
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
|
||||
.grunt
|
||||
|
||||
# Bower dependency directory (https://bower.io/)
|
||||
bower_components
|
||||
|
||||
# node-waf configuration
|
||||
.lock-wscript
|
||||
|
||||
# Compiled binary addons (https://nodejs.org/api/addons.html)
|
||||
build/Release
|
||||
|
||||
# Dependency directories
|
||||
node_modules/
|
||||
jspm_packages/
|
||||
|
||||
# Snowpack dependency directory (https://snowpack.dev/)
|
||||
web_modules/
|
||||
|
||||
# TypeScript cache
|
||||
*.tsbuildinfo
|
||||
|
||||
# Optional npm cache directory
|
||||
.npm
|
||||
|
||||
# Optional eslint cache
|
||||
.eslintcache
|
||||
|
||||
# Optional stylelint cache
|
||||
.stylelintcache
|
||||
|
||||
# Optional REPL history
|
||||
.node_repl_history
|
||||
|
||||
# Output of 'npm pack'
|
||||
*.tgz
|
||||
|
||||
# Yarn Integrity file
|
||||
.yarn-integrity
|
||||
|
||||
# dotenv environment variable files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# parcel-bundler cache (https://parceljs.org/)
|
||||
.cache
|
||||
.parcel-cache
|
||||
|
||||
# Next.js build output
|
||||
.next
|
||||
out
|
||||
|
||||
# Nuxt.js build / generate output
|
||||
.nuxt
|
||||
dist
|
||||
.output
|
||||
|
||||
# Gatsby files
|
||||
.cache/
|
||||
# Comment in the public line in if your project uses Gatsby and not Next.js
|
||||
# https://nextjs.org/blog/next-9-1#public-directory-support
|
||||
# public
|
||||
|
||||
# vuepress build output
|
||||
.vuepress/dist
|
||||
|
||||
# vuepress v2.x temp and cache directory
|
||||
.temp
|
||||
.cache
|
||||
|
||||
# Sveltekit cache directory
|
||||
.svelte-kit/
|
||||
|
||||
# vitepress build output
|
||||
**/.vitepress/dist
|
||||
|
||||
# vitepress cache directory
|
||||
**/.vitepress/cache
|
||||
|
||||
# Docusaurus cache and generated files
|
||||
.docusaurus
|
||||
|
||||
# Serverless directories
|
||||
.serverless/
|
||||
|
||||
# FuseBox cache
|
||||
.fusebox/
|
||||
|
||||
# DynamoDB Local files
|
||||
.dynamodb/
|
||||
|
||||
# Firebase cache directory
|
||||
.firebase/
|
||||
|
||||
# TernJS port file
|
||||
.tern-port
|
||||
|
||||
# Stores VSCode versions used for testing VSCode extensions
|
||||
.vscode-test
|
||||
|
||||
# yarn v3
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
# Vite files
|
||||
vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
.vite/
|
||||
150
README.md
Normal file
150
README.md
Normal file
@@ -0,0 +1,150 @@
|
||||
# STL to STEP Converter
|
||||
|
||||
A modular Node.js web application for converting STL files to STEP format with automatic file cleanup and Redis-based job tracking.
|
||||
|
||||
## Features
|
||||
|
||||
- ✅ Upload STL files via drag-and-drop or file picker
|
||||
- ✅ Convert STL to STEP format
|
||||
- ✅ Track conversion jobs with unique IDs
|
||||
- ✅ Redis-based job management with TTL
|
||||
- ✅ Automatic file cleanup scheduler
|
||||
- ✅ Download converted STEP files
|
||||
- ✅ Expiration tracking and display
|
||||
- ✅ Modular architecture
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
stl-to-step-converter/
|
||||
├── src/
|
||||
│ ├── config/
|
||||
│ │ └── index.js # Configuration
|
||||
│ ├── services/
|
||||
│ │ ├── redis.service.js # Redis operations
|
||||
│ │ ├── converter.service.js # STL to STEP conversion
|
||||
│ │ ├── file.service.js # File management
|
||||
│ │ └── cleanup.service.js # Scheduled cleanup
|
||||
│ ├── routes/
|
||||
│ │ └── conversion.routes.js # API endpoints
|
||||
│ └── server.js # Main application
|
||||
├── public/
|
||||
│ ├── index.html # Frontend UI
|
||||
│ └── app.js # Frontend logic
|
||||
├── uploads/ # Uploaded STL files
|
||||
├── converted/ # Converted STEP files
|
||||
└── package.json
|
||||
```
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 14+
|
||||
- Redis server
|
||||
- FreeCAD (with freecadcmd CLI tool)
|
||||
|
||||
## Installation
|
||||
|
||||
1. Install FreeCAD:
|
||||
- **Ubuntu/Debian**: `sudo apt-get install freecad`
|
||||
- **macOS**: `brew install freecad`
|
||||
- **Windows**: Download from [FreeCAD website](https://www.freecadweb.org/)
|
||||
|
||||
2. Install Node.js dependencies:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
3. Start Redis (if not running):
|
||||
```bash
|
||||
redis-server
|
||||
```
|
||||
|
||||
4. Start the application:
|
||||
```bash
|
||||
npm start
|
||||
```
|
||||
|
||||
For development with auto-reload:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
4. Open browser to `http://localhost:3000`
|
||||
|
||||
## Configuration
|
||||
|
||||
Edit `src/config/index.js` to customize:
|
||||
|
||||
- **Port**: Server port (default: 3000)
|
||||
- **Redis**: Connection settings
|
||||
- **Upload**: Max file size and directory
|
||||
- **Jobs**: TTL (time-to-live) and cleanup schedule
|
||||
- **Cleanup Cron**: Default runs every 15 minutes
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### POST `/api/convert`
|
||||
Upload and convert STL file
|
||||
- Body: `multipart/form-data` with `stlFile` field
|
||||
- Returns: `{ success, jobId, message }`
|
||||
|
||||
### GET `/api/job/:jobId`
|
||||
Get job status and expiration info
|
||||
- Returns: Job details including `expiresIn` (seconds)
|
||||
|
||||
### GET `/api/download/:jobId`
|
||||
Download converted STEP file
|
||||
- Returns: File download
|
||||
|
||||
## Environment Variables
|
||||
|
||||
```bash
|
||||
PORT=3000
|
||||
REDIS_HOST=localhost
|
||||
REDIS_PORT=6379
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. **Upload**: User uploads STL file via web interface
|
||||
2. **Job Creation**: System creates job in Redis with TTL
|
||||
3. **Conversion**: STL file is converted to STEP format
|
||||
4. **Storage**: Both files stored temporarily
|
||||
5. **Download**: User downloads converted file
|
||||
6. **Cleanup**: Scheduled job removes expired files and Redis entries
|
||||
|
||||
## Cleanup Process
|
||||
|
||||
- Runs every 15 minutes (configurable)
|
||||
- Removes Redis jobs past TTL
|
||||
- Deletes associated files
|
||||
- Cleans up orphaned files
|
||||
|
||||
## Conversion Process
|
||||
|
||||
The application uses FreeCAD's command-line interface (`freecadcmd`) to convert STL files to STEP format:
|
||||
|
||||
1. **Mesh Import**: STL file is imported using FreeCAD's Mesh module
|
||||
2. **Shape Creation**: Mesh topology is converted to a Part shape with 0.01 tolerance
|
||||
3. **Document Creation**: Temporary FreeCAD document is created for the conversion
|
||||
4. **Export**: Shape is exported to STEP format using FreeCAD's Import module
|
||||
5. **Cleanup**: Temporary document is closed to free memory
|
||||
|
||||
The conversion command:
|
||||
```bash
|
||||
freecadcmd -c "import Part; import Mesh; import FreeCAD; import Import; mesh = Mesh.read('input.stl'); shape = Part.Shape(); shape.makeShapeFromMesh(mesh.Topology, 0.01); doc = FreeCAD.newDocument('STLImport'); obj = doc.addObject('Part::Feature', 'MeshShape'); obj.Shape = shape; Import.export([obj], 'output.step'); FreeCAD.closeDocument('STLImport')"
|
||||
```
|
||||
|
||||
**Note**: FreeCAD must be installed and the `freecadcmd` command must be available in your system PATH.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
- File type validation (STL only)
|
||||
- File size limits (50MB default)
|
||||
- Automatic cleanup prevents disk filling
|
||||
- Redis TTL prevents memory leaks
|
||||
- Input sanitization on all endpoints
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
1601
package-lock.json
generated
Normal file
1601
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"name": "stl-to-step-converter",
|
||||
"version": "1.0.0",
|
||||
"description": "Web application for converting STL files to STEP format",
|
||||
"main": "src/server.js",
|
||||
"scripts": {
|
||||
"start": "node src/server.js",
|
||||
"dev": "nodemon src/server.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"express": "^4.18.2",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"redis": "^4.6.0",
|
||||
"node-cron": "^3.0.3",
|
||||
"uuid": "^9.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.0"
|
||||
}
|
||||
}
|
||||
144
public/app.js
Normal file
144
public/app.js
Normal file
@@ -0,0 +1,144 @@
|
||||
let selectedFile = null;
|
||||
let currentJobId = null;
|
||||
|
||||
const uploadArea = document.getElementById('uploadArea');
|
||||
const fileInput = document.getElementById('fileInput');
|
||||
const fileInfo = document.getElementById('fileInfo');
|
||||
const convertBtn = document.getElementById('convertBtn');
|
||||
const downloadBtn = document.getElementById('downloadBtn');
|
||||
const loader = document.getElementById('loader');
|
||||
const status = document.getElementById('status');
|
||||
|
||||
// Upload area click
|
||||
uploadArea.addEventListener('click', () => fileInput.click());
|
||||
|
||||
// File selection
|
||||
fileInput.addEventListener('change', (e) => {
|
||||
handleFile(e.target.files[0]);
|
||||
});
|
||||
|
||||
// Drag and drop
|
||||
uploadArea.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.add('dragover');
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('dragleave', () => {
|
||||
uploadArea.classList.remove('dragover');
|
||||
});
|
||||
|
||||
uploadArea.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
uploadArea.classList.remove('dragover');
|
||||
handleFile(e.dataTransfer.files[0]);
|
||||
});
|
||||
|
||||
function handleFile(file) {
|
||||
if (!file) return;
|
||||
|
||||
if (!file.name.toLowerCase().endsWith('.stl')) {
|
||||
showStatus('error', 'Please select a valid STL file');
|
||||
return;
|
||||
}
|
||||
|
||||
if (file.size > 50 * 1024 * 1024) {
|
||||
showStatus('error', 'File size exceeds 50MB limit');
|
||||
return;
|
||||
}
|
||||
|
||||
selectedFile = file;
|
||||
fileInfo.textContent = `Selected: ${file.name} (${formatFileSize(file.size)})`;
|
||||
fileInfo.classList.add('show');
|
||||
convertBtn.disabled = false;
|
||||
downloadBtn.style.display = 'none';
|
||||
status.style.display = 'none';
|
||||
}
|
||||
|
||||
convertBtn.addEventListener('click', async () => {
|
||||
if (!selectedFile) return;
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('stlFile', selectedFile);
|
||||
|
||||
convertBtn.disabled = true;
|
||||
loader.classList.add('show');
|
||||
showStatus('processing', 'Converting your file...');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/convert', {
|
||||
method: 'POST',
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.success) {
|
||||
currentJobId = data.jobId;
|
||||
showStatus('success', 'Conversion completed successfully!');
|
||||
|
||||
// Get job details to show expiration
|
||||
await updateJobStatus();
|
||||
|
||||
downloadBtn.style.display = 'block';
|
||||
downloadBtn.disabled = false;
|
||||
} else {
|
||||
showStatus('error', `Conversion failed: ${data.error || 'Unknown error'}`);
|
||||
}
|
||||
} catch (error) {
|
||||
showStatus('error', `Error: ${error.message}`);
|
||||
} finally {
|
||||
loader.classList.remove('show');
|
||||
convertBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
downloadBtn.addEventListener('click', () => {
|
||||
if (currentJobId) {
|
||||
window.location.href = `/api/download/${currentJobId}`;
|
||||
}
|
||||
});
|
||||
|
||||
async function updateJobStatus() {
|
||||
if (!currentJobId) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(`/api/job/${currentJobId}`);
|
||||
const job = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
const expiresIn = formatDuration(job.expiresIn);
|
||||
const expiresInfo = document.createElement('div');
|
||||
expiresInfo.className = 'expires-info';
|
||||
expiresInfo.textContent = `⏱️ File will expire in ${expiresIn}`;
|
||||
|
||||
// Append if not already present
|
||||
if (!status.querySelector('.expires-info')) {
|
||||
status.appendChild(expiresInfo);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch job status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function showStatus(type, message) {
|
||||
status.className = `status ${type}`;
|
||||
status.textContent = message;
|
||||
status.style.display = 'block';
|
||||
}
|
||||
|
||||
function formatFileSize(bytes) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
const k = 1024;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (seconds < 60) return `${seconds} seconds`;
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
if (minutes < 60) return `${minutes} minutes`;
|
||||
const hours = Math.floor(minutes / 60);
|
||||
return `${hours} hours`;
|
||||
}
|
||||
192
public/index.html
Normal file
192
public/index.html
Normal file
@@ -0,0 +1,192 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>STL to STEP Converter</title>
|
||||
<style>
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.container {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
|
||||
padding: 40px;
|
||||
max-width: 600px;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
h1 {
|
||||
color: #333;
|
||||
margin-bottom: 10px;
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
color: #666;
|
||||
margin-bottom: 30px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.upload-area {
|
||||
border: 3px dashed #667eea;
|
||||
border-radius: 12px;
|
||||
padding: 40px;
|
||||
text-align: center;
|
||||
background: #f8f9ff;
|
||||
cursor: pointer;
|
||||
transition: all 0.3s ease;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.upload-area:hover {
|
||||
border-color: #764ba2;
|
||||
background: #f0f2ff;
|
||||
}
|
||||
|
||||
.upload-area.dragover {
|
||||
border-color: #764ba2;
|
||||
background: #e8ebff;
|
||||
transform: scale(1.02);
|
||||
}
|
||||
|
||||
.upload-icon {
|
||||
font-size: 48px;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
input[type="file"] {
|
||||
display: none;
|
||||
}
|
||||
|
||||
button {
|
||||
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 14px 32px;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
transition: all 0.3s ease;
|
||||
margin-top: 10px;
|
||||
}
|
||||
|
||||
button:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
|
||||
}
|
||||
|
||||
button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.status {
|
||||
margin-top: 20px;
|
||||
padding: 16px;
|
||||
border-radius: 8px;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.status.success {
|
||||
background: #d4edda;
|
||||
border: 1px solid #c3e6cb;
|
||||
color: #155724;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.status.error {
|
||||
background: #f8d7da;
|
||||
border: 1px solid #f5c6cb;
|
||||
color: #721c24;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.status.processing {
|
||||
background: #d1ecf1;
|
||||
border: 1px solid #bee5eb;
|
||||
color: #0c5460;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.file-info {
|
||||
margin-top: 10px;
|
||||
padding: 12px;
|
||||
background: #f8f9fa;
|
||||
border-radius: 6px;
|
||||
font-size: 14px;
|
||||
color: #495057;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.file-info.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.loader {
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #667eea;
|
||||
border-radius: 50%;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
animation: spin 1s linear infinite;
|
||||
margin: 20px auto;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.loader.show {
|
||||
display: block;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.expires-info {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>🔄 STL to STEP Converter</h1>
|
||||
<p class="subtitle">Upload your STL file and convert it to STEP format</p>
|
||||
|
||||
<div class="upload-area" id="uploadArea">
|
||||
<div class="upload-icon">📁</div>
|
||||
<p><strong>Click to upload</strong> or drag and drop</p>
|
||||
<p style="font-size: 12px; color: #999; margin-top: 5px;">STL files only (Max 50MB)</p>
|
||||
<input type="file" id="fileInput" accept=".stl">
|
||||
</div>
|
||||
|
||||
<div class="file-info" id="fileInfo"></div>
|
||||
|
||||
<button id="convertBtn" disabled>Convert to STEP</button>
|
||||
<button id="downloadBtn" style="display: none;">Download STEP File</button>
|
||||
|
||||
<div class="loader" id="loader"></div>
|
||||
<div class="status" id="status"></div>
|
||||
</div>
|
||||
|
||||
<script src="app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
18
src/config/index.js
Normal file
18
src/config/index.js
Normal file
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
port: process.env.PORT || 3000,
|
||||
redis: {
|
||||
host: process.env.REDIS_HOST || 'localhost',
|
||||
port: process.env.REDIS_PORT || 6379,
|
||||
},
|
||||
upload: {
|
||||
dir: './uploads',
|
||||
maxSize: 50 * 1024 * 1024, // 50MB
|
||||
},
|
||||
output: {
|
||||
dir: './converted',
|
||||
},
|
||||
jobs: {
|
||||
ttl: 3600, // 1 hour in seconds
|
||||
cleanupCron: '*/15 * * * *', // Every 15 minutes
|
||||
},
|
||||
};
|
||||
139
src/routes/conversion.routes.js
Normal file
139
src/routes/conversion.routes.js
Normal file
@@ -0,0 +1,139 @@
|
||||
const express = require('express');
|
||||
const multer = require('multer');
|
||||
const { v4: uuidv4 } = require('uuid');
|
||||
const path = require('path');
|
||||
const config = require('../config');
|
||||
const converterService = require('../services/converter.service');
|
||||
const redisService = require('../services/redis.service');
|
||||
const fileService = require('../services/file.service');
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
const storage = multer.diskStorage({
|
||||
destination: (req, file, cb) => {
|
||||
cb(null, config.upload.dir);
|
||||
},
|
||||
filename: (req, file, cb) => {
|
||||
const uniqueName = `${uuidv4()}${path.extname(file.originalname)}`;
|
||||
cb(null, uniqueName);
|
||||
},
|
||||
});
|
||||
|
||||
const upload = multer({
|
||||
storage,
|
||||
limits: { fileSize: config.upload.maxSize },
|
||||
fileFilter: (req, file, cb) => {
|
||||
if (path.extname(file.originalname).toLowerCase() !== '.stl') {
|
||||
return cb(new Error('Only STL files are allowed'));
|
||||
}
|
||||
cb(null, true);
|
||||
},
|
||||
});
|
||||
|
||||
// Upload and convert STL to STEP
|
||||
router.post('/convert', upload.single('stlFile'), async (req, res) => {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file uploaded' });
|
||||
}
|
||||
|
||||
const jobId = uuidv4();
|
||||
const inputPath = req.file.path;
|
||||
const outputFilename = `${path.parse(req.file.filename).name}.step`;
|
||||
const outputPath = fileService.getOutputPath(outputFilename);
|
||||
|
||||
// Create job in Redis
|
||||
await redisService.createJob(jobId, {
|
||||
jobId,
|
||||
status: 'processing',
|
||||
originalFilename: req.file.originalname,
|
||||
inputPath,
|
||||
outputPath,
|
||||
outputFilename,
|
||||
});
|
||||
|
||||
// Perform conversion
|
||||
try {
|
||||
await converterService.convertSTLtoSTEP(inputPath, outputPath);
|
||||
|
||||
await redisService.updateJob(jobId, {
|
||||
status: 'completed',
|
||||
completedAt: Date.now(),
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
jobId,
|
||||
message: 'Conversion completed successfully',
|
||||
});
|
||||
} catch (conversionError) {
|
||||
await redisService.updateJob(jobId, {
|
||||
status: 'failed',
|
||||
error: conversionError.message,
|
||||
});
|
||||
|
||||
res.status(500).json({
|
||||
success: false,
|
||||
jobId,
|
||||
error: 'Conversion failed',
|
||||
details: conversionError.message,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Get job status
|
||||
router.get('/job/:jobId', async (req, res) => {
|
||||
try {
|
||||
const { jobId } = req.params;
|
||||
const job = await redisService.getJob(jobId);
|
||||
|
||||
if (!job) {
|
||||
return res.status(404).json({ error: 'Job not found or expired' });
|
||||
}
|
||||
|
||||
const expiresIn = Math.max(0, Math.floor(job.remainingTTL));
|
||||
|
||||
res.json({
|
||||
jobId: job.jobId,
|
||||
status: job.status,
|
||||
originalFilename: job.originalFilename,
|
||||
expiresIn,
|
||||
expiresAt: new Date(job.expiresAt).toISOString(),
|
||||
createdAt: new Date(job.createdAt).toISOString(),
|
||||
});
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
// Download converted file
|
||||
router.get('/download/:jobId', async (req, res) => {
|
||||
try {
|
||||
const { jobId } = req.params;
|
||||
const job = await redisService.getJob(jobId);
|
||||
|
||||
if (!job) {
|
||||
return res.status(404).json({ error: 'Job not found or expired' });
|
||||
}
|
||||
|
||||
if (job.status !== 'completed') {
|
||||
return res.status(400).json({
|
||||
error: `Cannot download: job status is ${job.status}`
|
||||
});
|
||||
}
|
||||
|
||||
const exists = await fileService.fileExists(job.outputPath);
|
||||
if (!exists) {
|
||||
return res.status(404).json({ error: 'Converted file not found' });
|
||||
}
|
||||
|
||||
res.download(job.outputPath, job.outputFilename);
|
||||
} catch (error) {
|
||||
res.status(500).json({ error: error.message });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
76
src/server.js
Normal file
76
src/server.js
Normal file
@@ -0,0 +1,76 @@
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const config = require('./config');
|
||||
const redisService = require('./services/redis.service');
|
||||
const fileService = require('./services/file.service');
|
||||
const cleanupService = require('./services/cleanup.service');
|
||||
const conversionRoutes = require('./routes/conversion.routes');
|
||||
|
||||
const app = express();
|
||||
|
||||
// Middleware
|
||||
app.use(express.json());
|
||||
app.use(express.static(path.join(__dirname, '../public')));
|
||||
|
||||
// Routes
|
||||
app.use('/api', conversionRoutes);
|
||||
|
||||
// Error handling middleware
|
||||
app.use((err, req, res, next) => {
|
||||
console.error('Error:', err);
|
||||
|
||||
if (err instanceof multer.MulterError) {
|
||||
if (err.code === 'LIMIT_FILE_SIZE') {
|
||||
return res.status(400).json({
|
||||
error: 'File too large',
|
||||
maxSize: `${config.upload.maxSize / (1024 * 1024)}MB`,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
res.status(500).json({
|
||||
error: err.message || 'Internal server error'
|
||||
});
|
||||
});
|
||||
|
||||
// Initialize and start server
|
||||
async function start() {
|
||||
try {
|
||||
// Ensure directories exist
|
||||
await fileService.ensureDirectories();
|
||||
console.log('Directories initialized');
|
||||
|
||||
// Connect to Redis
|
||||
await redisService.connect();
|
||||
|
||||
// Start cleanup scheduler
|
||||
cleanupService.start();
|
||||
|
||||
// Start server
|
||||
app.listen(config.port, () => {
|
||||
console.log(`Server running on http://localhost:${config.port}`);
|
||||
console.log(`Upload max size: ${config.upload.maxSize / (1024 * 1024)}MB`);
|
||||
console.log(`File TTL: ${config.jobs.ttl}s`);
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Failed to start server:', error);
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
// Graceful shutdown
|
||||
process.on('SIGINT', async () => {
|
||||
console.log('\nShutting down gracefully...');
|
||||
cleanupService.stop();
|
||||
await redisService.disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
process.on('SIGTERM', async () => {
|
||||
console.log('\nShutting down gracefully...');
|
||||
cleanupService.stop();
|
||||
await redisService.disconnect();
|
||||
process.exit(0);
|
||||
});
|
||||
|
||||
start();
|
||||
81
src/services/cleanup.service.js
Normal file
81
src/services/cleanup.service.js
Normal file
@@ -0,0 +1,81 @@
|
||||
const cron = require('node-cron');
|
||||
const config = require('../config');
|
||||
const redisService = require('./redis.service');
|
||||
const fileService = require('./file.service');
|
||||
|
||||
class CleanupService {
|
||||
constructor() {
|
||||
this.job = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
console.log(`Starting cleanup scheduler: ${config.jobs.cleanupCron}`);
|
||||
|
||||
this.job = cron.schedule(config.jobs.cleanupCron, async () => {
|
||||
console.log('Running cleanup job...');
|
||||
await this.cleanup();
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.job) {
|
||||
this.job.stop();
|
||||
console.log('Cleanup scheduler stopped');
|
||||
}
|
||||
}
|
||||
|
||||
async cleanup() {
|
||||
try {
|
||||
const stats = {
|
||||
filesDeleted: 0,
|
||||
jobsDeleted: 0,
|
||||
errors: 0,
|
||||
};
|
||||
|
||||
// Clean up expired jobs from Redis
|
||||
const jobKeys = await redisService.getAllJobKeys();
|
||||
|
||||
for (const key of jobKeys) {
|
||||
const jobId = key.replace('job:', '');
|
||||
const job = await redisService.getJob(jobId);
|
||||
|
||||
if (!job || job.remainingTTL <= 0) {
|
||||
// Clean up associated files
|
||||
if (job) {
|
||||
const deleted = await fileService.cleanupJobFiles(
|
||||
job.inputPath,
|
||||
job.outputPath
|
||||
);
|
||||
|
||||
if (deleted.inputDeleted) stats.filesDeleted++;
|
||||
if (deleted.outputDeleted) stats.filesDeleted++;
|
||||
}
|
||||
|
||||
await redisService.deleteJob(jobId);
|
||||
stats.jobsDeleted++;
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up orphaned files (files without jobs)
|
||||
const expiredFiles = await fileService.getExpiredFiles();
|
||||
|
||||
for (const filePath of expiredFiles) {
|
||||
const deleted = await fileService.deleteFile(filePath);
|
||||
if (deleted) stats.filesDeleted++;
|
||||
else stats.errors++;
|
||||
}
|
||||
|
||||
console.log('Cleanup completed:', stats);
|
||||
return stats;
|
||||
} catch (error) {
|
||||
console.error('Cleanup error:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async manualCleanup() {
|
||||
return await this.cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new CleanupService();
|
||||
36
src/services/converter.service.js
Normal file
36
src/services/converter.service.js
Normal file
@@ -0,0 +1,36 @@
|
||||
const { exec } = require('child_process');
|
||||
const path = require('path');
|
||||
const fs = require('fs').promises;
|
||||
const util = require('util');
|
||||
|
||||
const execPromise = util.promisify(exec);
|
||||
|
||||
class ConverterService {
|
||||
/**
|
||||
* Convert STL to STEP format using FreeCAD CLI
|
||||
*/
|
||||
async convertSTLtoSTEP(inputPath, outputPath) {
|
||||
try {
|
||||
const command = `freecadcmd -c "import Part; import Mesh; import FreeCAD; import Import; mesh = Mesh.read('${inputPath}'); shape = Part.Shape(); shape.makeShapeFromMesh(mesh.Topology, 0.01); doc = FreeCAD.newDocument('STLImport'); obj = doc.addObject('Part::Feature', 'MeshShape'); obj.Shape = shape; Import.export([obj], '${outputPath}'); FreeCAD.closeDocument('STLImport')"`;
|
||||
|
||||
const { stdout, stderr } = await execPromise(command);
|
||||
|
||||
return { success: true, outputPath };
|
||||
} catch (error) {
|
||||
throw new Error(`Conversion failed: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
async getFileInfo(filePath) {
|
||||
const stats = await fs.stat(filePath);
|
||||
return {
|
||||
size: stats.size,
|
||||
created: stats.birthtime,
|
||||
modified: stats.mtime,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new ConverterService();
|
||||
76
src/services/file.service.js
Normal file
76
src/services/file.service.js
Normal file
@@ -0,0 +1,76 @@
|
||||
const fs = require('fs').promises;
|
||||
const path = require('path');
|
||||
const config = require('../config');
|
||||
|
||||
class FileService {
|
||||
async ensureDirectories() {
|
||||
await fs.mkdir(config.upload.dir, { recursive: true });
|
||||
await fs.mkdir(config.output.dir, { recursive: true });
|
||||
}
|
||||
|
||||
async deleteFile(filePath) {
|
||||
try {
|
||||
await fs.unlink(filePath);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`Failed to delete file ${filePath}:`, error.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async fileExists(filePath) {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
getUploadPath(filename) {
|
||||
return path.join(config.upload.dir, filename);
|
||||
}
|
||||
|
||||
getOutputPath(filename) {
|
||||
return path.join(config.output.dir, filename);
|
||||
}
|
||||
|
||||
async cleanupJobFiles(inputPath, outputPath) {
|
||||
const results = await Promise.allSettled([
|
||||
this.deleteFile(inputPath),
|
||||
this.deleteFile(outputPath),
|
||||
]);
|
||||
|
||||
return {
|
||||
inputDeleted: results[0].status === 'fulfilled' && results[0].value,
|
||||
outputDeleted: results[1].status === 'fulfilled' && results[1].value,
|
||||
};
|
||||
}
|
||||
|
||||
async getExpiredFiles() {
|
||||
const now = Date.now();
|
||||
const expiredFiles = [];
|
||||
|
||||
for (const dir of [config.upload.dir, config.output.dir]) {
|
||||
try {
|
||||
const files = await fs.readdir(dir);
|
||||
|
||||
for (const file of files) {
|
||||
const filePath = path.join(dir, file);
|
||||
const stats = await fs.stat(filePath);
|
||||
const age = now - stats.mtimeMs;
|
||||
|
||||
if (age > config.jobs.ttl * 1000) {
|
||||
expiredFiles.push(filePath);
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`Error scanning directory ${dir}:`, error.message);
|
||||
}
|
||||
}
|
||||
|
||||
return expiredFiles;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new FileService();
|
||||
80
src/services/redis.service.js
Normal file
80
src/services/redis.service.js
Normal file
@@ -0,0 +1,80 @@
|
||||
const redis = require('redis');
|
||||
const config = require('../config');
|
||||
|
||||
class RedisService {
|
||||
constructor() {
|
||||
this.client = null;
|
||||
}
|
||||
|
||||
async connect() {
|
||||
this.client = redis.createClient({
|
||||
socket: {
|
||||
host: config.redis.host,
|
||||
port: config.redis.port,
|
||||
},
|
||||
});
|
||||
|
||||
this.client.on('error', (err) => console.error('Redis Client Error', err));
|
||||
await this.client.connect();
|
||||
console.log('Redis connected');
|
||||
}
|
||||
|
||||
async createJob(jobId, data) {
|
||||
const job = {
|
||||
...data,
|
||||
createdAt: Date.now(),
|
||||
expiresAt: Date.now() + (config.jobs.ttl * 1000),
|
||||
};
|
||||
await this.client.setEx(
|
||||
`job:${jobId}`,
|
||||
config.jobs.ttl,
|
||||
JSON.stringify(job)
|
||||
);
|
||||
return job;
|
||||
}
|
||||
|
||||
async getJob(jobId) {
|
||||
const data = await this.client.get(`job:${jobId}`);
|
||||
if (!data) return null;
|
||||
|
||||
const job = JSON.parse(data);
|
||||
const ttl = await this.client.ttl(`job:${jobId}`);
|
||||
|
||||
return {
|
||||
...job,
|
||||
remainingTTL: ttl > 0 ? ttl : 0,
|
||||
};
|
||||
}
|
||||
|
||||
async updateJob(jobId, updates) {
|
||||
const job = await this.getJob(jobId);
|
||||
if (!job) return null;
|
||||
|
||||
const updatedJob = { ...job, ...updates };
|
||||
const ttl = await this.client.ttl(`job:${jobId}`);
|
||||
|
||||
await this.client.setEx(
|
||||
`job:${jobId}`,
|
||||
ttl > 0 ? ttl : config.jobs.ttl,
|
||||
JSON.stringify(updatedJob)
|
||||
);
|
||||
|
||||
return updatedJob;
|
||||
}
|
||||
|
||||
async deleteJob(jobId) {
|
||||
await this.client.del(`job:${jobId}`);
|
||||
}
|
||||
|
||||
async getAllJobKeys() {
|
||||
return await this.client.keys('job:*');
|
||||
}
|
||||
|
||||
async disconnect() {
|
||||
if (this.client) {
|
||||
await this.client.quit();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = new RedisService();
|
||||
Reference in New Issue
Block a user