Add support for expiring links.

This commit is contained in:
Marsell Kukuljevic 2026-01-16 13:48:15 +00:00
parent fff882a849
commit f45a50b2b9
2 changed files with 98 additions and 29 deletions

View File

@ -14,6 +14,7 @@
--surface: #2d2d2d;
--primary: #10a37f;
--text: #ececec;
--smalltext: #888;
}
body {
background: var(--bg);
@ -77,9 +78,20 @@
.hidden {
display: none;
}
.expires {
font-size: 12px;
color: var(--smalltext);
margin-top: 8px;
text-align: center;
}
.expires > input {
background: #111;
border: 1px solid #444;
color: var(--text);
}
.status {
font-size: 12px;
color: #888;
color: var(--smalltext);
margin-top: 8px;
text-align: center;
}
@ -93,6 +105,9 @@
id="text"
placeholder="Type or paste your secret here..."
></textarea>
<div class="expires">
Expires in <input id="max-age" type="number" min="1" max="1000" value="48"> hours if unread.
</div>
<button class="btn" onclick="burnIt()">Create Burn Link</button>
<div class="status">
Maximum size: 10MB. Content is encrypted in-browser.
@ -156,8 +171,9 @@
</div>
<script>
// Make sure this matches MAX_SIZE_BYTES in server.js
// Make sure these match MAX_SIZE_BYTES and MAX_AGE_HOURS in server.js
const MAX_SIZE_BYTES = 10 * 1024 * 1024; // 10MB
const MAX_AGE_HOURS = 1000;
// Check for existing link in URL on load
window.onload = () => {
@ -172,9 +188,18 @@
}
async function burnIt() {
const text = document.getElementById("text").value;
const text = document.getElementById("text").value;
const maxAge = document.getElementById("max-age").value;
if (!text) return;
if (maxAge > MAX_AGE_HOURS) {
setErrorMessage(
"send-status",
`maximum age is higher than ${MAX_AGE_HOURS}`
);
return;
}
// 1. Generate Key
const key = await crypto.subtle.generateKey(
{ name: "AES-GCM", length: 256 },
@ -190,7 +215,6 @@
new TextEncoder().encode(text),
);
// 3. Upload Blob (IV + Ciphertext)
const blob = new Blob([iv, encrypted]);
if (blob.size > MAX_SIZE_BYTES) {
@ -198,9 +222,10 @@
return;
}
// 3. Upload Blob (IV + Ciphertext)
let res;
try {
res = await fetch("/api/secret", {
res = await fetch(`/api/secret?max-age=${maxAge}`, {
method: "POST",
body: blob,
});
@ -248,7 +273,7 @@
const res = await fetch(`/api/secret/${id}`);
if (!res.ok)
throw new Error(
"This secret was already burned or never existed.",
"this secret was already burned or never existed",
);
const buf = await res.arrayBuffer();

View File

@ -6,6 +6,11 @@ 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);
@ -14,6 +19,24 @@ const sendJSON = (res, status, data) => {
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;
@ -25,13 +48,24 @@ const server = http.createServer((req, res) => {
}
// CREATE Secret (Public POST)
if (method === 'POST' && url === '/api/secret') {
if (method === 'POST' && url.startsWith('/api/secret')) {
const contentLength = parseInt(req.headers['content-length'], 10);
if (contentLength > MAX_SIZE_BYTES) {
res.writeHead(413); return res.end('Too large');
res.writeHead(413);
res.end('too large');
return;
}
const id = crypto.randomUUID(); // Unique and Random URL component
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);
@ -41,32 +75,42 @@ const server = http.createServer((req, res) => {
return;
}
// READ & BURN Secret
if (method === 'GET' && url.startsWith('/api/secret/')) {
const id = url.split('/').pop();
if (!/^[a-z0-9-]+$/i.test(id)) { res.writeHead(400); return res.end(); }
const filePath = getSafeFilePath(url);
const maxAge = filePath.split('-').pop();
const filePath = path.join(DATA_DIR, id);
if (!fs.existsSync(filePath)) {
res.writeHead(404); return res.end('Burned or Not Found');
}
return fs.stat(filePath, (err, stats) => {
if (err) {
res.writeHead(404);
res.end('Burned or Not Found');
return;
}
const readStream = fs.createReadStream(filePath);
res.writeHead(200, {
'Content-Type': 'application/octet-stream',
'Cache-Control': 'no-store, no-cache, must-revalidate',
'Expires': 0
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));
});
readStream.pipe(res);
// Delete immediately after stream ends
readStream.on('end', () => {
fs.unlink(filePath, (err) => { if (err) console.error("Burn failed", err); });
});
return;
}
res.writeHead(404); res.end();
res.writeHead(404);
res.end();
});
server.listen(PORT, () => console.log(`Public Burn Server: http://localhost:${PORT}`));
server.listen(PORT, () => {
console.log(`Public Burn Server: http://localhost:${PORT}`)
});