Compare commits

..

35 Commits

Author SHA1 Message Date
53ab8ef297 refactor: use pipe 2026-03-17 10:41:45 +01:00
c51860c2b1 docs: update readme 2026-03-04 06:58:35 +01:00
51a5242219 docs: add screenshots 2026-03-04 06:56:42 +01:00
baa09c853c fix: remove comma in description 2026-03-02 09:39:06 +01:00
7b2e7a7824 fix: gitea repo 2026-03-02 09:38:38 +01:00
de72a9de4b refactor: pipe directly 2026-03-02 07:36:16 +01:00
26291f7d28 clean: format 2026-03-02 07:35:36 +01:00
87f98d914d fix: improve JS security and error handling in html.gleam 2026-03-02 07:35:23 +01:00
f5f5167af3 refactor: make css readable 2026-03-02 07:14:07 +01:00
613594ebf6 fix: missing type annotations 2026-03-01 22:20:42 +01:00
a23498a0ca refactor: pipe directly response into adding security headers 2026-03-01 22:09:26 +01:00
cb9c9369ba docs: link 2026-03-01 18:07:49 +01:00
12b6dde893 fix: lang 2026-03-01 14:59:31 +01:00
d4c09afc94 docs: description 2026-03-01 14:50:20 +01:00
80fcf29109 docs: jump line 2026-03-01 14:26:14 +01:00
898b582c3b ocs: update readme 2026-03-01 14:25:25 +01:00
35e015d1e0 feat: burn is better than deleted here 2026-03-01 13:18:02 +01:00
cdaac1b35d docs: update readme 2026-03-01 13:12:50 +01:00
02e28aba4a clean: one RUN, no custom port 2026-03-01 13:12:46 +01:00
7244b9bcc5 docs: phrasing 2026-03-01 13:06:22 +01:00
7ebde59800 docs: link 2026-03-01 13:05:40 +01:00
cf492f9798 docs: update readme 2026-03-01 13:05:04 +01:00
53dd416c17 docs: update readme 2026-03-01 12:59:36 +01:00
31da68b6c1 feat: dockerfile 2026-03-01 12:57:30 +01:00
2ecfda256b feat: bind mist on all interfaces 2026-03-01 12:57:24 +01:00
ce1fa0b801 test: add an unit test for generate() 2026-03-01 12:41:48 +01:00
3bb857ec19 docs: update readme 2026-03-01 12:14:38 +01:00
c1a9c2d22f clean: ultimately, we're not implementing a reset 2026-03-01 12:13:52 +01:00
25be9c8a1b clean: remove PeekPaste 2026-03-01 12:10:46 +01:00
77fbc2e0bb refactor: new css 2026-03-01 11:41:45 +01:00
c60900efb5 refactor: flatten POST handler with Result pipeline, simplify IP lookup 2026-03-01 10:24:47 +01:00
891107c442 refactor: extract lookup helper, use idiomatic pipelines, remove unused imports 2026-03-01 10:07:40 +01:00
0de588b2d6 refactor: modernize JavaScript with ES6+ syntax (arrow functions, destructuring, template literals) 2026-03-01 10:00:57 +01:00
2b24d18df2 clean: unused styles and other stuff fixed 2026-03-01 09:56:59 +01:00
4602cb2af1 docs: this code is not from me 2026-03-01 09:54:07 +01:00
14 changed files with 495 additions and 407 deletions

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM ghcr.io/gleam-lang/gleam:v1.14.0-erlang-alpine
RUN apk add --no-cache elixir git libstdc++ openssl
WORKDIR /app
COPY gleam.toml manifest.toml ./
COPY src/ ./src/
RUN gleam deps download && \
gleam build --target erlang && \
adduser -D -H spasteg && \
chown -R spasteg:spasteg /app
EXPOSE 3000
USER spasteg
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/ || exit 1
ENTRYPOINT ["gleam", "run", "--target", "erlang", "--", "--no-halt"]

View File

@@ -1,6 +1,8 @@
# spasteg # spasteg
A secure, self-hostable "burn after reading" paste service with ephemeral storage written in [Gleam](https://gleam.run). A secure self-hostable burn-after-reading paste service with ephemeral storage written in [Gleam](https://gleam.run).
Have a glimpse of the interface, check out [screenshots](screenshots/)!
## Features ## Features
@@ -11,6 +13,16 @@ A secure, self-hostable "burn after reading" paste service with ephemeral storag
- Fast and reliable - Fast and reliable
- Written in Gleam (type-safe) - Written in Gleam (type-safe)
## Architecture
| Component | Description |
| ------------ | -------------------------------------------------------------------------------- |
| Backend/Core | Gleam (type-safe language built upon the BEAM) |
| Web | Wisp framework + Mist HTTP server |
| Frontend | Lustre for HTML rendering |
| Storage | In-memory only (no persistence) |
| Security | AES-256-GCM client-side encryption, CSRF tokens, rate limiting, security headers |
## Configuration ## Configuration
### SECRET_KEY_BASE (Required for Production) ### SECRET_KEY_BASE (Required for Production)
@@ -39,7 +51,9 @@ For development, you can use:
SECRET_KEY_BASE=dev gleam run SECRET_KEY_BASE=dev gleam run
``` ```
## Quick Start ## How to run
### Development
```bash ```bash
# Clone and build # Clone and build
@@ -48,39 +62,38 @@ cd spasteg
gleam run gleam run
``` ```
The server starts on `http://localhost:3000`. The server starts on <http://localhost:3000>.
Note: you can run tests with `gleam test`.
### Production
The production environment is designed to run via Docker.
You can build the Docker image with:
```bash
docker build -t spasteg .
```
Then run the container with:
```bash
docker run -d --name pasteg -p <your_port>:3000 -e SECRET_KEY_BASE=$(openssl rand -base64 48) spasteg
```
The key is generated at startup here, and the container exposes port 3000 so feel free to use the port you want. It also runs as a non-root user with a health check configured.
## Usage ## Usage
1. Visit `http://localhost:3000` 1. Visit <http://localhost:3000>
2. Enter your text in the form 2. Enter your text in the form
3. Click "Create Paste" 3. Click "Create Paste"
4. Share the generated URL 4. Share the generated URL
5. The paste auto-destructs after first access 5. The paste auto-destructs after first access
Note: the creator cannot see their post with the copied link (except in private browsing) - it would be burned immediately.
## Architecture
- **Gleam**: Type-safe language built upon the BEAM
- **Web**: Wisp framework + Mist HTTP server
- **Frontend**: Lustre for HTML rendering
- **Storage**: In-memory only (no persistence)
- **Security**: AES-256-GCM client-side encryption, CSRF tokens, rate limiting, security headers
## Security Notes
- Pastes are client-side encrypted (AES-256-GCM) before being sent to server
- Server never sees the decryption key (stored in URL fragment after `#`)
- Data is stored **encrypted** in server memory only
- Data is **never written to disk**
- All data is lost on server restart
- CSRF protection via double-submit cookie pattern
- Rate limiting: 10 requests per minute per IP
- Security headers: CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy
- 10MB maximum paste size limit
- Intended for ephemeral sharing only — do not store sensitive data
## License ## License
This project is licensed under the GNU General Public License v3.0 or later (GPLv3+). See the [LICENSE](LICENSE) file for details. This project is licensed under the GNU General Public License v3.0 or later (GPLv3+).
See the [LICENSE](LICENSE) file for details.

View File

@@ -1,9 +1,9 @@
name = "spasteg" name = "spasteg"
version = "1.0.0" version = "1.0.0"
description = "A secure, self-hostable burn-after-reading paste service written in Gleam" description = "A secure self-hostable burn-after-reading paste service written in Gleam"
licences = ["GPL-3.0"] licences = ["GPL-3.0"]
repository = { type = "github", user = "Kharec", repo = "spasteg", host = "https://git.kharec.info" } repository = { type = "gitea", user = "Kharec", repo = "spasteg", host = "https://git.kharec.info" }
[dependencies] [dependencies]
gleam_stdlib = ">= 0.44.0 and < 2.0.0" gleam_stdlib = ">= 0.44.0 and < 2.0.0"

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

BIN
screenshots/main-view.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 36 KiB

BIN
screenshots/pasted-view.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

View File

@@ -10,14 +10,9 @@ import storage
import wisp import wisp
fn get_client_ip(request: wisp.Request) -> String { fn get_client_ip(request: wisp.Request) -> String {
case list.key_find(request.headers, "x-forwarded-for") { list.key_find(request.headers, "x-forwarded-for")
Ok(ip) -> ip |> result.try_recover(fn(_) { list.key_find(request.headers, "x-real-ip") })
Error(_) -> |> result.unwrap("unknown")
case list.key_find(request.headers, "x-real-ip") {
Ok(ip) -> ip
Error(_) -> "unknown"
}
}
} }
pub fn handle( pub fn handle(
@@ -25,12 +20,12 @@ pub fn handle(
storage: process.Subject(storage.StorageMsg), storage: process.Subject(storage.StorageMsg),
_secret_key_base: String, _secret_key_base: String,
) -> wisp.Response { ) -> wisp.Response {
let response = case wisp.path_segments(request) { case wisp.path_segments(request) {
[] -> handle_home(request, storage) [] -> handle_home(request, storage)
["paste", key_param] -> handle_paste(request, storage, key_param) ["paste", key_param] -> handle_paste(request, storage, key_param)
_ -> wisp.not_found() _ -> wisp.not_found()
} }
add_security_headers(response) |> add_security_headers
} }
fn add_security_headers(response: wisp.Response) -> wisp.Response { fn add_security_headers(response: wisp.Response) -> wisp.Response {
@@ -57,21 +52,67 @@ fn handle_home(
} }
http.Post -> { http.Post -> {
use form <- wisp.require_form(request) use form <- wisp.require_form(request)
let result = {
use _ <- result.try(verify_csrf(request, form))
use _ <- result.try(check_rate_limit(storage, get_client_ip(request)))
use key <- result.try(create_paste(storage, form))
Ok(key)
}
case result {
Ok(paste_key) -> {
wisp.ok()
|> wisp.html_body(html.created(paste_key))
}
Error(response) -> response
}
}
_ -> wisp.method_not_allowed([http.Get, http.Post])
}
}
fn verify_csrf(
request: wisp.Request,
form: wisp.FormData,
) -> Result(Nil, wisp.Response) {
let csrf_cookie = wisp.get_cookie(request, "csrf_token", wisp.Signed) let csrf_cookie = wisp.get_cookie(request, "csrf_token", wisp.Signed)
let csrf_form = list.key_find(form.values, "csrf_token") let csrf_form = list.key_find(form.values, "csrf_token")
case csrf_cookie, csrf_form { case csrf_cookie, csrf_form {
Ok(cookie_token), Ok(form_token) if cookie_token == form_token -> { Ok(cookie_token), Ok(form_token) if cookie_token == form_token -> Ok(Nil)
let ip = get_client_ip(request) _, _ -> Error(wisp.bad_request("Invalid CSRF token"))
}
}
fn check_rate_limit(
storage: process.Subject(storage.StorageMsg),
ip: String,
) -> Result(Nil, wisp.Response) {
let rate_reply = process.new_subject() let rate_reply = process.new_subject()
process.send(storage, storage.CheckRateLimit(ip, rate_reply)) process.send(storage, storage.CheckRateLimit(ip, rate_reply))
case process.receive(rate_reply, 1000) { case process.receive(rate_reply, 1000) {
Ok(True) -> { Ok(True) -> Ok(Nil)
_ ->
Error(
wisp.response(429)
|> wisp.html_body("Rate limit exceeded"),
)
}
}
fn create_paste(
storage: process.Subject(storage.StorageMsg),
form: wisp.FormData,
) -> Result(String, wisp.Response) {
let encrypted_content = let encrypted_content =
list.key_find(form.values, "encrypted_content") list.key_find(form.values, "encrypted_content")
|> result.unwrap("") |> result.unwrap("")
case string.length(encrypted_content) { case string.length(encrypted_content) {
0 -> wisp.bad_request("Missing content") 0 -> Error(wisp.bad_request("Missing content"))
n if n > 10_000_000 -> wisp.bad_request("Content too large") n if n > 10_000_000 -> Error(wisp.bad_request("Content too large"))
_ -> { _ -> {
let new_key = key.generate() let new_key = key.generate()
let paste_reply = process.new_subject() let paste_reply = process.new_subject()
@@ -79,31 +120,18 @@ fn handle_home(
storage, storage,
storage.CreatePaste(new_key, encrypted_content, paste_reply), storage.CreatePaste(new_key, encrypted_content, paste_reply),
) )
case process.receive(paste_reply, 1000) { case process.receive(paste_reply, 1000) {
Ok(True) -> { Ok(True) -> Ok(new_key)
wisp.ok() _ ->
|> wisp.html_body(html.created(new_key)) Error(
}
_ -> {
wisp.internal_server_error() wisp.internal_server_error()
|> wisp.html_body("Failed to create paste") |> 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( fn handle_paste(
request: wisp.Request, request: wisp.Request,

View File

@@ -55,8 +55,8 @@ pub fn created(key: String) -> String {
text("The server never stores or sees the decryption key."), text("The server never stores or sees the decryption key."),
]), ]),
html.div([attribute.class("notice")], [ html.div([attribute.class("notice")], [
html.strong([], [text("Burn after reading")]), html.strong([], [text("Beware")]),
text(" — This paste will be deleted after the first view"), text(" — This paste will be burned after the first view!"),
]), ]),
html.div( html.div(
[attribute.attribute("data-paste-id", key), attribute.class("hidden")], [attribute.attribute("data-paste-id", key), attribute.class("hidden")],
@@ -100,7 +100,7 @@ pub fn not_found() -> String {
layout.card([ layout.card([
html.h2([], [text("Paste not found")]), html.h2([], [text("Paste not found")]),
html.p([], [ html.p([], [
text("This paste may have already been viewed and deleted."), text("This content has already been burned 🔥"),
]), ]),
html.a([attribute.href("/"), attribute.class("btn-primary")], [ html.a([attribute.href("/"), attribute.class("btn-primary")], [
text("Create New Paste"), text("Create New Paste"),
@@ -112,47 +112,45 @@ pub fn not_found() -> String {
fn decrypt_js(encrypted_content: String) -> String { fn decrypt_js(encrypted_content: String) -> String {
" "
(async function() { (async () => {
const encryptedContent = '" <> escape_js_string(encrypted_content) <> "'; const encryptedContent = '" <> escape_js_string(encrypted_content) <> "';
const hash = window.location.hash.slice(1); const hash = location.hash.slice(1);
const loadingEl = document.getElementById('loading'); const els = {
const errorEl = document.getElementById('error'); loading: document.getElementById('loading'),
const contentEl = document.getElementById('content-display'); error: document.getElementById('error'),
content: document.getElementById('content-display')
};
if (!els.loading || !els.error || !els.content) {
console.error('Required DOM elements not found');
return;
}
if (!hash) { if (!hash) {
loadingEl.classList.add('hidden'); els.loading.classList.add('hidden');
errorEl.classList.remove('hidden'); els.error.classList.remove('hidden');
return; return;
} }
try { try {
const keyBase64 = hash.replace(/-/g, '+').replace(/_/g, '/'); let keyBase64 = hash.replace(/-/g, '+').replace(/_/g, '/');
while (keyBase64.length % 4) keyBase64 += '=';
const encryptedBytes = Uint8Array.from(atob(encryptedContent), c => c.charCodeAt(0)); const encryptedBytes = Uint8Array.from(atob(encryptedContent), c => c.charCodeAt(0));
const keyBytes = Uint8Array.from(atob(keyBase64), c => c.charCodeAt(0)); const keyBytes = Uint8Array.from(atob(keyBase64), c => c.charCodeAt(0));
const iv = encryptedBytes.slice(0, 12); const iv = encryptedBytes.slice(0, 12);
const ciphertext = encryptedBytes.slice(12);
const key = await crypto.subtle.importKey( const key = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt']);
'raw', const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encryptedBytes.slice(12));
keyBytes,
{ name: 'AES-GCM' }, els.content.textContent = new TextDecoder().decode(decrypted);
false, els.loading.classList.add('hidden');
['decrypt'] els.error.classList.add('hidden');
); els.content.classList.remove('hidden');
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) { } catch (e) {
console.error('Decryption failed:', e); console.error('Decryption failed:', e);
loadingEl.classList.add('hidden'); els.loading.classList.add('hidden');
errorEl.classList.remove('hidden'); els.error.classList.remove('hidden');
} }
})(); })();
" "
@@ -165,80 +163,97 @@ fn escape_js_string(s: String) -> String {
|> string.replace("\"", "\\\"") |> string.replace("\"", "\\\"")
|> string.replace("\n", "\\n") |> string.replace("\n", "\\n")
|> string.replace("\r", "\\r") |> string.replace("\r", "\\r")
|> string.replace("<", "\\u003c")
|> string.replace(">", "\\u003e")
|> string.replace("&", "\\u0026")
} }
fn crypto_js() -> String { fn crypto_js() -> String {
" "
async function encryptContent(content) { async function encryptContent(content) {
try {
const encoder = new TextEncoder(); const encoder = new TextEncoder();
const data = encoder.encode(content); const data = encoder.encode(content);
const key = await crypto.subtle.generateKey( const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
{ name: 'AES-GCM', length: 256 },
true,
['encrypt', 'decrypt']
);
const iv = crypto.getRandomValues(new Uint8Array(12)); const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = await crypto.subtle.encrypt( const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data);
{ name: 'AES-GCM', iv: iv },
key,
data
);
const keyData = await crypto.subtle.exportKey('raw', key); const keyData = await crypto.subtle.exportKey('raw', key);
const keyBytes = new Uint8Array(keyData); return {
const combined = new Uint8Array(iv.length + encrypted.byteLength); encrypted: btoa(String.fromCharCode(...iv, ...new Uint8Array(encrypted))),
combined.set(iv); key: btoa(String.fromCharCode(...new Uint8Array(keyData)))
combined.set(new Uint8Array(encrypted), iv.length); };
const encryptedBase64 = btoa(String.fromCharCode(...combined)); } catch (e) {
const keyBase64 = btoa(String.fromCharCode(...keyBytes)); console.error('Encryption failed:', e);
return { encrypted: encryptedBase64, key: keyBase64 }; throw new Error('Failed to encrypt content');
}
} }
async function base64ToUrlSafeBase64(base64) { function base64ToUrlSafe(base64) {
let result = base64.split('+').join('-'); return base64.split('+').join('-').split('/').join('_').replace(/=+$/g, '');
result = result.split('/').join('_');
while (result.endsWith('=')) {
result = result.slice(0, -1);
}
return result;
} }
document.getElementById('paste-form').addEventListener('submit', async function(e) { function showShareUrl(url) {
const card = document.querySelector('.card');
if (!card) {
console.error('Card element not found');
return;
}
const div = document.createElement('div');
div.className = 'share-url';
const label = document.createElement('label');
label.textContent = 'Share this URL';
div.appendChild(label);
const input = document.createElement('input');
input.type = 'text';
input.readOnly = true;
input.value = url;
input.addEventListener('click', () => input.select());
div.appendChild(input);
card.insertBefore(div, card.firstChild);
input.select();
}
document.getElementById('paste-form')?.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const content = document.getElementById('content').value;
const result = await encryptContent(content); const submitButton = e.target.querySelector('button[type=\"submit\"]');
document.getElementById('encrypted-content').value = result.encrypted; const contentInput = document.getElementById('content');
const response = await fetch('/', { const encryptedInput = document.getElementById('encrypted-content');
const csrfInput = document.getElementById('csrf-token');
if (!contentInput || !encryptedInput || !csrfInput) {
console.error('Required form elements not found');
return;
}
if (submitButton) submitButton.disabled = true;
try {
const content = contentInput.value;
const { encrypted, key } = await encryptContent(content);
encryptedInput.value = encrypted;
const csrfToken = csrfInput.value;
const res = await fetch('/', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: 'encrypted_content=' + encodeURIComponent(result.encrypted) + '&csrf_token=' + encodeURIComponent(document.getElementById('csrf-token').value) body: `encrypted_content=${encodeURIComponent(encrypted)}&csrf_token=${encodeURIComponent(csrfToken)}`
}); });
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;
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) throw new Error('No paste ID returned');
const pasteUrl = `${location.origin}/paste/${pasteId}#${base64ToUrlSafe(key)}`;
document.body.replaceChildren(...doc.body.childNodes); document.body.replaceChildren(...doc.body.childNodes);
showShareUrl(pasteUrl);
const card = document.querySelector('.card'); history.replaceState({}, '', pasteUrl);
const urlDiv = document.createElement('div'); } catch (err) {
urlDiv.className = 'share-url'; console.error('Form submission failed:', err);
const lbl = document.createElement('label'); alert('Failed to create paste. Please try again.');
lbl.textContent = 'Share this URL'; if (submitButton) submitButton.disabled = false;
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();
}); });
" "
} }

View File

@@ -4,6 +4,7 @@ import gleam/crypto
const key_length_bytes = 12 const key_length_bytes = 12
pub fn generate() -> String { pub fn generate() -> String {
crypto.strong_random_bytes(key_length_bytes) key_length_bytes
|> crypto.strong_random_bytes
|> bit_array.base64_url_encode(False) |> bit_array.base64_url_encode(False)
} }

View File

@@ -29,6 +29,7 @@ pub fn main() {
secret_key_base, secret_key_base,
) )
|> mist.new |> mist.new
|> mist.bind("0.0.0.0")
|> mist.port(3000) |> mist.port(3000)
|> mist.start |> mist.start

View File

@@ -1,6 +1,6 @@
import gleam/dict.{type Dict} import gleam/dict.{type Dict}
import gleam/erlang/process import gleam/erlang/process
import gleam/option.{type Option, None, Some} import gleam/option.{type Option}
import gleam/otp/actor import gleam/otp/actor
const max_requests_per_minute = 10 const max_requests_per_minute = 10
@@ -12,53 +12,48 @@ pub type StorageState {
pub type StorageMsg { pub type StorageMsg {
CreatePaste(key: String, content: String, reply: process.Subject(Bool)) CreatePaste(key: String, content: String, reply: process.Subject(Bool))
GetPaste(key: String, reply: process.Subject(Option(String))) GetPaste(key: String, reply: process.Subject(Option(String)))
PeekPaste(key: String, reply: process.Subject(Option(String)))
CheckRateLimit(ip: String, reply: process.Subject(Bool)) CheckRateLimit(ip: String, reply: process.Subject(Bool))
ResetRateLimits }
fn lookup_paste(state: StorageState, key: String) -> Option(String) {
state.pastes
|> dict.get(key)
|> option.from_result
} }
pub fn handle_message(state: StorageState, msg: StorageMsg) { pub fn handle_message(state: StorageState, msg: StorageMsg) {
case msg { case msg {
CreatePaste(key, content, reply) -> { CreatePaste(key, content, reply) -> {
let new_state = let new_pastes =
StorageState(dict.insert(state.pastes, key, content), state.rate_limits) state.pastes
|> dict.insert(key, content)
process.send(reply, True) process.send(reply, True)
actor.continue(new_state) actor.continue(StorageState(new_pastes, state.rate_limits))
}
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) -> { GetPaste(key, reply) -> {
let content = case dict.get(state.pastes, key) { let content = lookup_paste(state, key)
Ok(value) -> Some(value) let new_pastes =
Error(_) -> None state.pastes
} |> dict.delete(key)
let new_pastes = dict.delete(state.pastes, key)
let new_state = StorageState(new_pastes, state.rate_limits)
process.send(reply, content) process.send(reply, content)
actor.continue(new_state) actor.continue(StorageState(new_pastes, state.rate_limits))
} }
CheckRateLimit(ip, reply) -> { CheckRateLimit(ip, reply) -> {
let current_count = let count =
dict.get(state.rate_limits, ip) state.rate_limits
|> dict.get(ip)
|> option.from_result |> option.from_result
|> option.unwrap(0) |> option.unwrap(0)
let allowed = current_count < max_requests_per_minute let allowed = count < max_requests_per_minute
let new_limits = case allowed { let new_limits = case allowed {
True -> dict.insert(state.rate_limits, ip, current_count + 1) True ->
state.rate_limits
|> dict.insert(ip, count + 1)
False -> state.rate_limits False -> state.rate_limits
} }
process.send(reply, allowed) process.send(reply, allowed)
actor.continue(StorageState(state.pastes, new_limits)) actor.continue(StorageState(state.pastes, new_limits))
} }
ResetRateLimits -> {
actor.continue(StorageState(state.pastes, dict.new()))
}
} }
} }

View File

@@ -1,250 +1,238 @@
// this css is AI-generated, sorry, really not a front guy
pub const shared_css = " pub const shared_css = "
* { margin: 0; padding: 0; box-sizing: border-box; }
:root { :root {
--bg-primary: #0d1117; --bg: #0d1117;
--bg-secondary: #161b22; --bg2: #161b22;
--bg-tertiary: #21262d; --bg3: #21262d;
--text-primary: #e6edf3; --fg: #e6edf3;
--text-secondary: #8b949e; --dim: #8b949e;
--accent: #58a6ff; --accent: #58a6ff;
--accent-hover: #79c0ff; --green: #238636;
--success: #238636;
--success-hover: #2ea043;
--warning: #d29922;
--border: #30363d; --border: #30363d;
--radius: 12px; --warning: #d29922;
--radius-sm: 6px; --error: #f85149;
--mono: 'JetBrains Mono', monospace;
} }
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body { body {
font-family: 'Inter', system-ui, -apple-system, sans-serif; font-family: system-ui, sans-serif;
background: var(--bg-primary); background: var(--bg);
color: var(--text-primary); color: var(--fg);
min-height: 100vh; min-height: 100vh;
line-height: 1.6; line-height: 1.6;
} }
h2 {
font-size: 1.5rem;
margin-bottom: 16px;
}
.container { .container {
max-width: 900px; max-width: 900px;
margin: 0 auto; margin: 0 auto;
padding: 40px 20px; padding: 40px 20px;
} }
header { text-align: center; margin-bottom: 40px; }
.tagline { header {
color: var(--text-secondary); text-align: center;
font-size: 1rem; margin-bottom: 40px;
margin-top: 8px;
font-weight: 500;
} }
.logo { .logo {
font-size: 2.5rem; font-size: 2.5rem;
font-weight: 700; font-weight: 700;
letter-spacing: -0.02em; color: var(--accent);
background: linear-gradient(135deg, var(--accent) 0%, var(--accent-hover) 100%);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
} }
.tagline {
color: var(--dim);
margin-top: 8px;
}
.card { .card {
background: var(--bg-secondary); background: var(--bg2);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius); border-radius: 12px;
padding: 32px; padding: 32px;
} }
textarea {
width: 100%; textarea,
min-height: 300px; .paste-content,
background: var(--bg-tertiary); .share-url input,
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] { input[readonly] {
background: var(--bg-tertiary); width: 100%;
background: var(--bg3);
border: 1px solid var(--border); border: 1px solid var(--border);
border-radius: var(--radius-sm); border-radius: 6px;
padding: 12px 16px; padding: 14px;
font-family: 'JetBrains Mono', monospace; font-family: var(--mono);
font-size: 13px; font-size: 14px;
color: var(--text-primary); color: var(--fg);
flex: 1;
} }
.hidden { display: none; }
.warning { textarea {
background: rgba(248, 81, 73, 0.15); min-height: 300px;
border-color: #f85149; resize: vertical;
} }
.warning strong { color: #f85149; }
.error { textarea::placeholder {
background: rgba(248, 81, 73, 0.15); color: var(--dim);
border: 1px solid #f85149;
border-radius: var(--radius-sm);
padding: 16px;
color: #f85149;
} }
textarea:focus,
.share-url input:focus {
outline: none;
border-color: var(--accent);
}
.share-url { .share-url {
margin-bottom: 24px; margin-bottom: 24px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 8px; gap: 8px;
} }
.share-url label { .share-url label {
color: var(--text-secondary); color: var(--dim);
font-size: 14px; font-size: 14px;
font-weight: 500; font-weight: 500;
} }
.share-url input {
width: 100%; .paste-content pre {
background: var(--bg-tertiary); font-family: var(--mono);
border: 1px solid var(--border); font-size: 14px;
border-radius: var(--radius-sm); line-height: 1.6;
padding: 12px 16px; white-space: pre-wrap;
font-family: 'JetBrains Mono', monospace; word-wrap: break-word;
font-size: 13px; margin: 0;
color: var(--text-primary);
cursor: text;
} }
.share-url input:focus {
outline: none; .actions {
margin-top: 24px;
display: flex;
justify-content: flex-end;
gap: 12px;
}
.actions.center {
justify-content: center;
}
button,
.btn-primary {
background: var(--green);
color: #fff;
border: none;
padding: 12px 24px;
border-radius: 6px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
text-decoration: none;
display: inline-block;
}
button:hover:not(:disabled),
.btn-primary:hover {
filter: brightness(1.15);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.notice {
background: rgba(210,153,34,.15);
border: 1px solid var(--warning);
border-radius: 6px;
padding: 16px;
margin-bottom: 24px;
}
.notice strong {
color: var(--warning);
}
.notice.warning {
background: rgba(248,81,73,.15);
border-color: var(--error);
}
.notice.warning strong {
color: var(--error);
}
.error {
background: rgba(248,81,73,.15);
border: 1px solid var(--error);
border-radius: 6px;
padding: 16px;
color: var(--error);
}
footer {
text-align: center;
padding: 20px;
color: var(--dim);
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;
}
.centered p {
color: var(--dim);
margin-bottom: 24px;
}
.centered .btn-primary {
background: transparent;
color: var(--fg);
border: 1px solid var(--border);
font-weight: 500;
}
.centered .btn-primary:hover {
background: var(--bg3);
border-color: var(--accent); border-color: var(--accent);
} }
.hidden {
display: none;
}
#loading { #loading {
text-align: center; text-align: center;
padding: 40px; padding: 40px;
color: var(--text-secondary); color: var(--dim);
} }
.mascot { .mascot {
display: inline-flex; display: inline-flex;
vertical-align: middle; vertical-align: middle;
margin-left: 4px; margin-left: 4px;
} }
.mascot svg { .mascot svg {
height: 1em; height: 1em;
width: auto; width: auto;

View File

@@ -1 +1,26 @@
import gleam/list
import gleam/string
import gleeunit
import gleeunit/should
import key
pub fn main() {
gleeunit.main()
}
pub fn generate_test() {
let key_result = key.generate()
let allowed_chars =
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"
key_result |> should.not_equal("")
key_result
|> string.length
|> should.equal(16)
key_result
|> string.to_graphemes
|> list.all(fn(char) { allowed_chars |> string.contains(char) })
|> should.be_true
}