Compare commits

...

22 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
11 changed files with 345 additions and 111 deletions

View File

@@ -7,11 +7,11 @@ WORKDIR /app
COPY gleam.toml manifest.toml ./ COPY gleam.toml manifest.toml ./
COPY src/ ./src/ COPY src/ ./src/
RUN gleam deps download && gleam build --target erlang RUN gleam deps download && \
gleam build --target erlang && \
adduser -D -H spasteg && \
chown -R spasteg:spasteg /app
RUN adduser -D -H spasteg && chown -R spasteg:spasteg /app
ENV PORT=3000
EXPOSE 3000 EXPOSE 3000
USER spasteg USER spasteg

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
@@ -13,11 +15,13 @@ A secure, self-hostable "burn after reading" paste service with ephemeral storag
## Architecture ## Architecture
- Gleam: Type-safe language built upon the BEAM | Component | Description |
- Web: Wisp framework + Mist HTTP server | ------------ | -------------------------------------------------------------------------------- |
- Frontend: Lustre for HTML rendering | Backend/Core | Gleam (type-safe language built upon the BEAM) |
- Storage: In-memory only (no persistence) | Web | Wisp framework + Mist HTTP server |
- Security: AES-256-GCM client-side encryption, CSRF tokens, rate limiting, security headers | 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
@@ -47,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
@@ -56,41 +62,38 @@ cd spasteg
gleam run gleam run
``` ```
The server starts on `http://localhost:3000`. The server starts on <http://localhost:3000>.
## Docker image+deployment Note: you can run tests with `gleam test`.
### Build ### Production
The production environment is designed to run via Docker.
You can build the Docker image with:
```bash ```bash
docker build -t spasteg . docker build -t spasteg .
``` ```
### Run Then run the container with:
```bash ```bash
# Generate a secure key docker run -d --name pasteg -p <your_port>:3000 -e SECRET_KEY_BASE=$(openssl rand -base64 48) spasteg
docker run -p 3000:3000 -e SECRET_KEY_BASE=$(openssl rand -base64 48) spasteg
``` ```
**With custom port:** 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.
```bash
docker run -p 8080:3000 -e SECRET_KEY_BASE=$(openssl rand -base64 48) -e PORT=3000 spasteg
```
The container exposes port 3000 and 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.
## 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

@@ -20,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 {
@@ -85,7 +85,10 @@ fn verify_csrf(
} }
} }
fn check_rate_limit(storage, ip: String) -> Result(Nil, wisp.Response) { 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))
@@ -99,7 +102,10 @@ fn check_rate_limit(storage, ip: String) -> Result(Nil, wisp.Response) {
} }
} }
fn create_paste(storage, form: wisp.FormData) -> Result(String, wisp.Response) { 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("")

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"),
@@ -121,6 +121,11 @@ fn decrypt_js(encrypted_content: String) -> String {
content: document.getElementById('content-display') content: document.getElementById('content-display')
}; };
if (!els.loading || !els.error || !els.content) {
console.error('Required DOM elements not found');
return;
}
if (!hash) { if (!hash) {
els.loading.classList.add('hidden'); els.loading.classList.add('hidden');
els.error.classList.remove('hidden'); els.error.classList.remove('hidden');
@@ -128,7 +133,9 @@ fn decrypt_js(encrypted_content: String) -> String {
} }
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);
@@ -156,64 +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) {
const encoder = new TextEncoder(); try {
const data = encoder.encode(content); const encoder = new TextEncoder();
const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); const data = encoder.encode(content);
const iv = crypto.getRandomValues(new Uint8Array(12)); const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data); const iv = crypto.getRandomValues(new Uint8Array(12));
const keyData = await crypto.subtle.exportKey('raw', key); const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data);
return { const keyData = await crypto.subtle.exportKey('raw', key);
encrypted: btoa(String.fromCharCode(...new Uint8Array([...iv, ...new Uint8Array(encrypted)]))), return {
key: btoa(String.fromCharCode(...new Uint8Array(keyData))) encrypted: btoa(String.fromCharCode(...iv, ...new Uint8Array(encrypted))),
}; key: btoa(String.fromCharCode(...new Uint8Array(keyData)))
};
} catch (e) {
console.error('Encryption failed:', e);
throw new Error('Failed to encrypt content');
}
} }
function base64ToUrlSafe(base64) { function base64ToUrlSafe(base64) {
return base64.replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/, ''); return base64.split('+').join('-').split('/').join('_').replace(/=+$/g, '');
} }
function showShareUrl(url) { function showShareUrl(url) {
const card = document.querySelector('.card'); const card = document.querySelector('.card');
if (!card) {
console.error('Card element not found');
return;
}
const div = document.createElement('div'); const div = document.createElement('div');
div.className = 'share-url'; div.className = 'share-url';
div.innerHTML = '<label>Share this URL</label>'; const label = document.createElement('label');
label.textContent = 'Share this URL';
div.appendChild(label);
const input = document.createElement('input'); const input = document.createElement('input');
input.type = 'text'; input.type = 'text';
input.readOnly = true; input.readOnly = true;
input.value = url; input.value = url;
input.onclick = () => input.select(); input.addEventListener('click', () => input.select());
div.appendChild(input); div.appendChild(input);
card.insertBefore(div, card.firstChild); card.insertBefore(div, card.firstChild);
input.select(); input.select();
} }
document.getElementById('paste-form').addEventListener('submit', async (e) => { document.getElementById('paste-form')?.addEventListener('submit', async (e) => {
e.preventDefault(); 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('/', { const submitButton = e.target.querySelector('button[type=\"submit\"]');
method: 'POST', const contentInput = document.getElementById('content');
headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, const encryptedInput = document.getElementById('encrypted-content');
body: `encrypted_content=${encodeURIComponent(encrypted)}&csrf_token=${encodeURIComponent(csrfToken)}` const csrfInput = document.getElementById('csrf-token');
});
const html = await res.text(); if (!contentInput || !encryptedInput || !csrfInput) {
const doc = new DOMParser().parseFromString(html, 'text/html'); console.error('Required form elements not found');
const pasteId = doc.querySelector('[data-paste-id]')?.getAttribute('data-paste-id'); return;
if (!pasteId) return; }
const pasteUrl = `${location.origin}/paste/${pasteId}#${base64ToUrlSafe(key)}`; if (submitButton) submitButton.disabled = true;
document.body.replaceChildren(...doc.body.childNodes);
showShareUrl(pasteUrl); try {
history.replaceState({}, '', pasteUrl); const content = contentInput.value;
const { encrypted, key } = await encryptContent(content);
encryptedInput.value = encrypted;
const csrfToken = csrfInput.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) throw new Error('No paste ID returned');
const pasteUrl = `${location.origin}/paste/${pasteId}#${base64ToUrlSafe(key)}`;
document.body.replaceChildren(...doc.body.childNodes);
showShareUrl(pasteUrl);
history.replaceState({}, '', pasteUrl);
} catch (err) {
console.error('Form submission failed:', err);
alert('Failed to create paste. Please try again.');
if (submitButton) submitButton.disabled = false;
}
}); });
" "
} }

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

@@ -1,7 +1,6 @@
// this css is IA-generated, sorry, really not a front guy // 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: #0d1117; --bg: #0d1117;
--bg2: #161b22; --bg2: #161b22;
@@ -11,47 +10,232 @@ pub const shared_css = "
--accent: #58a6ff; --accent: #58a6ff;
--green: #238636; --green: #238636;
--border: #30363d; --border: #30363d;
--warning: #d29922;
--error: #f85149;
--mono: 'JetBrains Mono', monospace; --mono: 'JetBrains Mono', monospace;
} }
body { font-family: system-ui, sans-serif; background: var(--bg); color: var(--fg); min-height: 100vh; line-height: 1.6; }
.container { max-width: 900px; margin: 0 auto; padding: 40px 20px; } * {
header { text-align: center; margin-bottom: 40px; } margin: 0;
.logo { font-size: 2.5rem; font-weight: 700; color: var(--accent); } padding: 0;
.tagline { color: var(--dim); margin-top: 8px; } box-sizing: border-box;
.card { background: var(--bg2); border: 1px solid var(--border); border-radius: 12px; padding: 32px; }
textarea, .paste-content, .share-url input, input[readonly] {
width: 100%; background: var(--bg3); border: 1px solid var(--border);
border-radius: 6px; padding: 14px; font-family: var(--mono); font-size: 14px; color: var(--fg);
} }
textarea { min-height: 300px; resize: vertical; }
textarea:focus, .share-url input:focus { outline: none; border-color: var(--accent); } body {
textarea::placeholder { color: var(--dim); } font-family: system-ui, sans-serif;
.paste-content pre { font-family: var(--mono); font-size: 14px; line-height: 1.6; white-space: pre-wrap; word-wrap: break-word; margin: 0; } background: var(--bg);
.actions { margin-top: 24px; display: flex; justify-content: flex-end; gap: 12px; } color: var(--fg);
.actions.center { justify-content: center; } min-height: 100vh;
button, .btn-primary { line-height: 1.6;
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;
h2 {
font-size: 1.5rem;
margin-bottom: 16px;
}
.container {
max-width: 900px;
margin: 0 auto;
padding: 40px 20px;
}
header {
text-align: center;
margin-bottom: 40px;
}
.logo {
font-size: 2.5rem;
font-weight: 700;
color: var(--accent);
}
.tagline {
color: var(--dim);
margin-top: 8px;
}
.card {
background: var(--bg2);
border: 1px solid var(--border);
border-radius: 12px;
padding: 32px;
}
textarea,
.paste-content,
.share-url input,
input[readonly] {
width: 100%;
background: var(--bg3);
border: 1px solid var(--border);
border-radius: 6px;
padding: 14px;
font-family: var(--mono);
font-size: 14px;
color: var(--fg);
}
textarea {
min-height: 300px;
resize: vertical;
}
textarea::placeholder {
color: var(--dim);
}
textarea:focus,
.share-url input:focus {
outline: none;
border-color: var(--accent);
}
.share-url {
margin-bottom: 24px;
display: flex;
flex-direction: column;
gap: 8px;
}
.share-url label {
color: var(--dim);
font-size: 14px;
font-weight: 500;
}
.paste-content pre {
font-family: var(--mono);
font-size: 14px;
line-height: 1.6;
white-space: pre-wrap;
word-wrap: break-word;
margin: 0;
}
.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);
}
.hidden {
display: none;
}
#loading {
text-align: center;
padding: 40px;
color: var(--dim);
}
.mascot {
display: inline-flex;
vertical-align: middle;
margin-left: 4px;
}
.mascot svg {
height: 1em;
width: auto;
display: 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 #d29922; border-radius: 6px; padding: 16px; margin-bottom: 24px; }
.notice strong { color: #d29922; }
.notice.warning { background: rgba(248,81,73,.15); border-color: #f85149; }
.notice.warning strong { color: #f85149; }
.error { background: rgba(248,81,73,.15); border: 1px solid #f85149; border-radius: 6px; padding: 16px; color: #f85149; }
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 .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); }
.centered p { color: var(--dim); margin-bottom: 24px; }
h2 { font-size: 1.5rem; margin-bottom: 16px; }
.share-url { margin-bottom: 24px; display: flex; flex-direction: column; gap: 8px; }
.share-url label { color: var(--dim); font-size: 14px; font-weight: 500; }
#loading { text-align: center; padding: 40px; color: var(--dim); }
.hidden { display: none; }
.mascot { display: inline-flex; vertical-align: middle; margin-left: 4px; }
.mascot svg { height: 1em; width: auto; display: block; }
" "