const http = require('node:http'); const fs = require('node:fs'); const path = require('node:path'); const crypto = require('node:crypto'); const PORT = process.env.PORT || 3000; const DATA_DIR = path.join(__dirname, 'data'); const MAX_SIZE_BYTES = 10 * 1024 * 1024; // 10MB const MAX_AGE_HOURS = 1000; const DEF_AGE_HOURS = 48; // default const MS_IN_HOUR = 60 * 60 * 1000; if (!fs.existsSync(DATA_DIR)) fs.mkdirSync(DATA_DIR); const sendJSON = (res, status, data) => { res.writeHead(status, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(data)); }; // These lines are absolutely critical to filesystem security, performing // several overlapping checks. Be careful before changing them; we don't // want an attacker to get arbitrary read access to the filesystem. const getSafeFilePath = (url) => { const id = url.split('/').pop(); if (!/^[a-z0-9-]+$/i.test(id)) return; const baseDir = path.resolve(DATA_DIR); const filePath = path.resolve(DATA_DIR, id); if (!filePath.startsWith(baseDir + '/')) return; return filePath; }; const deleteFile = (filePath) => { fs.unlink(filePath, (err) => { if (err) console.error(`Burn failed for ${filePath}`, err); }); } const server = http.createServer((req, res) => { const { method, url } = req; // Serve Frontend if (method === 'GET' && (url === '/' || url.startsWith('/view'))) { res.writeHead(200, { 'Content-Type': 'text/html' }); fs.createReadStream(path.join(__dirname, 'index.html')).pipe(res); return; } // CREATE Secret (Public POST) if (method === 'POST' && url.startsWith('/api/secret')) { const contentLength = parseInt(req.headers['content-length'], 10); if (contentLength > MAX_SIZE_BYTES) { res.writeHead(413); res.end('too large'); return; } const matchAge = url.match(/max-age=(\d+)/); const maxAge = matchAge ? matchAge[1] : DEF_AGE_HOURS; if (maxAge > MAX_AGE_HOURS) { res.writeHead(422); res.end(`max age is higher than ${MAX_AGE_HOURS}h`); return; } // Unique and Random URL component with age appended const id = crypto.randomUUID() + "-" + maxAge; const filePath = path.join(DATA_DIR, id); const writeStream = fs.createWriteStream(filePath); req.pipe(writeStream); req.on('end', () => sendJSON(res, 201, { id })); writeStream.on('error', () => { res.writeHead(500); res.end(); }); return; } // READ & BURN Secret if (method === 'GET' && url.startsWith('/api/secret/')) { const filePath = getSafeFilePath(url); const maxAge = filePath.split('-').pop(); return fs.stat(filePath, (err, stats) => { if (err) { res.writeHead(404); res.end('Burned or Not Found'); return; } if (Date.now() - stats.mtimeMs > maxAge * MS_IN_HOUR) { res.writeHead(404); res.end('Burned or Not Found'); return deleteFile(filePath); } const readStream = fs.createReadStream(filePath); res.writeHead(200, { 'Content-Type': 'application/octet-stream', 'Cache-Control': 'no-store, no-cache, must-revalidate', 'Expires': 0 }); readStream.pipe(res); // Delete immediately after stream ends readStream.on('end', () => deleteFile(filePath)); }); } res.writeHead(404); res.end(); }); server.listen(PORT, () => { console.log(`Public Burn Server: http://localhost:${PORT}`) });