import gleam/option.{None, Some} import gleam/string import lustre/attribute import lustre/element.{text} import lustre/element/html import layout pub fn index(csrf_token: String) -> String { layout.page("spasteg — burn after reading", [ layout.header(Some("burn after reading")), layout.card([ html.form([attribute.id("paste-form")], [ html.input([ attribute.type_("hidden"), attribute.id("csrf-token"), attribute.name("csrf_token"), attribute.value(csrf_token), ]), html.textarea( [ attribute.id("content"), attribute.name("content"), attribute.placeholder("Enter your paste here..."), attribute.required(True), ], "", ), html.input([ attribute.type_("hidden"), attribute.id("encrypted-content"), attribute.name("encrypted_content"), ]), html.div([attribute.class("actions")], [ html.button([attribute.type_("submit")], [text("Create Paste")]), ]), ]), ]), layout.footer(), html.script([], crypto_js()), ]) } pub fn created(key: String) -> String { layout.page("spasteg — paste created", [ layout.header(None), layout.card([ html.div([attribute.class("notice warning")], [ html.strong([], [text("Important: ")]), text("The decryption key is in the URL after the # symbol. "), text( "Share the complete URL including the part after # to allow decryption. ", ), text("The server never stores or sees the decryption key."), ]), html.div([attribute.class("notice")], [ html.strong([], [text("Burn after reading")]), text(" — This paste will be deleted after the first view"), ]), html.div( [attribute.attribute("data-paste-id", key), attribute.class("hidden")], [], ), html.div([attribute.class("actions center")], [ html.a([attribute.href("/"), attribute.class("btn-primary")], [ text("Create New Paste"), ]), ]), ]), layout.footer(), ]) } pub fn paste(encrypted_content: String) -> String { layout.page("spasteg — burn after reading", [ layout.header(None), layout.card([ html.div([attribute.class("paste-content")], [ html.div([attribute.id("loading")], [text("Loading...")]), html.div([attribute.id("error"), attribute.class("error")], [ text( "Unable to decrypt. Make sure you have the complete URL including the key after the # symbol.", ), ]), html.pre( [attribute.id("content-display"), attribute.class("hidden")], [], ), ]), ]), html.script([], decrypt_js(encrypted_content)), ]) } pub fn not_found() -> String { layout.page("spasteg — not found", [ html.div([attribute.class("centered")], [ layout.logo(), layout.card([ html.h2([], [text("Paste not found")]), html.p([], [ text("This paste may have already been viewed and deleted."), ]), html.a([attribute.href("/"), attribute.class("btn-primary")], [ text("Create New Paste"), ]), ]), ]), ]) } fn decrypt_js(encrypted_content: String) -> String { " (async () => { const encryptedContent = '" <> escape_js_string(encrypted_content) <> "'; const hash = location.hash.slice(1); const els = { loading: document.getElementById('loading'), error: document.getElementById('error'), content: document.getElementById('content-display') }; if (!hash) { els.loading.classList.add('hidden'); els.error.classList.remove('hidden'); return; } try { const keyBase64 = hash.replace(/-/g, '+').replace(/_/g, '/'); const encryptedBytes = Uint8Array.from(atob(encryptedContent), c => c.charCodeAt(0)); const keyBytes = Uint8Array.from(atob(keyBase64), c => c.charCodeAt(0)); const iv = encryptedBytes.slice(0, 12); const key = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt']); const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encryptedBytes.slice(12)); els.content.textContent = new TextDecoder().decode(decrypted); els.loading.classList.add('hidden'); els.error.classList.add('hidden'); els.content.classList.remove('hidden'); } catch (e) { console.error('Decryption failed:', e); els.loading.classList.add('hidden'); els.error.classList.remove('hidden'); } })(); " } fn escape_js_string(s: String) -> String { s |> string.replace("\\", "\\\\") |> string.replace("'", "\\'") |> string.replace("\"", "\\\"") |> string.replace("\n", "\\n") |> string.replace("\r", "\\r") } fn crypto_js() -> String { " async function encryptContent(content) { const encoder = new TextEncoder(); const data = encoder.encode(content); const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); const iv = crypto.getRandomValues(new Uint8Array(12)); const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data); const keyData = await crypto.subtle.exportKey('raw', key); return { encrypted: btoa(String.fromCharCode(...new Uint8Array([...iv, ...new Uint8Array(encrypted)]))), key: btoa(String.fromCharCode(...new Uint8Array(keyData))) }; } function base64ToUrlSafe(base64) { return base64.replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, ''); } function showShareUrl(url) { const card = document.querySelector('.card'); const div = document.createElement('div'); div.className = 'share-url'; div.innerHTML = ''; const input = document.createElement('input'); input.type = 'text'; input.readOnly = true; input.value = url; input.onclick = () => input.select(); div.appendChild(input); card.insertBefore(div, card.firstChild); input.select(); } document.getElementById('paste-form').addEventListener('submit', async (e) => { e.preventDefault(); const content = document.getElementById('content').value; const { encrypted, key } = await encryptContent(content); document.getElementById('encrypted-content').value = encrypted; const csrfToken = document.getElementById('csrf-token').value; const res = await fetch('/', { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: `encrypted_content=${encodeURIComponent(encrypted)}&csrf_token=${encodeURIComponent(csrfToken)}` }); const html = await res.text(); const doc = new DOMParser().parseFromString(html, 'text/html'); const pasteId = doc.querySelector('[data-paste-id]')?.getAttribute('data-paste-id'); if (!pasteId) return; const pasteUrl = `${location.origin}/paste/${pasteId}#${base64ToUrlSafe(key)}`; document.body.replaceChildren(...doc.body.childNodes); showShareUrl(pasteUrl); history.replaceState({}, '', pasteUrl); }); " }