/** * 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();