From 9e21f6138d3dd6550c91719014eb392304165869 Mon Sep 17 00:00:00 2001 From: Kharec Date: Sun, 1 Mar 2026 08:50:06 +0100 Subject: [PATCH] first sources: batman --- src/handlers.gleam | 134 ++++++++++++++++++++++++ src/html.gleam | 244 +++++++++++++++++++++++++++++++++++++++++++ src/key.gleam | 9 ++ src/layout.gleam | 73 +++++++++++++ src/spasteg.gleam | 36 +++++++ src/storage.gleam | 69 +++++++++++++ src/styles.gleam | 253 +++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 818 insertions(+) create mode 100644 src/handlers.gleam create mode 100644 src/html.gleam create mode 100644 src/key.gleam create mode 100644 src/layout.gleam create mode 100644 src/spasteg.gleam create mode 100644 src/storage.gleam create mode 100644 src/styles.gleam diff --git a/src/handlers.gleam b/src/handlers.gleam new file mode 100644 index 0000000..e2a0ff5 --- /dev/null +++ b/src/handlers.gleam @@ -0,0 +1,134 @@ +import gleam/erlang/process +import gleam/http +import gleam/list +import gleam/option.{Some} +import gleam/result +import gleam/string +import html +import key +import storage +import wisp + +fn get_client_ip(request: wisp.Request) -> String { + case list.key_find(request.headers, "x-forwarded-for") { + Ok(ip) -> ip + Error(_) -> + case list.key_find(request.headers, "x-real-ip") { + Ok(ip) -> ip + Error(_) -> "unknown" + } + } +} + +pub fn handle( + request: wisp.Request, + storage: process.Subject(storage.StorageMsg), + _secret_key_base: String, +) -> wisp.Response { + let response = case wisp.path_segments(request) { + [] -> handle_home(request, storage) + ["paste", key_param] -> handle_paste(request, storage, key_param) + _ -> wisp.not_found() + } + add_security_headers(response) +} + +fn add_security_headers(response: wisp.Response) -> wisp.Response { + response + |> wisp.set_header("X-Frame-Options", "DENY") + |> wisp.set_header("X-Content-Type-Options", "nosniff") + |> wisp.set_header( + "Content-Security-Policy", + "default-src 'self'; script-src 'self' 'unsafe-inline' https://fonts.googleapis.com; style-src 'self' 'unsafe-inline' https://fonts.googleapis.com; font-src https://fonts.gstatic.com;", + ) + |> wisp.set_header("Referrer-Policy", "strict-origin-when-cross-origin") +} + +fn handle_home( + request: wisp.Request, + storage: process.Subject(storage.StorageMsg), +) -> wisp.Response { + case request.method { + http.Get -> { + let csrf_token = key.generate() + wisp.ok() + |> wisp.set_cookie(request, "csrf_token", csrf_token, wisp.Signed, 3600) + |> wisp.html_body(html.index(csrf_token)) + } + http.Post -> { + use form <- wisp.require_form(request) + let csrf_cookie = wisp.get_cookie(request, "csrf_token", wisp.Signed) + let csrf_form = list.key_find(form.values, "csrf_token") + case csrf_cookie, csrf_form { + Ok(cookie_token), Ok(form_token) if cookie_token == form_token -> { + let ip = get_client_ip(request) + let rate_reply = process.new_subject() + process.send(storage, storage.CheckRateLimit(ip, rate_reply)) + case process.receive(rate_reply, 1000) { + Ok(True) -> { + let encrypted_content = + list.key_find(form.values, "encrypted_content") + |> result.unwrap("") + case string.length(encrypted_content) { + 0 -> wisp.bad_request("Missing content") + n if n > 10_000_000 -> wisp.bad_request("Content too large") + _ -> { + let new_key = key.generate() + let paste_reply = process.new_subject() + process.send( + storage, + storage.CreatePaste(new_key, encrypted_content, paste_reply), + ) + case process.receive(paste_reply, 1000) { + Ok(True) -> { + wisp.ok() + |> wisp.html_body(html.created(new_key)) + } + _ -> { + wisp.internal_server_error() + |> wisp.html_body("Failed to create paste") + } + } + } + } + } + _ -> { + wisp.response(429) + |> wisp.html_body("Rate limit exceeded") + } + } + } + _, _ -> wisp.bad_request("Invalid CSRF token") + } + } + _ -> wisp.method_not_allowed([http.Get, http.Post]) + } +} + +fn handle_paste( + request: wisp.Request, + storage: process.Subject(storage.StorageMsg), + paste_key: String, +) -> wisp.Response { + case request.method { + http.Get -> { + let reply = process.new_subject() + process.send(storage, storage.GetPaste(paste_key, reply)) + let content = case process.receive(reply, 1000) { + Ok(Some(value)) -> value + _ -> "not_found" + } + case content { + "not_found" -> { + wisp.ok() + |> wisp.html_body(html.not_found()) + } + _ -> { + wisp.ok() + |> wisp.html_body(html.paste(content)) + } + } + } + _ -> wisp.method_not_allowed([http.Get]) + } +} diff --git a/src/html.gleam b/src/html.gleam new file mode 100644 index 0000000..44460c6 --- /dev/null +++ b/src/html.gleam @@ -0,0 +1,244 @@ +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 function() { + const encryptedContent = '" <> escape_js_string(encrypted_content) <> "'; + const hash = window.location.hash.slice(1); + const loadingEl = document.getElementById('loading'); + const errorEl = document.getElementById('error'); + const contentEl = document.getElementById('content-display'); + + if (!hash) { + loadingEl.classList.add('hidden'); + errorEl.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 ciphertext = encryptedBytes.slice(12); + const key = await crypto.subtle.importKey( + 'raw', + keyBytes, + { name: 'AES-GCM' }, + false, + ['decrypt'] + ); + const decrypted = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: iv }, + key, + ciphertext + ); + const decoder = new TextDecoder(); + const plaintext = decoder.decode(decrypted); + contentEl.textContent = plaintext; + loadingEl.classList.add('hidden'); + errorEl.classList.add('hidden'); + contentEl.classList.remove('hidden'); + } catch (e) { + console.error('Decryption failed:', e); + loadingEl.classList.add('hidden'); + errorEl.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: iv }, + key, + data + ); + const keyData = await crypto.subtle.exportKey('raw', key); + const keyBytes = new Uint8Array(keyData); + const combined = new Uint8Array(iv.length + encrypted.byteLength); + combined.set(iv); + combined.set(new Uint8Array(encrypted), iv.length); + const encryptedBase64 = btoa(String.fromCharCode(...combined)); + const keyBase64 = btoa(String.fromCharCode(...keyBytes)); + return { encrypted: encryptedBase64, key: keyBase64 }; +} + +async function base64ToUrlSafeBase64(base64) { + let result = base64.split('+').join('-'); + result = result.split('/').join('_'); + while (result.endsWith('=')) { + result = result.slice(0, -1); + } + return result; +} + +document.getElementById('paste-form').addEventListener('submit', async function(e) { + e.preventDefault(); + const content = document.getElementById('content').value; + const result = await encryptContent(content); + document.getElementById('encrypted-content').value = result.encrypted; + const response = await fetch('/', { + method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, + body: 'encrypted_content=' + encodeURIComponent(result.encrypted) + '&csrf_token=' + encodeURIComponent(document.getElementById('csrf-token').value) + }); + const html = await response.text(); + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const keyUrlSafe = await base64ToUrlSafeBase64(result.key); + const url = new URL(window.location.href); + const pasteId = doc.querySelector('[data-paste-id]').getAttribute('data-paste-id'); + const pasteUrl = url.origin + '/paste/' + pasteId + '#' + keyUrlSafe; + + document.body.replaceChildren(...doc.body.childNodes); + + const card = document.querySelector('.card'); + const urlDiv = document.createElement('div'); + urlDiv.className = 'share-url'; + const lbl = document.createElement('label'); + lbl.textContent = 'Share this URL'; + urlDiv.appendChild(lbl); + const inp = document.createElement('input'); + inp.type = 'text'; + inp.readOnly = true; + inp.value = pasteUrl; + inp.onclick = function() { this.select(); }; + urlDiv.appendChild(inp); + card.insertBefore(urlDiv, card.firstChild); + + window.history.replaceState({}, '', pasteUrl); + + inp.select(); +}); +" +} diff --git a/src/key.gleam b/src/key.gleam new file mode 100644 index 0000000..b9d01e4 --- /dev/null +++ b/src/key.gleam @@ -0,0 +1,9 @@ +import gleam/bit_array +import gleam/crypto + +const key_length_bytes = 12 + +pub fn generate() -> String { + crypto.strong_random_bytes(key_length_bytes) + |> bit_array.base64_url_encode(False) +} diff --git a/src/layout.gleam b/src/layout.gleam new file mode 100644 index 0000000..fe63c30 --- /dev/null +++ b/src/layout.gleam @@ -0,0 +1,73 @@ +import gleam/option.{type Option, None, Some} + +import lustre/attribute +import lustre/element.{type Element, none, text} +import lustre/element/html + +import styles + +pub fn page(title: String, children: List(Element(a))) -> String { + html.html([attribute.lang("en")], [ + html.head([], [ + html.meta([attribute.charset("UTF-8")]), + html.meta([ + attribute.name("viewport"), + attribute.content("width=device-width, initial-scale=1.0"), + ]), + html.title([], title), + html.link([ + attribute.rel("preconnect"), + attribute.href("https://fonts.googleapis.com"), + ]), + html.link([ + attribute.rel("preconnect"), + attribute.href("https://fonts.gstatic.com"), + attribute.attribute("crossorigin", ""), + ]), + html.link([ + attribute.rel("stylesheet"), + attribute.href( + "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap", + ), + ]), + html.style([], styles.shared_css), + ]), + html.body([], [ + html.div([attribute.class("container")], children), + ]), + ]) + |> element.to_document_string +} + +pub fn logo() -> Element(a) { + html.h1([attribute.class("logo")], [text("spasteg")]) +} + +pub fn card(children: List(Element(a))) -> Element(a) { + html.div([attribute.class("card")], children) +} + +pub fn header(subtitle: Option(String)) -> Element(a) { + html.header([], [ + logo(), + case subtitle { + Some(text_content) -> + html.p([attribute.class("tagline")], [text(text_content)]) + None -> none() + }, + ]) +} + +pub fn footer() -> Element(a) { + let lucy_svg = + "" + html.footer([], [ + text("Proudly made with Gleam "), + element.unsafe_raw_html( + "http://www.w3.org/1999/xhtml", + "span", + [attribute.class("mascot")], + lucy_svg, + ), + ]) +} diff --git a/src/spasteg.gleam b/src/spasteg.gleam new file mode 100644 index 0000000..8ea80f8 --- /dev/null +++ b/src/spasteg.gleam @@ -0,0 +1,36 @@ +import envoy +import gleam/erlang/process +import handlers +import key +import logging +import mist +import storage +import wisp +import wisp/wisp_mist + +pub fn main() { + wisp.configure_logger() + let assert Ok(storage_actor) = storage.start() + let storage = storage_actor.data + let secret_key_base = case envoy.get("SECRET_KEY_BASE") { + Ok(secret) if secret != "" -> secret + _ -> { + let generated = key.generate() + logging.log( + logging.Warning, + "Warning: SECRET_KEY_BASE not set. Using temporary key.", + ) + generated + } + } + let assert Ok(_) = + wisp_mist.handler( + fn(req) { handlers.handle(req, storage, secret_key_base) }, + secret_key_base, + ) + |> mist.new + |> mist.port(3000) + |> mist.start + + process.sleep_forever() +} diff --git a/src/storage.gleam b/src/storage.gleam new file mode 100644 index 0000000..813eee4 --- /dev/null +++ b/src/storage.gleam @@ -0,0 +1,69 @@ +import gleam/dict.{type Dict} +import gleam/erlang/process +import gleam/option.{type Option, None, Some} +import gleam/otp/actor + +const max_requests_per_minute = 10 + +pub type StorageState { + StorageState(pastes: Dict(String, String), rate_limits: Dict(String, Int)) +} + +pub type StorageMsg { + CreatePaste(key: String, content: String, reply: process.Subject(Bool)) + GetPaste(key: String, reply: process.Subject(Option(String))) + PeekPaste(key: String, reply: process.Subject(Option(String))) + CheckRateLimit(ip: String, reply: process.Subject(Bool)) + ResetRateLimits +} + +pub fn handle_message(state: StorageState, msg: StorageMsg) { + case msg { + CreatePaste(key, content, reply) -> { + let new_state = + StorageState(dict.insert(state.pastes, key, content), state.rate_limits) + process.send(reply, True) + actor.continue(new_state) + } + PeekPaste(key, reply) -> { + let content = case dict.get(state.pastes, key) { + Ok(value) -> Some(value) + Error(_) -> None + } + process.send(reply, content) + actor.continue(state) + } + GetPaste(key, reply) -> { + let content = case dict.get(state.pastes, key) { + Ok(value) -> Some(value) + Error(_) -> None + } + let new_pastes = dict.delete(state.pastes, key) + let new_state = StorageState(new_pastes, state.rate_limits) + process.send(reply, content) + actor.continue(new_state) + } + CheckRateLimit(ip, reply) -> { + let current_count = + dict.get(state.rate_limits, ip) + |> option.from_result + |> option.unwrap(0) + let allowed = current_count < max_requests_per_minute + let new_limits = case allowed { + True -> dict.insert(state.rate_limits, ip, current_count + 1) + False -> state.rate_limits + } + process.send(reply, allowed) + actor.continue(StorageState(state.pastes, new_limits)) + } + ResetRateLimits -> { + actor.continue(StorageState(state.pastes, dict.new())) + } + } +} + +pub fn start() { + actor.new(StorageState(dict.new(), dict.new())) + |> actor.on_message(handle_message) + |> actor.start +} diff --git a/src/styles.gleam b/src/styles.gleam new file mode 100644 index 0000000..4ed74b8 --- /dev/null +++ b/src/styles.gleam @@ -0,0 +1,253 @@ +pub const shared_css = " +* { margin: 0; padding: 0; box-sizing: border-box; } +:root { + --bg-primary: #0d1117; + --bg-secondary: #161b22; + --bg-tertiary: #21262d; + --text-primary: #e6edf3; + --text-secondary: #8b949e; + --accent: #58a6ff; + --accent-hover: #79c0ff; + --success: #238636; + --success-hover: #2ea043; + --warning: #d29922; + --border: #30363d; + --radius: 12px; + --radius-sm: 6px; +} +body { + font-family: 'Inter', system-ui, -apple-system, sans-serif; + background: var(--bg-primary); + color: var(--text-primary); + min-height: 100vh; + line-height: 1.6; +} +.container { + max-width: 900px; + margin: 0 auto; + padding: 40px 20px; +} +header { text-align: center; margin-bottom: 40px; } +.tagline { + color: var(--text-secondary); + font-size: 1rem; + margin-top: 8px; + font-weight: 500; +} +.logo { + font-size: 2.5rem; + font-weight: 700; + letter-spacing: -0.02em; + background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} +.card { + background: var(--bg-secondary); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 32px; +} +textarea { + width: 100%; + min-height: 300px; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 16px; + font-family: 'JetBrains Mono', monospace; + font-size: 14px; + color: var(--text-primary); + resize: vertical; + transition: border-color 0.2s; +} +textarea:focus { outline: none; border-color: var(--accent); } +textarea::placeholder { color: var(--text-secondary); } +.actions { + margin-top: 24px; + display: flex; + justify-content: flex-end; + gap: 12px; +} +button, .btn-primary { + background: var(--success); + color: white; + border: none; + padding: 12px 24px; + border-radius: var(--radius-sm); + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; + text-decoration: none; + display: inline-block; +} +button:hover:not(:disabled), .btn-primary:hover { background: var(--success-hover); } +button:disabled { opacity: 0.5; cursor: not-allowed; } +.btn-secondary { + background: transparent; + color: var(--text-primary); + border: 1px solid var(--border); + padding: 12px 24px; + border-radius: var(--radius-sm); + font-size: 14px; + font-weight: 500; + cursor: pointer; + text-decoration: none; + display: inline-block; + transition: background 0.2s, border-color 0.2s; +} +.btn-secondary:hover { background: var(--bg-tertiary); border-color: var(--accent); } +.btn-copy { + background: var(--accent); + color: var(--bg-primary); + border: none; + padding: 10px 20px; + border-radius: var(--radius-sm); + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; + white-space: nowrap; +} +.btn-copy:hover { background: var(--accent-hover); } +.url-section { margin-bottom: 24px; } +.url-section label { + display: block; + margin-bottom: 8px; + color: var(--text-secondary); + font-size: 14px; + font-weight: 500; +} +.url-box { + display: flex; + gap: 12px; + align-items: center; +} +.url-input { + flex: 1; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 12px 16px; + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + color: var(--text-primary); +} +.notice { + background: rgba(210, 153, 34, 0.15); + border: 1px solid var(--warning); + border-radius: var(--radius-sm); + padding: 16px; + margin-bottom: 24px; +} +.notice strong { color: var(--warning); } +.paste-content, .paste-preview { + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 20px; +} +.paste-preview { margin-bottom: 24px; } +.paste-content pre, .paste-preview pre { + font-family: 'JetBrains Mono', monospace; + font-size: 14px; + line-height: 1.6; + color: var(--text-primary); + white-space: pre-wrap; + word-wrap: break-word; + margin: 0; +} +footer { + text-align: center; + padding: 20px; + color: var(--text-secondary); + font-size: 13px; + margin-top: 20px; +} +.centered { text-align: center; padding: 40px; } +.centered .logo { margin-bottom: 40px; } +.centered .card { max-width: 500px; margin: 0 auto; } +h2 { font-size: 1.5rem; margin-bottom: 16px; } +.centered p { color: var(--text-secondary); margin-bottom: 24px; } +.centered .btn-primary { + background: transparent; + color: var(--text-primary); + border: 1px solid var(--border); + padding: 12px 24px; + border-radius: var(--radius-sm); + font-size: 14px; + font-weight: 500; + cursor: pointer; + text-decoration: none; + display: inline-block; + transition: background 0.2s, border-color 0.2s; +} +.centered .btn-primary:hover { background: var(--bg-tertiary); border-color: var(--accent); } +header { margin-bottom: 30px; } +input[readonly] { + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 12px 16px; + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + color: var(--text-primary); + flex: 1; +} +.hidden { display: none; } +.warning { + background: rgba(248, 81, 73, 0.15); + border-color: #f85149; +} +.warning strong { color: #f85149; } +.error { + background: rgba(248, 81, 73, 0.15); + border: 1px solid #f85149; + border-radius: var(--radius-sm); + padding: 16px; + color: #f85149; +} +.share-url { + margin-bottom: 24px; + display: flex; + flex-direction: column; + gap: 8px; +} +.share-url label { + color: var(--text-secondary); + font-size: 14px; + font-weight: 500; +} +.share-url input { + width: 100%; + background: var(--bg-tertiary); + border: 1px solid var(--border); + border-radius: var(--radius-sm); + padding: 12px 16px; + font-family: 'JetBrains Mono', monospace; + font-size: 13px; + color: var(--text-primary); + cursor: text; +} +.share-url input:focus { + outline: none; + border-color: var(--accent); +} +#loading { + text-align: center; + padding: 40px; + color: var(--text-secondary); +} +.mascot { + display: inline-flex; + vertical-align: middle; + margin-left: 4px; +} +.mascot svg { + height: 1em; + width: auto; + display: block; +} +"