const express = require('express'); const multer = require('multer'); const fs = require('fs'); const path = require('path'); const app = express(); let currentPort = parseInt(process.env.PORT, 10) || 3000; // Directories const uploadDir = path.join(__dirname, 'uploads'); const publicDir = path.join(__dirname, 'public'); if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }); } // 10-Minute File Expiration (in milliseconds) const FILE_TTL_MS = 10 * 60 * 1000; // Utility: format file size function formatBytes(bytes) { if (!bytes || bytes === 0) return '0 B'; const k = 1024; const sizes = ['B', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; } // Utility: Safe unique filename (prevent overwrites and directory traversal) function getSafeUniqueName(destination, originalName) { const base = path.basename(originalName).replace(/[^\w\s.-]/gi, '_').trim() || 'file'; const ext = path.extname(base); const nameOnly = path.basename(base, ext) || 'file'; let candidate = `${nameOnly}${ext}`; let counter = 1; while (fs.existsSync(path.join(destination, candidate))) { candidate = `${nameOnly} (${counter})${ext}`; counter++; } return candidate; } // Utility: Validate path and prevent directory traversal function getSafeFilePath(filename) { if (!filename || typeof filename !== 'string') return null; const safeName = path.basename(filename); const resolvedPath = path.resolve(uploadDir, safeName); if (!resolvedPath.startsWith(uploadDir + path.sep) && resolvedPath !== uploadDir) { return null; } return { resolvedPath, safeName }; } // Automatic cleanup of expired files (older than 10 minutes) async function cleanupExpiredFiles() { try { const entries = await fs.promises.readdir(uploadDir); const now = Date.now(); for (const name of entries) { if (name.startsWith('.')) continue; // Preserve .gitkeep and hidden files const fullPath = path.join(uploadDir, name); try { const stats = await fs.promises.stat(fullPath); if (stats.isFile() && (now - stats.mtimeMs) >= FILE_TTL_MS) { await fs.promises.unlink(fullPath); } } catch (err) { // Ignore files already deleted or locked } } } catch (err) { // Ignore read errors during sweep } } // Run cleanup sweep every 10 seconds (unref'd to prevent blocking process exit) const cleanupInterval = setInterval(cleanupExpiredFiles, 10 * 1000); cleanupInterval.unref(); // Multer storage const storage = multer.diskStorage({ destination: (req, file, cb) => cb(null, uploadDir), filename: (req, file, cb) => { cb(null, getSafeUniqueName(uploadDir, file.originalname)); } }); const upload = multer({ storage, limits: { fileSize: 2 * 1024 * 1024 * 1024 } // 2GB }); // Middlewares app.use((req, res, next) => { res.header('Access-Control-Allow-Origin', '*'); res.header('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS'); res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept, x-katdrop-key'); if (req.method === 'OPTIONS') { return res.sendStatus(200); } next(); }); app.use(express.json()); app.use(express.static(publicDir)); // Optional API Key Protection (if KATDROP_KEY environment variable is set) const REQUIRED_API_KEY = process.env.KATDROP_KEY || ''; if (REQUIRED_API_KEY) { app.use((req, res, next) => { if (req.method === 'OPTIONS' || req.path === '/health' || req.path === '/') return next(); const clientKey = req.headers['x-katdrop-key'] || req.query.key; if (clientKey !== REQUIRED_API_KEY) { return res.status(401).json({ error: 'Unauthorized: Invalid or missing API key' }); } next(); }); } // Health check endpoint app.get('/health', (req, res) => { res.json({ status: 'ok', uptime: Math.floor(process.uptime()) }); }); // Upload endpoint (supports single or multiple files) app.post('/upload', upload.array('file', 20), (req, res) => { if (!req.files || req.files.length === 0) { return res.status(400).json({ error: 'No files uploaded' }); } const now = Date.now(); const uploaded = req.files.map(f => ({ name: f.filename, size: f.size, sizeFormatted: formatBytes(f.size), expiresAt: new Date(now + FILE_TTL_MS).toISOString(), remainingSeconds: Math.floor(FILE_TTL_MS / 1000) })); res.json({ success: true, files: uploaded }); }); // List files with metadata, remaining TTL, and on-demand expiration check app.get('/files', async (req, res) => { try { const entries = await fs.promises.readdir(uploadDir); const files = []; const now = Date.now(); for (const name of entries) { if (name.startsWith('.')) continue; const fullPath = path.join(uploadDir, name); try { const stats = await fs.promises.stat(fullPath); if (!stats.isFile()) continue; const ageMs = now - stats.mtimeMs; // Immediate purge if expired if (ageMs >= FILE_TTL_MS) { fs.promises.unlink(fullPath).catch(() => {}); continue; } const remainingMs = Math.max(0, FILE_TTL_MS - ageMs); const remainingSeconds = Math.ceil(remainingMs / 1000); files.push({ name, size: stats.size, sizeFormatted: formatBytes(stats.size), modifiedAt: stats.mtime, expiresAt: new Date(stats.mtimeMs + FILE_TTL_MS).toISOString(), remainingSeconds }); } catch (e) { continue; } } // Sort newest first files.sort((a, b) => new Date(b.modifiedAt) - new Date(a.modifiedAt)); res.json(files); } catch (err) { res.status(500).json({ error: 'Error reading files' }); } }); // Download / direct link with on-demand expiration check app.get('/uploads/:filename', (req, res) => { const safe = getSafeFilePath(req.params.filename); if (!safe || !fs.existsSync(safe.resolvedPath)) { return res.status(404).send('File not found'); } try { const stats = fs.statSync(safe.resolvedPath); if ((Date.now() - stats.mtimeMs) >= FILE_TTL_MS) { fs.unlink(safe.resolvedPath, () => {}); return res.status(410).send('File expired'); } } catch (err) { return res.status(404).send('File not found'); } res.download(safe.resolvedPath, safe.safeName); }); // Delete file app.delete('/delete/:filename', async (req, res) => { const safe = getSafeFilePath(req.params.filename); if (!safe) return res.status(400).send('Invalid filename'); try { await fs.promises.unlink(safe.resolvedPath); res.send('File deleted'); } catch (err) { res.status(500).send('Error deleting file'); } }); // Start Server with fallback port handling function startServer(port, maxAttempts = 10) { const server = app.listen(port, '0.0.0.0', () => { currentPort = port; console.log(`\nkatdrop running on http://localhost:${port}\n`); cleanupExpiredFiles(); // Run initial cleanup on startup }); server.on('error', (err) => { if (err.code === 'EADDRINUSE' && !process.env.PORT && maxAttempts > 0) { startServer(port + 1, maxAttempts - 1); } else { console.error(`Failed to start server on port ${port}:`, err.message); process.exit(1); } }); } startServer(currentPort);