forked from Spearhead/privateburn
Add support for expiring links.
This commit is contained in:
parent
fff882a849
commit
f45a50b2b9
37
index.html
37
index.html
@ -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();
|
||||
|
||||
90
server.js
90
server.js
@ -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}`)
|
||||
});
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user