2026-01-15 13:38:02 +02:00
|
|
|
<!doctype html>
|
|
|
|
|
<html lang="en">
|
|
|
|
|
<head>
|
2026-01-16 10:44:01 +00:00
|
|
|
<link
|
|
|
|
|
rel="icon"
|
|
|
|
|
href="data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🔥</text></svg>"
|
|
|
|
|
>
|
2026-01-15 13:38:02 +02:00
|
|
|
<meta charset="UTF-8" />
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
|
|
|
<title>PrivateBin Clone</title>
|
|
|
|
|
<style>
|
|
|
|
|
:root {
|
|
|
|
|
--bg: #1e1e1e;
|
|
|
|
|
--surface: #2d2d2d;
|
|
|
|
|
--primary: #10a37f;
|
|
|
|
|
--text: #ececec;
|
2026-01-16 13:48:15 +00:00
|
|
|
--smalltext: #888;
|
2026-01-15 13:38:02 +02:00
|
|
|
}
|
|
|
|
|
body {
|
|
|
|
|
background: var(--bg);
|
|
|
|
|
color: var(--text);
|
|
|
|
|
font-family: sans-serif;
|
|
|
|
|
display: flex;
|
|
|
|
|
align-items: center;
|
|
|
|
|
justify-content: center;
|
|
|
|
|
height: 100vh;
|
|
|
|
|
margin: 0;
|
|
|
|
|
}
|
|
|
|
|
.card {
|
|
|
|
|
width: 90%;
|
|
|
|
|
max-width: 600px;
|
|
|
|
|
background: var(--surface);
|
|
|
|
|
padding: 2rem;
|
|
|
|
|
border-radius: 12px;
|
|
|
|
|
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5);
|
|
|
|
|
}
|
|
|
|
|
textarea {
|
|
|
|
|
width: 100%;
|
|
|
|
|
height: 250px;
|
|
|
|
|
background: #111;
|
|
|
|
|
border: 1px solid #444;
|
|
|
|
|
color: #fff;
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
resize: none;
|
|
|
|
|
box-sizing: border-box;
|
|
|
|
|
outline: none;
|
|
|
|
|
}
|
|
|
|
|
textarea:focus {
|
|
|
|
|
border-color: var(--primary);
|
|
|
|
|
}
|
|
|
|
|
.btn {
|
|
|
|
|
width: 100%;
|
|
|
|
|
background: var(--primary);
|
|
|
|
|
color: white;
|
|
|
|
|
border: none;
|
|
|
|
|
padding: 15px;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
font-size: 16px;
|
|
|
|
|
font-weight: bold;
|
|
|
|
|
margin-top: 1rem;
|
|
|
|
|
}
|
|
|
|
|
.btn:active {
|
|
|
|
|
transform: scale(0.98);
|
|
|
|
|
}
|
|
|
|
|
.url-box {
|
|
|
|
|
background: #111;
|
|
|
|
|
padding: 15px;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
border: 1px dashed var(--primary);
|
|
|
|
|
margin-top: 1rem;
|
|
|
|
|
cursor: pointer;
|
|
|
|
|
word-break: break-all;
|
|
|
|
|
text-align: center;
|
|
|
|
|
}
|
|
|
|
|
.hidden {
|
|
|
|
|
display: none;
|
|
|
|
|
}
|
2026-01-16 13:48:15 +00:00
|
|
|
.expires {
|
|
|
|
|
font-size: 12px;
|
|
|
|
|
color: var(--smalltext);
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
text-align: center;
|
|
|
|
|
}
|
|
|
|
|
.expires > input {
|
|
|
|
|
background: #111;
|
|
|
|
|
border: 1px solid #444;
|
|
|
|
|
color: var(--text);
|
|
|
|
|
}
|
2026-01-15 13:38:02 +02:00
|
|
|
.status {
|
|
|
|
|
font-size: 12px;
|
2026-01-16 13:48:15 +00:00
|
|
|
color: var(--smalltext);
|
2026-01-15 13:38:02 +02:00
|
|
|
margin-top: 8px;
|
|
|
|
|
text-align: center;
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<div class="card" id="main">
|
|
|
|
|
<div id="input-view">
|
2026-01-16 10:27:38 +00:00
|
|
|
<h2 id="send-status" style="margin-bottom: 0"></h2>
|
2026-01-15 13:38:02 +02:00
|
|
|
<textarea
|
|
|
|
|
id="text"
|
|
|
|
|
placeholder="Type or paste your secret here..."
|
|
|
|
|
></textarea>
|
2026-01-16 13:48:15 +00:00
|
|
|
<div class="expires">
|
|
|
|
|
Expires in <input id="max-age" type="number" min="1" max="1000" value="48"> hours if unread.
|
|
|
|
|
</div>
|
2026-01-15 13:38:02 +02:00
|
|
|
<button class="btn" onclick="burnIt()">Create Burn Link</button>
|
|
|
|
|
<div class="status">
|
|
|
|
|
Maximum size: 10MB. Content is encrypted in-browser.
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div id="result-view" class="hidden">
|
|
|
|
|
<h2 style="margin-top: 0">Link Generated!</h2>
|
|
|
|
|
<p style="color: #aaa">
|
|
|
|
|
Copy the link below. It will be deleted forever after the
|
|
|
|
|
first person opens it.
|
|
|
|
|
</p>
|
|
|
|
|
<div
|
|
|
|
|
class="url-box"
|
|
|
|
|
id="url-display"
|
|
|
|
|
onclick="copyToClipboard()"
|
|
|
|
|
></div>
|
|
|
|
|
<button
|
|
|
|
|
class="btn"
|
|
|
|
|
style="background: #444"
|
2026-01-16 10:55:16 +00:00
|
|
|
onclick="reload()"
|
2026-01-15 13:38:02 +02:00
|
|
|
>
|
|
|
|
|
Create Another
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<div id="read-view" class="hidden">
|
|
|
|
|
<h2 id="read-status" style="margin-bottom: 0">Decrypting...</h2>
|
|
|
|
|
<div id="content-container" class="hidden">
|
|
|
|
|
<div
|
|
|
|
|
id="content"
|
|
|
|
|
style="
|
|
|
|
|
white-space: pre-wrap;
|
|
|
|
|
background: #111;
|
|
|
|
|
padding: 1rem;
|
|
|
|
|
border-radius: 8px;
|
|
|
|
|
margin-top: 1rem;
|
|
|
|
|
border: 1px solid #333;
|
|
|
|
|
max-height: 400px;
|
|
|
|
|
overflow-y: auto;
|
|
|
|
|
"
|
|
|
|
|
></div>
|
|
|
|
|
<div style="display: flex; gap: 10px; margin-top: 1rem">
|
|
|
|
|
<button
|
|
|
|
|
class="btn"
|
|
|
|
|
onclick="copyDecryptedText()"
|
|
|
|
|
style="flex: 1"
|
|
|
|
|
>
|
|
|
|
|
📋 Copy Secret
|
|
|
|
|
</button>
|
|
|
|
|
<button
|
|
|
|
|
class="btn secondary"
|
|
|
|
|
onclick="location.href = '/'"
|
|
|
|
|
style="flex: 1; background: #444"
|
|
|
|
|
>
|
|
|
|
|
Dismiss
|
|
|
|
|
</button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
<script>
|
2026-01-16 13:48:15 +00:00
|
|
|
// Make sure these match MAX_SIZE_BYTES and MAX_AGE_HOURS in server.js
|
2026-01-16 10:27:38 +00:00
|
|
|
const MAX_SIZE_BYTES = 10 * 1024 * 1024; // 10MB
|
2026-01-16 13:48:15 +00:00
|
|
|
const MAX_AGE_HOURS = 1000;
|
2026-01-16 10:27:38 +00:00
|
|
|
|
2026-01-15 13:38:02 +02:00
|
|
|
// Check for existing link in URL on load
|
|
|
|
|
window.onload = () => {
|
|
|
|
|
if (window.location.hash.includes("id=")) initDecryption();
|
|
|
|
|
};
|
|
|
|
|
|
2026-01-16 10:55:16 +00:00
|
|
|
// This is a workaround for Firefox trying to be helpful. Most
|
|
|
|
|
// browsers clear content on reload, but Firefox does not.
|
|
|
|
|
async function reload() {
|
|
|
|
|
document.getElementById("text").value = "";
|
|
|
|
|
window.location.reload();
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 13:38:02 +02:00
|
|
|
async function burnIt() {
|
2026-01-16 13:48:15 +00:00
|
|
|
const text = document.getElementById("text").value;
|
|
|
|
|
const maxAge = document.getElementById("max-age").value;
|
2026-01-15 13:38:02 +02:00
|
|
|
if (!text) return;
|
|
|
|
|
|
2026-01-16 13:48:15 +00:00
|
|
|
if (maxAge > MAX_AGE_HOURS) {
|
|
|
|
|
setErrorMessage(
|
|
|
|
|
"send-status",
|
|
|
|
|
`maximum age is higher than ${MAX_AGE_HOURS}`
|
|
|
|
|
);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 13:38:02 +02:00
|
|
|
// 1. Generate Key
|
|
|
|
|
const key = await crypto.subtle.generateKey(
|
|
|
|
|
{ name: "AES-GCM", length: 256 },
|
|
|
|
|
true,
|
|
|
|
|
["encrypt", "decrypt"],
|
|
|
|
|
);
|
|
|
|
|
const iv = crypto.getRandomValues(new Uint8Array(12));
|
|
|
|
|
|
|
|
|
|
// 2. Encrypt
|
|
|
|
|
const encrypted = await crypto.subtle.encrypt(
|
|
|
|
|
{ name: "AES-GCM", iv },
|
|
|
|
|
key,
|
|
|
|
|
new TextEncoder().encode(text),
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const blob = new Blob([iv, encrypted]);
|
2026-01-16 10:27:38 +00:00
|
|
|
|
|
|
|
|
if (blob.size > MAX_SIZE_BYTES) {
|
|
|
|
|
setErrorMessage("send-status", "message too large");
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 13:48:15 +00:00
|
|
|
// 3. Upload Blob (IV + Ciphertext)
|
2026-01-16 10:27:38 +00:00
|
|
|
let res;
|
|
|
|
|
try {
|
2026-01-16 13:48:15 +00:00
|
|
|
res = await fetch(`/api/secret?max-age=${maxAge}`, {
|
2026-01-16 10:27:38 +00:00
|
|
|
method: "POST",
|
|
|
|
|
body: blob,
|
|
|
|
|
});
|
|
|
|
|
if (!res.ok) throw new Error(res.statusText);
|
|
|
|
|
// We need this catch since res.ok only works with some errors
|
|
|
|
|
} catch (e) {
|
|
|
|
|
let msg = e.message.replace("NetworkError", "networking problem");
|
|
|
|
|
setErrorMessage("send-status", msg);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 13:38:02 +02:00
|
|
|
const { id } = await res.json();
|
|
|
|
|
|
|
|
|
|
// 4. Create Hash-Link (Key stays in hash, never sent to server)
|
|
|
|
|
const jwk = await crypto.subtle.exportKey("jwk", key);
|
|
|
|
|
const keyStr = btoa(JSON.stringify(jwk));
|
|
|
|
|
const fullUrl = `${window.location.origin}/#id=${id}&key=${keyStr}`;
|
|
|
|
|
|
|
|
|
|
document.getElementById("input-view").classList.add("hidden");
|
|
|
|
|
document
|
|
|
|
|
.getElementById("result-view")
|
|
|
|
|
.classList.remove("hidden");
|
|
|
|
|
document.getElementById("url-display").innerText = fullUrl;
|
|
|
|
|
|
|
|
|
|
navigator.clipboard.writeText(fullUrl);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function initDecryption() {
|
|
|
|
|
document.getElementById("input-view").classList.add("hidden");
|
|
|
|
|
document.getElementById("read-view").classList.remove("hidden");
|
|
|
|
|
const statusEl = document.getElementById("read-status");
|
|
|
|
|
const contentContainer =
|
|
|
|
|
document.getElementById("content-container");
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const params = new URLSearchParams(
|
|
|
|
|
window.location.hash.substring(1),
|
|
|
|
|
);
|
|
|
|
|
const id = params.get("id");
|
|
|
|
|
const keyStr = params.get("key");
|
|
|
|
|
|
|
|
|
|
if (!id || !keyStr)
|
|
|
|
|
throw new Error("Invalid or malformed link.");
|
|
|
|
|
|
|
|
|
|
const res = await fetch(`/api/secret/${id}`);
|
|
|
|
|
if (!res.ok)
|
|
|
|
|
throw new Error(
|
2026-01-16 13:48:15 +00:00
|
|
|
"this secret was already burned or never existed",
|
2026-01-15 13:38:02 +02:00
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const buf = await res.arrayBuffer();
|
|
|
|
|
const iv = buf.slice(0, 12);
|
|
|
|
|
const data = buf.slice(12);
|
|
|
|
|
|
|
|
|
|
const jwk = JSON.parse(atob(keyStr));
|
|
|
|
|
const key = await window.crypto.subtle.importKey(
|
|
|
|
|
"jwk",
|
|
|
|
|
jwk,
|
|
|
|
|
"AES-GCM",
|
|
|
|
|
true,
|
|
|
|
|
["decrypt"],
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
const decrypted = await crypto.subtle.decrypt(
|
|
|
|
|
{ name: "AES-GCM", iv },
|
|
|
|
|
key,
|
|
|
|
|
data,
|
|
|
|
|
);
|
|
|
|
|
const text = new TextDecoder().decode(decrypted);
|
|
|
|
|
|
|
|
|
|
// Update Header with Warning
|
|
|
|
|
statusEl.innerHTML = `
|
|
|
|
|
<span style="color: var(--primary)">🔥 Secret Decrypted & Burned</span>
|
|
|
|
|
<div style="font-size: 0.9rem; color: #ffab00; margin-top: 12px; font-weight: normal; line-height: 1.4; border-left: 3px solid #ffab00; padding-left: 10px;">
|
|
|
|
|
<strong>Attention:</strong> This is the only time and place you can view this secret.
|
|
|
|
|
It has been deleted from the server. Copy it now before closing this tab.
|
|
|
|
|
</div>
|
|
|
|
|
`;
|
|
|
|
|
|
|
|
|
|
document.getElementById("content").innerText = text;
|
|
|
|
|
contentContainer.classList.remove("hidden");
|
|
|
|
|
|
|
|
|
|
// Wipe hash from URL immediately for safety
|
|
|
|
|
window.history.replaceState(null, null, " ");
|
|
|
|
|
} catch (e) {
|
2026-01-16 10:27:38 +00:00
|
|
|
setErrorMessage("read-status", e.message);
|
2026-01-15 13:38:02 +02:00
|
|
|
const content = document.getElementById("content");
|
|
|
|
|
contentContainer.classList.remove("hidden");
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-16 10:27:38 +00:00
|
|
|
function setErrorMessage(id, msg) {
|
|
|
|
|
const statusEl = document.getElementById(id);
|
|
|
|
|
statusEl.innerText = `❌ Error: ${msg}`;
|
|
|
|
|
statusEl.style.color = "#ff4444";
|
|
|
|
|
}
|
|
|
|
|
|
2026-01-15 13:38:02 +02:00
|
|
|
function copyDecryptedText() {
|
|
|
|
|
const text = document.getElementById("content").innerText;
|
|
|
|
|
navigator.clipboard.writeText(text);
|
|
|
|
|
const copyBtn = event.target;
|
|
|
|
|
const originalText = copyBtn.innerText;
|
|
|
|
|
copyBtn.innerText = "✅ Copied!";
|
|
|
|
|
setTimeout(() => (copyBtn.innerText = originalText), 2000);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function copyToClipboard() {
|
|
|
|
|
const text = document.getElementById("url-display").innerText;
|
|
|
|
|
navigator.clipboard.writeText(text);
|
|
|
|
|
alert("Copied!");
|
|
|
|
|
}
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|