220 lines
6.9 KiB
Gleam
220 lines
6.9 KiB
Gleam
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 = '<label>Share this URL</label>';
|
|
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);
|
|
});
|
|
"
|
|
}
|