/**
* Browser app for composing, encrypting, viewing, and sharing encrypted pastes.
*/
const THEME_STORAGE_KEY = 'theme';
const LOAD_CONFIRM_PREFIX = '-';
// Static labels and runtime state drive both the compose and read-only views.
const EXPIRATION_LABELS = {
'5min': '5 minutes',
'10min': '10 minutes',
'30min': '30 minutes',
'1hour': '1 hour',
'3hour': '3 hours',
'6hour': '6 hours',
'12hour': '12 hours',
'1day': '1 day',
'3day': '3 days',
'1week': '1 week',
'1month': '1 month',
'1year': '1 year',
never: 'Never',
};
const DURATION_LABELS = {
300: '5 minutes',
600: '10 minutes',
1800: '30 minutes',
3600: '1 hour',
10800: '3 hours',
21600: '6 hours',
43200: '12 hours',
86400: '1 day',
259200: '3 days',
604800: '1 week',
2592000: '1 month',
31536000: '1 year',
};
const state = {
config: null,
page: 'compose',
composerTab: 'editor',
pasteId: null,
pasteKey: null,
claimToken: null,
loadConfirmRequired: false,
viewPassword: '',
pasteData: null,
currentFormatter: 'plaintext',
currentAttachments: [],
shareResultUrl: null,
shareDeleteUrl: null,
draftFiles: [],
pendingAttachmentEntries: [],
retryAction: null,
};
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
const bootstrapApi = window.bootstrap;
const markdownConverter =
typeof window.showdown !== 'undefined'
? new window.showdown.Converter({
simplifiedAutoLink: true,
strikethrough: true,
tables: true,
tasklists: true,
ghCodeBlocks: true,
simpleLineBreaks: true,
})
: null;
const elements = {
body: document.body,
brandLink: document.getElementById('brand-link'),
brandIcon: document.getElementById('brand-icon'),
metaTwitterTitle: document.getElementById('meta-twitter-title'),
metaOgTitle: document.getElementById('meta-og-title'),
metaOgSiteName: document.getElementById('meta-og-site-name'),
loadingindicator: document.getElementById('loadingindicator'),
loadingindicatorText: document.getElementById('loadingindicator-text'),
retrybutton: document.getElementById('retrybutton'),
newbutton: document.getElementById('newbutton'),
clonebutton: document.getElementById('clonebutton'),
rawtextbutton: document.getElementById('rawtextbutton'),
downloadtextbutton: document.getElementById('downloadtextbutton'),
emaillink: document.getElementById('emaillink'),
qrcodelink: document.getElementById('qrcodelink'),
pasteExpiration: document.getElementById('pasteExpiration'),
burnafterreading: document.getElementById('burnafterreading'),
opendiscussion: document.getElementById('opendiscussion'),
passwordinput: document.getElementById('passwordinput'),
file: document.getElementById('file'),
customattachment: document.getElementById('customattachment'),
fileremovebutton: document.getElementById('fileremovebutton'),
pasteFormatter: document.getElementById('pasteFormatter'),
themeToggle: document.getElementById('bd-theme'),
editorTabs: document.getElementById('editorTabs'),
messageedit: document.getElementById('messageedit'),
messagepreview: document.getElementById('messagepreview'),
sendbutton: document.getElementById('sendbutton'),
remainingtime: document.getElementById('remainingtime'),
remainingtimeText: document.getElementById('remainingtime-text'),
attachment: document.getElementById('attachment'),
attachmentPreview: document.getElementById('attachmentPreview'),
status: document.getElementById('status'),
statusText: document.getElementById('status-text'),
newFromAlert: document.getElementById('new-from-alert'),
errormessage: document.getElementById('errormessage'),
errorText: document.getElementById('error-text'),
pastesuccess: document.getElementById('pastesuccess'),
copyLink: document.getElementById('copyLink'),
deletelink: document.getElementById('deletelink'),
pastelink: document.getElementById('pastelink'),
placeholder: document.getElementById('placeholder'),
copyShortcutHint: document.getElementById('copyShortcutHint'),
copyShortcutHintText: document.getElementById('copyShortcutHintText'),
prettymessage: document.getElementById('prettymessage'),
prettyprint: document.getElementById('prettyprint'),
prettyMessageCopyBtn: document.getElementById('prettyMessageCopyBtn'),
copyIcon: document.getElementById('copyIcon'),
copySuccessIcon: document.getElementById('copySuccessIcon'),
plaintext: document.getElementById('plaintext'),
message: document.getElementById('message'),
messagetab: document.getElementById('messagetab'),
discussion: document.getElementById('discussion'),
commentcontainer: document.getElementById('commentcontainer'),
loadingnotice: document.getElementById('loadingnotice'),
footerAppName: document.getElementById('footer-app-name'),
footerVersion: document.getElementById('footer-version'),
aboutAppName: document.getElementById('about-app-name'),
projectPageLink: document.getElementById('project-page-link'),
passwordmodal: document.getElementById('passwordmodal'),
passwordform: document.getElementById('passwordform'),
passworddecrypt: document.getElementById('passworddecrypt'),
loadconfirmmodal: document.getElementById('loadconfirmmodal'),
loadconfirmOpenNow: document.getElementById('loadconfirm-open-now'),
qrcodemodal: document.getElementById('qrcodemodal'),
qrcodeDisplay: document.getElementById('qrcode-display'),
emailconfirmmodal: document.getElementById('emailconfirmmodal'),
emailconfirmTimezoneCurrent: document.getElementById('emailconfirm-timezone-current'),
emailconfirmTimezoneUtc: document.getElementById('emailconfirm-timezone-utc'),
commenttemplate: document.getElementById('commenttemplate'),
commenttailtemplate: document.getElementById('commenttailtemplate'),
replytemplate: document.getElementById('replytemplate'),
attachmenttemplate: document.getElementById('attachmenttemplate'),
dropzone: document.getElementById('dropzone'),
composeControls: [
document.getElementById('expiration'),
document.getElementById('burnafterreadingoption'),
document.getElementById('opendiscussionoption'),
document.getElementById('password'),
document.getElementById('attach'),
document.getElementById('formatter'),
],
passwordToggles: [...document.querySelectorAll('.toggle-password')],
};
// Encoding and crypto helpers mirror the PrivateBin-style client-side envelope format.
function escapeHtml(value) {
return String(value).replace(/[&<>"']/g, (character) => {
switch (character) {
case '&':
return '&';
case '<':
return '<';
case '>':
return '>';
case '"':
return '"';
case "'":
return ''';
default:
return character;
}
});
}
function sanitizeUrl(url) {
try {
const normalized = new URL(url, window.location.origin);
if (['http:', 'https:', 'mailto:'].includes(normalized.protocol)) {
return normalized.href;
}
} catch {
return null;
}
return null;
}
function bytesToBase64(bytes) {
let binary = '';
for (const byte of bytes) {
binary += String.fromCharCode(byte);
}
return btoa(binary);
}
function base64Encode(bytes) {
return bytesToBase64(bytes);
}
function base64Decode(value) {
const normalized = value.replace(/-/g, '+').replace(/_/g, '/');
const padded = normalized + '='.repeat((4 - (normalized.length % 4)) % 4);
const binary = atob(padded);
return Uint8Array.from(binary, (character) => character.charCodeAt(0));
}
function base64UrlEncode(bytes) {
return bytesToBase64(bytes).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, '');
}
function base64UrlDecode(value) {
return base64Decode(value);
}
function randomKey() {
return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)));
}
function buildCipherSpec() {
const iv = crypto.getRandomValues(new Uint8Array(16));
const salt = crypto.getRandomValues(new Uint8Array(8));
return [base64Encode(iv), base64Encode(salt), 100000, 256, 128, 'aes', 'gcm', 'none'];
}
function getCipherSpec(envelope) {
if (!Array.isArray(envelope?.adata)) {
throw new Error('Envelope is missing authenticated data.');
}
return Array.isArray(envelope.adata[0]) ? envelope.adata[0] : envelope.adata;
}
async function deriveKey(secretKey, password, spec) {
const secretBytes = base64UrlDecode(secretKey);
const passwordBytes = textEncoder.encode(password ?? '');
const material = new Uint8Array(secretBytes.length + passwordBytes.length);
material.set(secretBytes);
material.set(passwordBytes, secretBytes.length);
const keyMaterial = await crypto.subtle.importKey('raw', material, 'PBKDF2', false, ['deriveKey']);
return crypto.subtle.deriveKey(
{
name: 'PBKDF2',
hash: 'SHA-256',
salt: base64Decode(spec[1]),
iterations: Number(spec[2]),
},
keyMaterial,
{ name: `AES-${String(spec[6] ?? 'gcm').toUpperCase()}`, length: Number(spec[3]) },
false,
['encrypt', 'decrypt'],
);
}
async function encryptCipherPayload(value, secretKey, password, adata, spec) {
const key = await deriveKey(secretKey, password, spec);
const cipherText = await crypto.subtle.encrypt(
{
name: 'AES-GCM',
iv: base64Decode(spec[0]),
tagLength: Number(spec[4]),
additionalData: textEncoder.encode(JSON.stringify(adata)),
},
key,
textEncoder.encode(JSON.stringify(value)),
);
return {
v: 2,
adata,
ct: base64Encode(new Uint8Array(cipherText)),
};
}
async function encryptPasteEnvelope(value, secretKey, password, formatter, discussionOpen, burnAfterReading) {
const spec = buildCipherSpec();
const adata = [spec, formatter, discussionOpen ? 1 : 0, burnAfterReading ? 1 : 0];
return encryptCipherPayload(value, secretKey, password, adata, spec);
}
async function encryptCommentEnvelope(value, secretKey, password) {
const spec = buildCipherSpec();
return encryptCipherPayload(value, secretKey, password, spec, spec);
}
function buildDecryptAlgorithm(spec, adata) {
const mode = String(spec[6] ?? 'gcm').toLowerCase();
const iv = base64Decode(spec[0]);
if (mode === 'gcm') {
return {
name: 'AES-GCM',
iv,
tagLength: Number(spec[4]),
additionalData: textEncoder.encode(JSON.stringify(adata)),
};
}
if (mode === 'cbc') {
return { name: 'AES-CBC', iv };
}
if (mode === 'ctr') {
return { name: 'AES-CTR', counter: iv, length: 128 };
}
throw new Error(`Unsupported cipher mode: ${mode}`);
}
async function inflateBytes(bytes, format) {
const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream(format));
return new Uint8Array(await new Response(stream).arrayBuffer());
}
async function maybeDecompress(bytes, mode) {
if (mode !== 'zlib') {
return bytes;
}
if (typeof DecompressionStream === 'undefined') {
throw new Error('This browser cannot decompress legacy PrivateBin payloads.');
}
try {
return await inflateBytes(bytes, 'deflate');
} catch {
return inflateBytes(bytes, 'deflate-raw');
}
}
async function decryptEnvelope(envelope, secretKey, password) {
const spec = getCipherSpec(envelope);
const key = await deriveKey(secretKey, password, spec);
const plaintext = await crypto.subtle.decrypt(buildDecryptAlgorithm(spec, envelope.adata), key, base64Decode(envelope.ct));
const decompressed = await maybeDecompress(new Uint8Array(plaintext), String(spec[7] ?? 'none').toLowerCase());
return JSON.parse(textDecoder.decode(decompressed));
}
// UI chrome helpers keep the SPA shell, theme, and rendered content in sync with app state.
function humanizeExpiration(value) {
return EXPIRATION_LABELS[value] ?? value;
}
function secondsToHuman(seconds) {
const minute = 60;
const hour = 3600;
const day = 86400;
const month = 2592000;
if (seconds < minute) {
return [Math.floor(seconds), 'second'];
}
if (seconds < hour) {
return [Math.floor(seconds / minute), 'minute'];
}
if (seconds < day) {
return [Math.floor(seconds / hour), 'hour'];
}
if (seconds < 2 * month) {
return [Math.floor(seconds / day), 'day'];
}
return [Math.floor(seconds / month), 'month'];
}
function humanizeDuration(seconds) {
if (DURATION_LABELS[seconds]) {
return DURATION_LABELS[seconds];
}
const [value, unit] = secondsToHuman(seconds);
return `${value} ${unit}${value === 1 ? '' : 's'}`;
}
function buildHomeUrl() {
const basePath = state.config?.basePath ?? '/';
return new URL(basePath, window.location.origin);
}
function buildShareUrl(shareUrlFromServer, secretKey, burnAfterReading) {
return `${shareUrlFromServer}#${burnAfterReading ? LOAD_CONFIRM_PREFIX : ''}${secretKey}`;
}
function buildDeleteUrl(pasteId, deleteToken) {
const url = buildHomeUrl();
url.searchParams.set('pasteid', pasteId);
url.searchParams.set('deletetoken', deleteToken);
return url.toString();
}
function isSharePageLocation() {
return Boolean(getPasteLocation().pasteId);
}
function getPasteLocation() {
const params = new URLSearchParams(window.location.search);
let pasteId = params.get('pasteid');
if (!pasteId && window.location.search.startsWith('?') && !window.location.search.includes('=')) {
pasteId = window.location.search.slice(1);
}
const hash = window.location.hash ? window.location.hash.slice(1) : null;
const loadConfirmRequired = hash ? hash.startsWith(LOAD_CONFIRM_PREFIX) : false;
const pasteKey = hash ? (loadConfirmRequired ? hash.slice(LOAD_CONFIRM_PREFIX.length) : hash) : null;
return {
pasteId,
pasteKey,
deleteToken: params.get('deletetoken'),
loadConfirmRequired,
};
}
function selectText(element) {
const selection = window.getSelection();
const range = document.createRange();
range.selectNodeContents(element);
selection.removeAllRanges();
selection.addRange(range);
}
function hideLoading() {
elements.body.classList.remove('loading');
elements.loadingindicator.classList.add('hidden');
}
function showLoading(message) {
elements.body.classList.add('loading');
elements.loadingindicator.classList.remove('hidden');
elements.loadingindicatorText.textContent = message;
}
function setRetryAction(action) {
state.retryAction = action;
elements.retrybutton.classList.toggle('hidden', typeof action !== 'function');
}
function showAlert(target, textElement, message, showStartOver = false) {
textElement.textContent = message;
target.classList.remove('hidden');
elements.newFromAlert.classList.toggle('hidden', !showStartOver);
}
function hideStatus() {
elements.status.classList.add('hidden');
elements.statusText.textContent = '';
elements.newFromAlert.classList.add('hidden');
}
function hideError() {
elements.errormessage.classList.add('hidden');
elements.errorText.textContent = '';
}
function hideRemaining() {
elements.remainingtime.classList.add('hidden');
elements.remainingtime.classList.remove('foryoureyesonly');
elements.remainingtimeText.textContent = '';
}
function showStatus(message, showStartOver = false) {
hideError();
showAlert(elements.status, elements.statusText, message, showStartOver);
}
function showError(message) {
hideStatus();
elements.errorText.textContent = message;
elements.errormessage.classList.remove('hidden');
}
function showRemaining(message, burnAfterReading = false) {
elements.remainingtimeText.textContent = message;
elements.remainingtime.classList.toggle('foryoureyesonly', burnAfterReading);
elements.remainingtime.classList.remove('hidden');
}
function setCopyShortcutHint(message = '') {
elements.copyShortcutHintText.innerHTML = message;
elements.copyShortcutHint.classList.toggle('hidden', message.length === 0);
}
function setTheme(theme) {
document.documentElement.setAttribute('data-bs-theme', theme);
elements.themeToggle.checked = theme === 'dark';
}
function preferredTheme() {
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
}
function initializeTheme() {
const stored = window.localStorage.getItem(THEME_STORAGE_KEY);
setTheme(stored === 'light' || stored === 'dark' ? stored : preferredTheme());
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', () => {
if (!window.localStorage.getItem(THEME_STORAGE_KEY)) {
setTheme(preferredTheme());
}
});
}
function syncBranding(appName) {
document.title = isSharePageLocation() ? `Encrypted note on ${appName}` : `${appName} - Private, end-to-end encrypted pastebin`;
elements.brandIcon.alt = appName;
elements.footerAppName.textContent = appName;
elements.aboutAppName.textContent = appName;
elements.metaTwitterTitle?.setAttribute('content', `Encrypted note on ${appName}`);
elements.metaOgTitle?.setAttribute('content', appName);
elements.metaOgSiteName?.setAttribute('content', appName);
}
function syncUiChrome(config) {
syncBranding(config.appName);
document.documentElement.lang = config.uiLanguageCode ?? 'en';
if (config.uiLanguageRtl) {
document.documentElement.setAttribute('dir', 'rtl');
} else {
document.documentElement.removeAttribute('dir');
}
if (elements.footerVersion) {
elements.footerVersion.textContent = config.appVersion ?? '1.0.0';
}
if (elements.projectPageLink && config.projectPageUrl) {
elements.projectPageLink.href = config.projectPageUrl;
}
}
function renderExpirationOptions(config) {
elements.pasteExpiration.innerHTML = '';
for (const value of config.supportedExpirations) {
const option = document.createElement('option');
option.value = value;
option.textContent = humanizeExpiration(value);
if (value === config.defaultExpiration) {
option.selected = true;
}
elements.pasteExpiration.append(option);
}
}
function hardenLinks(container) {
container.querySelectorAll('a').forEach((link) => {
link.setAttribute('target', '_blank');
link.setAttribute('rel', 'noreferrer noopener');
});
}
function linkifyPlainText(text) {
const escaped = escapeHtml(text);
return escaped.replace(/((?:https?:\/\/|mailto:)[^\s<]+)/g, (match) => {
const safeUrl = sanitizeUrl(match);
if (!safeUrl) {
return match;
}
return `${match}`;
});
}
function sanitizeHtml(html) {
if (window.DOMPurify) {
return window.DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
});
}
return html;
}
function clearRenderedMessage() {
elements.placeholder.classList.add('hidden');
elements.prettymessage.classList.add('hidden');
elements.plaintext.classList.add('hidden');
elements.plaintext.classList.remove('markdown-view');
elements.plaintext.innerHTML = '';
elements.prettyprint.innerHTML = '';
}
function renderMessageContent(content, formatter) {
clearRenderedMessage();
if (!content) {
elements.placeholder.classList.remove('hidden');
return;
}
if (formatter === 'markdown' && markdownConverter) {
const html = sanitizeHtml(markdownConverter.makeHtml(content));
elements.plaintext.classList.add('markdown-view');
elements.plaintext.innerHTML = html;
elements.plaintext.classList.remove('hidden');
hardenLinks(elements.plaintext);
return;
}
if (formatter === 'syntaxhighlighting') {
const escaped = escapeHtml(content);
const highlighted = window.PR && typeof window.PR.prettyPrintOne === 'function' ? window.PR.prettyPrintOne(escaped) : escaped;
elements.prettyprint.innerHTML = highlighted;
elements.prettymessage.classList.remove('hidden');
return;
}
elements.plaintext.innerHTML = linkifyPlainText(content).replace(/\n/g, '
');
elements.plaintext.classList.remove('hidden');
hardenLinks(elements.plaintext);
}
function renderPreview() {
renderMessageContent(elements.message.value, elements.pasteFormatter.value);
}
function setComposerTab(tab) {
state.composerTab = tab;
const preview = tab === 'preview';
elements.messageedit.classList.toggle('active', !preview);
elements.messagepreview.classList.toggle('active', preview);
elements.message.classList.toggle('hidden', preview);
if (preview) {
renderPreview();
} else {
clearRenderedMessage();
}
}
function updateTopNav() {
const isCompose = state.page === 'compose';
const hasPaste = Boolean(state.pasteData);
const isBurn = state.pasteData?.adata?.[3] === 1;
elements.body.dataset.page = isCompose ? 'compose' : 'view';
elements.body.dataset.hasPaste = hasPaste ? 'true' : 'false';
for (const control of elements.composeControls) {
control.classList.toggle('hidden', !isCompose);
}
elements.editorTabs.classList.toggle('hidden', !isCompose);
elements.sendbutton.classList.toggle('hidden', !isCompose);
elements.newbutton.classList.toggle('hidden', false);
elements.clonebutton.classList.toggle('hidden', isCompose || !hasPaste || isBurn);
elements.rawtextbutton.classList.toggle('hidden', isCompose || !hasPaste);
elements.downloadtextbutton.classList.toggle('hidden', isCompose || !hasPaste);
elements.emaillink.classList.toggle('hidden', isCompose || !hasPaste || isBurn);
elements.qrcodelink.classList.toggle('hidden', isCompose || !hasPaste || isBurn);
}
// Attachment helpers support uploads from the composer and legacy nested attachment payloads.
function updateAttachmentComposerSummary() {
elements.customattachment.innerHTML = '';
if (state.pendingAttachmentEntries.length > 0) {
for (const entry of state.pendingAttachmentEntries) {
const chip = document.createElement('div');
chip.className = 'attachment-chip';
chip.textContent = `Attached: ${entry.name}`;
elements.customattachment.append(chip);
}
elements.customattachment.classList.remove('hidden');
return;
}
if (state.draftFiles.length > 0) {
for (const file of state.draftFiles) {
const chip = document.createElement('div');
chip.className = 'attachment-chip';
chip.textContent = `Attached: ${file.name}`;
elements.customattachment.append(chip);
}
elements.customattachment.classList.remove('hidden');
return;
}
elements.customattachment.classList.add('hidden');
}
function clearDraftAttachments() {
state.draftFiles = [];
state.pendingAttachmentEntries = [];
elements.file.value = '';
updateAttachmentComposerSummary();
}
function readAttachmentFile(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve({ name: file.name, dataUrl: reader.result });
reader.onerror = () => reject(new Error('Failed to read attachment.'));
reader.readAsDataURL(file);
});
}
async function collectAttachmentEntries() {
if (state.pendingAttachmentEntries.length > 0) {
return state.pendingAttachmentEntries;
}
const files = state.draftFiles.length > 0 ? state.draftFiles : [...elements.file.files];
if (files.length === 0) {
return [];
}
return Promise.all(files.map((file) => readAttachmentFile(file)));
}
function normalizeAttachmentEntries(attachment, attachmentName) {
if (Array.isArray(attachment) && Array.isArray(attachmentName)) {
return attachment
.map((dataUrl, index) => ({
dataUrl,
name: attachmentName[index] ?? `attachment-${index + 1}`,
}))
.filter((entry) => typeof entry.dataUrl === 'string' && typeof entry.name === 'string');
}
if (typeof attachment === 'string' && typeof attachmentName === 'string') {
return [{ dataUrl: attachment, name: attachmentName }];
}
return [];
}
async function loadLegacyNestedAttachments(payload, secretKey, password) {
if (!payload.attachment || !payload.attachment_name) {
return [];
}
const attachment = await decryptEnvelope(payload.attachment, secretKey, password);
const attachmentName = await decryptEnvelope(payload.attachment_name, secretKey, password);
return normalizeAttachmentEntries(attachment.attachment, attachmentName.attachment_name);
}
function renderAttachmentPreview(entries) {
elements.attachmentPreview.innerHTML = '';
if (!entries.length) {
elements.attachmentPreview.classList.add('hidden');
return;
}
const previewable = entries.find((entry) => typeof entry.dataUrl === 'string' && entry.dataUrl.startsWith('data:'));
if (!previewable) {
elements.attachmentPreview.classList.add('hidden');
return;
}
const label = document.createElement('div');
label.className = 'attachment-preview-label';
label.textContent = previewable.name;
elements.attachmentPreview.append(label);
if (previewable.dataUrl.startsWith('data:image/')) {
const image = document.createElement('img');
image.src = previewable.dataUrl;
image.alt = previewable.name;
elements.attachmentPreview.append(image);
elements.attachmentPreview.classList.remove('hidden');
return;
}
if (previewable.dataUrl.startsWith('data:application/pdf')) {
const frame = document.createElement('iframe');
frame.className = 'pdfPreview';
frame.src = previewable.dataUrl;
frame.title = previewable.name;
elements.attachmentPreview.append(frame);
elements.attachmentPreview.classList.remove('hidden');
return;
}
elements.attachmentPreview.classList.add('hidden');
}
function renderAttachments(entries) {
elements.attachment.innerHTML = '';
state.currentAttachments = entries;
if (!entries.length) {
elements.attachment.classList.add('hidden');
renderAttachmentPreview([]);
return;
}
for (const entry of entries) {
const template = elements.attachmenttemplate.cloneNode(true);
template.removeAttribute('id');
template.classList.remove('hidden');
const link = template.querySelector('a');
link.href = entry.dataUrl;
link.download = entry.name;
const attachmentLabel = link.querySelector('span');
if (attachmentLabel) {
attachmentLabel.textContent = `: ${entry.name}`;
} else {
link.textContent = `Download attachment: ${entry.name}`;
}
elements.attachment.append(template);
}
elements.attachment.classList.remove('hidden');
renderAttachmentPreview(entries);
}
function renderRemainingTime(payload) {
hideRemaining();
if (payload.adata?.[3] === 1) {
showRemaining("FOR YOUR EYES ONLY. Don't close this window, this message can't be displayed again.", true);
return;
}
const ttl = Number(payload.meta?.time_to_live ?? 0);
if (ttl > 0) {
showRemaining(`This document will expire in ${humanizeDuration(ttl)}.`);
}
}
function getCurrentShareUrl() {
return state.shareResultUrl ?? window.location.href;
}
// Sharing helpers update the post-create success UI and optional delivery affordances.
function buildEmailBody(shareUrl, expirationDateText, isBurnAfterReading) {
const lines = [];
if (expirationDateText !== null || isBurnAfterReading) {
lines.push('Notice:');
if (expirationDateText !== null) {
lines.push('');
lines.push(` - This link will expire after ${expirationDateText}.`);
}
if (isBurnAfterReading) {
lines.push('');
lines.push(' - This link can only be accessed once, do not use back or refresh button in your browser.');
}
lines.push('');
lines.push('');
}
lines.push('Link:');
lines.push(shareUrl);
return lines.join('\n');
}
function triggerEmailSend(body) {
window.open(`mailto:?body=${encodeURIComponent(body)}`, '_self', 'noopener,noreferrer');
}
function copyIconSuccess() {
elements.copyIcon.style.display = 'none';
elements.copySuccessIcon.style.display = 'block';
window.setTimeout(() => {
elements.copyIcon.style.display = 'block';
elements.copySuccessIcon.style.display = 'none';
}, 1000);
}
async function copyText(value, successMessage, useDocumentIcon = false) {
if (!value) {
return;
}
await navigator.clipboard.writeText(value);
if (useDocumentIcon) {
copyIconSuccess();
}
showStatus(successMessage);
}
function renderPasteSuccess() {
if (!state.shareResultUrl || !state.shareDeleteUrl) {
elements.pastesuccess.classList.add('hidden');
elements.pastelink.innerHTML = '';
return;
}
const url = state.shareResultUrl;
elements.deletelink.href = state.shareDeleteUrl;
const deleteLabel = elements.deletelink.querySelector('span');
if (deleteLabel) {
deleteLabel.textContent = 'Delete data';
}
elements.pastelink.innerHTML = `Your document is ${escapeHtml(url)} (Hit Ctrl+c to copy)`;
elements.pastesuccess.classList.remove('hidden');
const pasteUrl = document.getElementById('pasteurl');
if (pasteUrl) {
selectText(pasteUrl);
}
}
// Discussion helpers rebuild the threaded comment tree on the client after decryption.
function createReplyForm(parentId) {
const form = elements.replytemplate.cloneNode(true);
form.removeAttribute('id');
form.classList.remove('hidden');
const nicknameInput = form.querySelector('#nickname');
const messageInput = form.querySelector('#replymessage');
const replyButton = form.querySelector('#replybutton');
const status = form.querySelector('#replystatus');
const statusText = form.querySelector('.replystatus-text');
replyButton.addEventListener('click', async () => {
const comment = messageInput.value.trim();
if (!comment) {
status.className = 'statusmessage alert alert-danger';
statusText.textContent = 'Comment content is required.';
status.classList.remove('hidden');
return;
}
try {
await postComment(parentId, nicknameInput.value.trim(), comment);
form.remove();
} catch (error) {
status.className = 'statusmessage alert alert-danger';
statusText.textContent = error instanceof Error ? error.message : 'Failed to post comment.';
status.classList.remove('hidden');
}
});
return form;
}
function buildCommentTree(comments) {
const childrenByParent = new Map();
for (const comment of comments) {
const parentKey = comment.parentId || state.pasteId;
if (!childrenByParent.has(parentKey)) {
childrenByParent.set(parentKey, []);
}
childrenByParent.get(parentKey).push(comment);
}
for (const childList of childrenByParent.values()) {
childList.sort((left, right) => Number(left.createdAt) - Number(right.createdAt));
}
return childrenByParent;
}
function renderCommentBranch(container, childrenByParent, parentId, depth = 0) {
const comments = childrenByParent.get(parentId) ?? [];
for (const comment of comments) {
const node = elements.commenttemplate.cloneNode(true);
node.removeAttribute('id');
node.dataset.commentId = comment.id;
node.classList.add(depth === 0 ? 'comment-root' : `comment-depth-${Math.min(depth, 3)}`);
const nickname = node.querySelector('.nickname');
if (comment.nickname) {
nickname.textContent = comment.nickname;
} else {
nickname.innerHTML = 'Anonymous';
}
const commentDate = node.querySelector('.commentdate');
commentDate.textContent = comment.createdAt ? `(${new Date(comment.createdAt).toLocaleString()})` : '';
node.querySelector('.commentdata').textContent = comment.comment || '';
node.querySelector('.comment-reply-button').addEventListener('click', () => {
container.querySelectorAll('.reply').forEach((reply) => {
if (!reply.id) {
reply.remove();
}
});
node.append(createReplyForm(comment.id));
});
container.append(node);
renderCommentBranch(container, childrenByParent, comment.id, depth + 1);
}
}
function renderComments(comments) {
elements.commentcontainer.innerHTML = '';
const tree = buildCommentTree(comments);
renderCommentBranch(elements.commentcontainer, tree, state.pasteId, 0);
const tail = elements.commenttailtemplate.cloneNode(true);
tail.removeAttribute('id');
tail.querySelector('button').addEventListener('click', () => {
elements.commentcontainer.append(createReplyForm(state.pasteId));
});
elements.commentcontainer.append(tail);
}
// Paste lifecycle actions load, consume, clone, create, and delete encrypted documents.
async function postComment(parentId, nickname, comment) {
hideError();
const envelope = await encryptCommentEnvelope({ comment, nickname }, state.pasteKey, state.viewPassword);
const response = await fetch(`/api/v1/pastes/${state.pasteId}/comments`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({
...envelope,
pasteid: state.pasteId,
parentid: parentId,
}),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error ?? 'Failed to post comment.');
}
await loadPaste({ preserveStatus: true });
showStatus('Comment posted.');
}
async function consumePaste(silent = false) {
if (!state.pasteId || !state.claimToken) {
return;
}
const response = await fetch(`/api/v1/pastes/${state.pasteId}/consume`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ claimToken: state.claimToken }),
});
const payload = await response.json();
if (!response.ok) {
if (!silent) {
showError(payload.error ?? 'Failed to burn paste.');
}
return;
}
state.claimToken = null;
if (!silent) {
showStatus('Paste consumed.');
}
}
async function loadPaste({ preserveStatus = false } = {}) {
showLoading('Loading…');
if (!preserveStatus) {
hideError();
hideStatus();
}
hideRemaining();
renderAttachments([]);
clearRenderedMessage();
elements.discussion.classList.add('hidden');
if (!state.pasteId || !state.pasteKey) {
hideLoading();
showError('Paste key is missing from the URL fragment.');
return;
}
try {
const response = await fetch(`/api/v1/pastes/${state.pasteId}`);
const payload = await response.json();
if (!response.ok) {
setRetryAction(() => void loadPaste());
throw new Error(payload.error ?? 'Failed to load paste.');
}
let message;
try {
message = await decryptEnvelope(payload, state.pasteKey, state.viewPassword);
} catch (error) {
if (!state.viewPassword) {
hideLoading();
bootstrapApi?.Modal.getOrCreateInstance(elements.passwordmodal)?.show();
setRetryAction(() => void loadPaste());
return;
}
throw error;
}
state.claimToken = payload.claimToken ?? null;
state.pasteData = message;
state.currentFormatter = payload.adata?.[1] ?? 'plaintext';
state.shareResultUrl = null;
state.shareDeleteUrl = null;
renderMessageContent(message.paste ?? '', state.currentFormatter);
renderRemainingTime(payload);
let attachments = normalizeAttachmentEntries(message.attachment, message.attachment_name);
if (!attachments.length) {
attachments = await loadLegacyNestedAttachments(payload, state.pasteKey, state.viewPassword);
}
renderAttachments(attachments);
if (payload.adata?.[2] === 1) {
const comments = [];
for (const comment of payload.comments ?? []) {
const data = await decryptEnvelope(comment, state.pasteKey, state.viewPassword);
comments.push({
...data,
id: comment.id,
parentId: comment.parentId,
createdAt: comment.createdAt,
});
}
renderComments(comments);
elements.discussion.classList.remove('hidden');
}
state.page = 'view';
updateTopNav();
setCopyShortcutHint(
'To copy document press on the copy button or use the clipboard shortcut Ctrl+c/Cmd+c',
);
setRetryAction(null);
hideLoading();
elements.loadingnotice.classList.add('hidden');
if (payload.adata?.[3] === 1 && state.claimToken) {
void consumePaste(true);
}
} catch (error) {
hideLoading();
console.error(error);
showError(error instanceof Error ? error.message : 'Unable to decrypt the paste with the current key or password.');
}
}
function startNewPaste(pushHistory = true) {
state.page = 'compose';
state.pasteId = null;
state.pasteKey = null;
state.claimToken = null;
state.loadConfirmRequired = false;
state.viewPassword = '';
state.pasteData = null;
state.currentFormatter = 'plaintext';
state.currentAttachments = [];
state.shareResultUrl = null;
state.shareDeleteUrl = null;
clearDraftAttachments();
hideStatus();
hideError();
hideRemaining();
renderAttachments([]);
elements.discussion.classList.add('hidden');
elements.commentcontainer.innerHTML = '';
elements.message.value = '';
elements.passwordinput.value = '';
elements.passworddecrypt.value = '';
elements.burnafterreading.checked = false;
elements.opendiscussion.checked = false;
elements.pasteFormatter.value = 'plaintext';
elements.pasteExpiration.value = state.config?.defaultExpiration ?? elements.pasteExpiration.value;
elements.pastesuccess.classList.add('hidden');
setCopyShortcutHint();
setComposerTab('editor');
updateTopNav();
setRetryAction(null);
elements.loadingnotice.classList.add('hidden');
if (pushHistory) {
const homeUrl = buildHomeUrl();
history.pushState({ type: 'create' }, document.title, `${homeUrl.pathname}${homeUrl.search}`);
}
elements.message.classList.remove('hidden');
elements.message.focus();
}
function togglePasswordField(button) {
const input = button.closest('.input-group')?.querySelector('.input-password');
if (!input) {
return;
}
const isHidden = input.getAttribute('type') === 'password';
input.setAttribute('type', isHidden ? 'text' : 'password');
const use = button.querySelector('use');
if (use) {
use.setAttribute('href', isHidden ? '/vendor/bootstrap-icons.svg#eye-slash' : '/vendor/bootstrap-icons.svg#eye');
}
button.setAttribute('title', isHidden ? 'Hide password' : 'Show password');
button.setAttribute('aria-label', isHidden ? 'Hide password' : 'Show password');
}
function handleTabKey(event) {
if ((event.ctrlKey || event.metaKey) && event.key.toLowerCase() === 'm') {
event.preventDefault();
elements.messagetab.checked = !elements.messagetab.checked;
return;
}
if (event.key === 'Escape') {
event.preventDefault();
elements.messagetab.checked = !elements.messagetab.checked;
return;
}
if (event.key === 'Tab' && elements.messagetab.checked) {
event.preventDefault();
const start = elements.message.selectionStart;
const end = elements.message.selectionEnd;
elements.message.setRangeText('\t', start, end, 'end');
}
}
async function createPaste() {
showLoading('Creating paste…');
hideError();
hideStatus();
try {
const content = elements.message.value;
if (!content.trim()) {
throw new Error('Paste content is required.');
}
const secretKey = randomKey();
const formatter = elements.pasteFormatter.value;
const burnAfterReading = elements.burnafterreading.checked;
const discussionOpen = burnAfterReading ? false : elements.opendiscussion.checked;
const attachmentEntries = await collectAttachmentEntries();
const payload = { paste: content };
if (attachmentEntries.length > 0) {
payload.attachment = attachmentEntries.map((entry) => entry.dataUrl);
payload.attachment_name = attachmentEntries.map((entry) => entry.name);
}
const envelope = await encryptPasteEnvelope(
payload,
secretKey,
elements.passwordinput.value,
formatter,
discussionOpen,
burnAfterReading,
);
envelope.meta = { expire: elements.pasteExpiration.value };
const response = await fetch('/api/v1/pastes', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify(envelope),
});
const result = await response.json();
if (!response.ok) {
throw new Error(result.error ?? 'Failed to create paste.');
}
state.pasteId = result.id;
state.pasteKey = secretKey;
state.shareResultUrl = buildShareUrl(result.shareUrl, secretKey, burnAfterReading);
state.shareDeleteUrl = buildDeleteUrl(result.id, result.deleteToken);
renderPasteSuccess();
showStatus('Paste created. Share the link and store the delete token safely.');
history.pushState({ type: 'newpaste' }, document.title, state.shareResultUrl);
setRetryAction(null);
hideLoading();
elements.loadingnotice.classList.add('hidden');
} catch (error) {
hideLoading();
console.error(error);
showError(error instanceof Error ? error.message : 'Failed to create paste.');
setRetryAction(() => void createPaste());
}
}
async function deletePasteFromUrl(deleteToken) {
if (!state.pasteId || !deleteToken) {
return false;
}
showLoading('Deleting paste…');
hideError();
hideStatus();
try {
const response = await fetch(`/api/v1/pastes/${state.pasteId}`, {
method: 'DELETE',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ deleteToken }),
});
const payload = await response.json();
if (!response.ok) {
throw new Error(payload.error ?? 'Failed to delete paste.');
}
const homeUrl = buildHomeUrl();
history.replaceState({ type: 'create' }, document.title, `${homeUrl.pathname}${homeUrl.search}`);
startNewPaste(false);
showStatus('Paste deleted.', true);
hideLoading();
elements.loadingnotice.classList.add('hidden');
return true;
} catch (error) {
hideLoading();
console.error(error);
showError(error instanceof Error ? error.message : 'Failed to delete paste.');
return true;
}
}
function cloneCurrentPaste() {
if (!state.pasteData) {
return;
}
const attachments = state.currentAttachments.map((entry) => ({ ...entry }));
startNewPaste(false);
state.pendingAttachmentEntries = attachments;
updateAttachmentComposerSummary();
elements.message.value = state.pasteData.paste ?? '';
elements.pasteFormatter.value = state.currentFormatter;
state.page = 'compose';
updateTopNav();
clearRenderedMessage();
elements.message.classList.remove('hidden');
showStatus(
attachments.length
? `The cloned file '${attachments.map((entry) => entry.name).join(', ')}' was attached to this document.`
: 'Cloned current document.',
);
history.pushState({ type: 'clone' }, document.title, buildHomeUrl().toString());
}
function openRawText() {
if (!state.pasteData?.paste) {
return;
}
const popup = window.open('', '_blank', 'noopener,noreferrer');
if (!popup) {
showError('Unable to open a new window for raw text.');
return;
}
popup.document.write(
`
${escapeHtml(state.pasteData.paste)}`,
);
popup.document.close();
}
function downloadCurrentPaste() {
if (!state.pasteData?.paste) {
return;
}
const blob = new Blob([state.pasteData.paste], { type: 'text/plain;charset=utf-8' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${state.pasteId ?? 'paste'}.txt`;
link.click();
URL.revokeObjectURL(url);
}
function showQrCode() {
const shareUrl = getCurrentShareUrl();
if (!shareUrl) {
return;
}
elements.qrcodeDisplay.innerHTML = '';
if (typeof window.kjua === 'function') {
elements.qrcodeDisplay.append(
window.kjua({
render: 'canvas',
text: shareUrl,
size: 200,
fill: document.documentElement.getAttribute('data-bs-theme') === 'dark' ? '#f8f9fa' : '#212529',
back: document.documentElement.getAttribute('data-bs-theme') === 'dark' ? '#212529' : '#ffffff',
quiet: 2,
}),
);
} else {
elements.qrcodeDisplay.textContent = shareUrl;
}
bootstrapApi?.Modal.getOrCreateInstance(elements.qrcodemodal)?.show();
}
function sendEmailLink() {
const shareUrl = getCurrentShareUrl();
if (!shareUrl) {
return;
}
const ttl = state.pasteData?.meta?.time_to_live ?? 0;
const isBurnAfterReading = state.pasteData?.adata?.[3] === 1;
if (ttl > 0 && elements.emailconfirmmodal) {
const expirationDate = new Date(Date.now() + ttl * 1000);
expirationDate.setUTCSeconds(expirationDate.getUTCSeconds() - 30);
expirationDate.setUTCSeconds(0);
const modal = bootstrapApi?.Modal.getOrCreateInstance(elements.emailconfirmmodal);
const sendWithLocale = (timeZone) => {
const localeConfiguration = { dateStyle: 'long', timeStyle: 'long' };
if (timeZone) {
localeConfiguration.timeZone = timeZone;
}
modal?.hide();
triggerEmailSend(buildEmailBody(shareUrl, expirationDate.toLocaleString([], localeConfiguration), isBurnAfterReading));
};
elements.emailconfirmTimezoneCurrent.onclick = () => sendWithLocale(undefined);
elements.emailconfirmTimezoneUtc.onclick = () => sendWithLocale('UTC');
modal?.show();
return;
}
triggerEmailSend(buildEmailBody(shareUrl, null, isBurnAfterReading));
}
// Bootstrapping happens after config loads so event handlers can reflect server-provided options.
function initializeAttachmentDnD() {
let dragDepth = 0;
function assignFiles(files) {
state.pendingAttachmentEntries = [];
state.draftFiles = [...files];
updateAttachmentComposerSummary();
}
document.addEventListener('dragenter', (event) => {
if (!event.dataTransfer?.types?.includes('Files')) {
return;
}
dragDepth += 1;
elements.dropzone.classList.remove('hidden');
});
document.addEventListener('dragover', (event) => {
if (!event.dataTransfer?.types?.includes('Files')) {
return;
}
event.preventDefault();
});
document.addEventListener('dragleave', (event) => {
if (!event.dataTransfer?.types?.includes('Files')) {
return;
}
dragDepth = Math.max(0, dragDepth - 1);
if (dragDepth === 0) {
elements.dropzone.classList.add('hidden');
}
});
document.addEventListener('drop', (event) => {
if (!event.dataTransfer?.files?.length) {
return;
}
event.preventDefault();
dragDepth = 0;
elements.dropzone.classList.add('hidden');
assignFiles(event.dataTransfer.files);
});
}
async function boot() {
initializeTheme();
initializeAttachmentDnD();
window.addEventListener('popstate', () => {
window.location.reload();
});
const configResponse = await fetch('/api/v1/config');
state.config = await configResponse.json();
syncUiChrome(state.config);
renderExpirationOptions(state.config);
elements.brandLink.href = buildHomeUrl().toString();
const httpsUrl = new URL(window.location.href);
httpsUrl.protocol = 'https:';
const httpsLink = document.getElementById('httpslink');
if (httpsLink) {
httpsLink.setAttribute('href', httpsUrl.toString());
}
for (const toggle of elements.passwordToggles) {
toggle.addEventListener('click', () => togglePasswordField(toggle));
}
elements.themeToggle.addEventListener('change', () => {
const theme = elements.themeToggle.checked ? 'dark' : 'light';
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
setTheme(theme);
});
elements.messageedit.addEventListener('click', (event) => {
event.preventDefault();
setComposerTab('editor');
});
elements.messagepreview.addEventListener('click', (event) => {
event.preventDefault();
setComposerTab('preview');
});
elements.newbutton.addEventListener('click', () => startNewPaste());
elements.newFromAlert.addEventListener('click', () => startNewPaste());
elements.retrybutton.addEventListener('click', () => {
if (state.retryAction) {
state.retryAction();
}
});
elements.sendbutton.addEventListener('click', () => void createPaste());
elements.copyLink.addEventListener('click', () => void copyText(state.shareResultUrl, 'Link copied to clipboard.'));
elements.prettyMessageCopyBtn.addEventListener(
'click',
() => void copyText(state.pasteData?.paste ?? elements.message.value, 'Document copied to clipboard.', true),
);
elements.clonebutton.addEventListener('click', cloneCurrentPaste);
elements.rawtextbutton.addEventListener('click', openRawText);
elements.downloadtextbutton.addEventListener('click', downloadCurrentPaste);
elements.qrcodelink.addEventListener('click', showQrCode);
elements.emaillink.addEventListener('click', sendEmailLink);
elements.message.addEventListener('input', () => {
if (state.composerTab === 'preview') {
renderPreview();
}
});
elements.message.addEventListener('keydown', handleTabKey);
elements.pasteFormatter.addEventListener('change', () => {
if (state.composerTab === 'preview') {
renderPreview();
}
});
elements.burnafterreading.addEventListener('change', () => {
if (elements.burnafterreading.checked) {
elements.opendiscussion.checked = false;
}
});
elements.opendiscussion.addEventListener('change', () => {
if (elements.opendiscussion.checked) {
elements.burnafterreading.checked = false;
}
});
elements.file.addEventListener('change', () => {
state.pendingAttachmentEntries = [];
state.draftFiles = [...elements.file.files];
updateAttachmentComposerSummary();
});
elements.fileremovebutton.addEventListener('click', (event) => {
event.preventDefault();
clearDraftAttachments();
});
elements.passwordform.addEventListener('submit', (event) => {
event.preventDefault();
state.viewPassword = elements.passworddecrypt.value;
bootstrapApi?.Modal.getOrCreateInstance(elements.passwordmodal)?.hide();
void loadPaste();
});
elements.loadconfirmOpenNow.addEventListener('click', () => {
void loadPaste();
});
document.addEventListener('copy', (event) => {
if (!state.pasteData?.paste) {
return;
}
const selectedText = window.getSelection()?.toString() ?? '';
if (selectedText.length > 0) {
return;
}
event.clipboardData?.setData('text/plain', state.pasteData.paste);
event.preventDefault();
showStatus('Document copied to clipboard.');
});
const location = getPasteLocation();
state.pasteId = location.pasteId;
state.pasteKey = location.pasteKey;
state.loadConfirmRequired = location.loadConfirmRequired;
updateTopNav();
if (location.deleteToken && state.pasteId) {
await deletePasteFromUrl(location.deleteToken);
return;
}
if (state.pasteId) {
state.page = 'view';
updateTopNav();
if (state.loadConfirmRequired) {
hideLoading();
elements.loadingnotice.classList.add('hidden');
bootstrapApi?.Modal.getOrCreateInstance(elements.loadconfirmmodal)?.show();
return;
}
await loadPaste();
return;
}
startNewPaste(false);
}
void boot();