117 lines
3.7 KiB
JavaScript
117 lines
3.7 KiB
JavaScript
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}`)
|
|
});
|