privateburn/server.js

117 lines
3.7 KiB
JavaScript
Raw Permalink Normal View History

2026-01-15 13:38:02 +02:00
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
2026-01-16 13:48:15 +00:00
const MAX_AGE_HOURS = 1000;
const DEF_AGE_HOURS = 48; // default
const MS_IN_HOUR = 60 * 60 * 1000;
2026-01-15 13:38:02 +02:00
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));
};
2026-01-16 13:48:15 +00:00
// 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);
});
}
2026-01-15 13:38:02 +02:00
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)
2026-01-16 13:48:15 +00:00
if (method === 'POST' && url.startsWith('/api/secret')) {
2026-01-15 13:38:02 +02:00
const contentLength = parseInt(req.headers['content-length'], 10);
if (contentLength > MAX_SIZE_BYTES) {
2026-01-16 13:48:15 +00:00
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;
2026-01-15 13:38:02 +02:00
}
2026-01-16 13:48:15 +00:00
// Unique and Random URL component with age appended
const id = crypto.randomUUID() + "-" + maxAge;
2026-01-15 13:38:02 +02:00
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;
}
2026-01-16 13:48:15 +00:00
2026-01-15 13:38:02 +02:00
// READ & BURN Secret
if (method === 'GET' && url.startsWith('/api/secret/')) {
2026-01-16 13:48:15 +00:00
const filePath = getSafeFilePath(url);
const maxAge = filePath.split('-').pop();
2026-01-15 13:38:02 +02:00
2026-01-16 13:48:15 +00:00
return fs.stat(filePath, (err, stats) => {
if (err) {
res.writeHead(404);
res.end('Burned or Not Found');
return;
}
2026-01-15 13:38:02 +02:00
2026-01-16 13:48:15 +00:00
if (Date.now() - stats.mtimeMs > maxAge * MS_IN_HOUR) {
res.writeHead(404);
res.end('Burned or Not Found');
return deleteFile(filePath);
}
2026-01-15 13:38:02 +02:00
2026-01-16 13:48:15 +00:00
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));
2026-01-15 13:38:02 +02:00
});
}
2026-01-16 13:48:15 +00:00
res.writeHead(404);
res.end();
2026-01-15 13:38:02 +02:00
});
2026-01-16 13:48:15 +00:00
server.listen(PORT, () => {
console.log(`Public Burn Server: http://localhost:${PORT}`)
});