first sources: batman
This commit is contained in:
134
src/handlers.gleam
Normal file
134
src/handlers.gleam
Normal file
@@ -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])
|
||||
}
|
||||
}
|
||||
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();
|
||||
});
|
||||
"
|
||||
}
|
||||
9
src/key.gleam
Normal file
9
src/key.gleam
Normal file
@@ -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)
|
||||
}
|
||||
73
src/layout.gleam
Normal file
73
src/layout.gleam
Normal file
@@ -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 =
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 2105 2016' fill='none' class='lucy'><path fill='#FFAFF3' d='M842.026 129.177C870.114 49.5833 974.531 31.1719 1028.15 96.3575L1309.17 438.02C1343.94 480.296 1395.4 505.347 1450.14 506.68L1892.78 517.45C1977.29 519.505 2026.92 612.947 1981.45 683.983L1742.87 1056.65C1728.28 1079.43 1718.77 1105.09 1715 1131.88C1711.22 1158.66 1713.27 1185.95 1721 1211.87L1847.34 1635.7C1871.42 1716.45 1797.88 1792.71 1716.03 1771.44L1287.55 1660.11C1261.36 1653.3 1234.01 1652.21 1207.36 1656.91C1180.72 1661.6 1155.39 1671.98 1133.11 1687.34L768.557 1938.51C698.913 1986.48 603.729 1939.98 598.726 1855.86L572.502 1414.38C569.257 1359.74 542.373 1309.24 498.866 1276.01L147.203 1007.41C80.1767 956.216 94.8588 851.43 173.566 820.594L585.833 659.08C636.813 639.107 676.599 597.967 694.813 546.348L842.026 129.177Z'/><path fill='#151515' d='M918.91 20.3875C868.969 29.1948 823.32 62.3526 804.42 115.904L657.186 533.07C642.831 573.741 611.52 606.123 571.327 621.871L159.044 783.395C53.2498 824.843 32.7918 970.356 122.981 1039.24L474.644 1307.81C491.576 1320.73 505.522 1337.15 515.528 1355.96C525.534 1374.76 531.366 1395.5 532.625 1416.76L558.835 1858.23C565.559 1971.49 697.668 2035.92 791.287 1971.44L791.289 1971.43L1155.87 1720.26L1155.87 1720.25C1173.42 1708.15 1193.38 1699.97 1214.37 1696.27C1235.37 1692.57 1256.92 1693.43 1277.55 1698.8L1277.55 1698.8L1706.04 1810.11C1816.06 1838.7 1918.17 1732.96 1885.75 1624.22L1885.75 1624.23L1759.42 1200.41L1759.42 1200.41C1753.33 1180 1751.72 1158.52 1754.69 1137.42C1757.66 1116.33 1765.15 1096.13 1776.65 1078.2L1776.65 1078.19L2015.25 705.513L2015.24 705.511C2076.44 609.933 2007.46 480.197 1893.87 477.434L1451.21 466.681C1408.06 465.633 1367.56 445.914 1340.17 412.608L1059.15 70.9554C1023.08 27.1014 968.841 11.5585 918.896 20.3665M932.34 96.6134C955.035 92.6111 979.819 100.451 997.365 121.779L1278.38 463.422C1320.52 514.663 1382.95 545.042 1449.26 546.655L1891.92 557.408C1947.35 558.754 1977.63 615.896 1947.86 662.382L1709.27 1035.06C1673.48 1090.93 1663.8 1159.69 1682.74 1223.26L1809.08 1647.07C1824.81 1699.85 1779.83 1746.62 1726.15 1732.68L1726.15 1732.68L1297.66 1621.36C1233.45 1604.67 1165.09 1616.73 1110.46 1654.38L745.884 1905.55C700.205 1937.02 641.945 1908.45 638.676 1853.48L612.469 1412.01C608.537 1345.78 575.914 1284.5 523.186 1244.22L171.524 975.65C127.662 942.151 136.58 878.1 188.203 857.867L600.485 696.341C662.256 672.139 710.525 622.24 732.608 559.671L732.608 559.669L879.842 142.504C889.034 116.463 909.64 100.618 932.335 96.6154'/></svg>"
|
||||
html.footer([], [
|
||||
text("Proudly made with Gleam "),
|
||||
element.unsafe_raw_html(
|
||||
"http://www.w3.org/1999/xhtml",
|
||||
"span",
|
||||
[attribute.class("mascot")],
|
||||
lucy_svg,
|
||||
),
|
||||
])
|
||||
}
|
||||
36
src/spasteg.gleam
Normal file
36
src/spasteg.gleam
Normal file
@@ -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()
|
||||
}
|
||||
69
src/storage.gleam
Normal file
69
src/storage.gleam
Normal file
@@ -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
|
||||
}
|
||||
253
src/styles.gleam
Normal file
253
src/styles.gleam
Normal file
@@ -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;
|
||||
}
|
||||
"
|
||||
Reference in New Issue
Block a user