uploader / server.js
vyles's picture
fix: ensure upload directory exists before writing metadata
720815f
/**
* Web Uploader - Backend Server
* Express.js server for handling file uploads with multer.
*/
import express from 'express';
import multer from 'multer';
import cors from 'cors';
import path from 'path';
import os from 'os';
import { fileURLToPath } from 'url';
import fs from 'fs/promises';
import { existsSync, mkdirSync } from 'fs';
import dotenv from 'dotenv';
import rateLimit from 'express-rate-limit';
import { v4 as uuidv4 } from 'uuid';
dotenv.config();
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
const CONFIG = {
port: process.env.PORT || 7860,
uploadDir: process.env.UPLOAD_DIR || path.join(os.tmpdir(), 'web-uploader'),
maxFileSize: parseInt(process.env.MAX_FILE_SIZE) || 500 * 1024 * 1024,
rateLimitWindow: parseInt(process.env.RATE_LIMIT_WINDOW_MS) || 15 * 60 * 1000, // 15 minutes
rateLimitMax: parseInt(process.env.RATE_LIMIT_MAX) || 100,
cleanupInterval: parseInt(process.env.CLEANUP_INTERVAL_MS) || 60 * 60 * 1000 // 1 hour
};
const METADATA_FILE = path.join(CONFIG.uploadDir, 'metadata.json');
const app = express();
app.set('trust proxy', true);
app.use(cors());
app.use(express.static(path.join(__dirname, 'public')));
app.use('/uploads', express.static(CONFIG.uploadDir));
// Rate Limiting
const limiter = rateLimit({
windowMs: CONFIG.rateLimitWindow,
max: CONFIG.rateLimitMax,
standardHeaders: true,
legacyHeaders: false,
validate: { trustProxy: false }
});
const storage = multer.diskStorage({
destination: (req, file, cb) => {
if (!existsSync(CONFIG.uploadDir)) {
mkdirSync(CONFIG.uploadDir, { recursive: true });
}
cb(null, CONFIG.uploadDir);
},
filename: (req, file, cb) => {
const uniqueSuffix = `${Date.now()}-${Math.round(Math.random() * 1e9)}`;
cb(null, `${uniqueSuffix}-${file.originalname}`);
}
});
const upload = multer({
storage,
limits: { fileSize: CONFIG.maxFileSize }
});
// Metadata Manager
class MetadataManager {
static async ensureMetadataFile() {
if (!existsSync(CONFIG.uploadDir)) {
mkdirSync(CONFIG.uploadDir, { recursive: true });
}
if (!existsSync(METADATA_FILE)) {
await fs.writeFile(METADATA_FILE, JSON.stringify({}, null, 2));
}
}
static async load() {
await this.ensureMetadataFile();
try {
const data = await fs.readFile(METADATA_FILE, 'utf8');
return JSON.parse(data);
} catch (error) {
console.error('Error reading metadata:', error);
return {};
}
}
static async save(data) {
await fs.writeFile(METADATA_FILE, JSON.stringify(data, null, 2));
}
static async addFile(filename, expiration) {
const metadata = await this.load();
const expiresAt = expiration === 'permanent' ? null : Date.now() + this.parseExpiration(expiration);
metadata[filename] = {
filename,
uploadedAt: Date.now(),
expiresAt
};
await this.save(metadata);
return metadata[filename];
}
static parseExpiration(exp) {
const units = {
'h': 60 * 60 * 1000,
'd': 24 * 60 * 60 * 1000,
'w': 7 * 24 * 60 * 60 * 1000
};
const match = exp.match(/^(\d+)([hdw])$/);
if (!match) return 24 * 60 * 60 * 1000; // Default 24h
return parseInt(match[1]) * units[match[2]];
}
static async cleanup() {
const metadata = await this.load();
const now = Date.now();
let changed = false;
for (const [filename, info] of Object.entries(metadata)) {
if (info.expiresAt && info.expiresAt < now) {
try {
const filePath = path.join(CONFIG.uploadDir, filename);
if (existsSync(filePath)) {
await fs.unlink(filePath);
console.log(`Expired file deleted: ${filename}`);
}
delete metadata[filename];
changed = true;
} catch (err) {
console.error(`Failed to delete expired file ${filename}:`, err);
}
}
}
if (changed) {
await this.save(metadata);
}
}
}
// Start cleanup task
setInterval(() => {
MetadataManager.cleanup();
}, CONFIG.cleanupInterval);
// Initial cleanup on startup
MetadataManager.cleanup();
function isValidFilePath(filePath) {
const safeUploadDir = path.normalize(CONFIG.uploadDir);
const resolvedPath = path.normalize(filePath);
return resolvedPath.startsWith(safeUploadDir + path.sep);
}
function buildFileUrl(req, filename) {
return `${req.protocol}://${req.get('host')}/uploads/${filename}`;
}
app.post('/api/upload', limiter, upload.array('files'), async (req, res) => {
if (!req.files || req.files.length === 0) {
return res.status(400).json({
status: 'error',
message: 'No files uploaded'
});
}
const expiration = req.body.expiration || '24h';
const uploadedFiles = await Promise.all(req.files.map(async file => {
const metadata = await MetadataManager.addFile(file.filename, expiration);
return {
originalName: file.originalname,
filename: file.filename,
url: buildFileUrl(req, file.filename),
expiresAt: metadata.expiresAt
};
}));
res.status(200).json({
status: 'success',
message: 'Files uploaded successfully',
files: uploadedFiles
});
});
app.delete('/api/delete/:filename', async (req, res) => {
const { filename } = req.params;
const filePath = path.join(CONFIG.uploadDir, filename);
if (!isValidFilePath(filePath)) {
return res.status(403).json({
status: 'error',
message: 'Access denied: Invalid file path'
});
}
try {
// Remove from metadata
const metadata = await MetadataManager.load();
if (metadata[filename]) {
delete metadata[filename];
await MetadataManager.save(metadata);
}
await fs.access(filePath);
} catch {
return res.status(404).json({
status: 'error',
message: 'File not found'
});
}
try {
await fs.unlink(filePath);
res.status(200).json({
status: 'success',
message: 'File deleted successfully'
});
} catch (err) {
console.error('Delete error:', err);
res.status(500).json({
status: 'error',
message: 'Failed to delete file'
});
}
});
app.use((err, req, res, next) => {
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(413).json({
status: 'error',
message: `File too large. Maximum size is ${CONFIG.maxFileSize / (1024 * 1024)} MB`
});
}
return res.status(400).json({
status: 'error',
message: err.message
});
}
next(err);
});
app.listen(CONFIG.port, () => {
console.log(`Server running on http://localhost:${CONFIG.port}`);
});