first sources: batman
This commit is contained in:
244
src/html.gleam
Normal file
244
src/html.gleam
Normal file
@@ -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();
|
||||
});
|
||||
"
|
||||
}
|
||||
Reference in New Issue
Block a user