Compare commits

..

25 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
12 changed files with 378 additions and 111 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 IP (resets on server restart)
- 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

@@ -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,11 +163,15 @@ 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({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']); const key = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, ['encrypt', 'decrypt']);
@@ -168,36 +179,60 @@ async function encryptContent(content) {
const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data); const encrypted = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, key, data);
const keyData = await crypto.subtle.exportKey('raw', key); const keyData = await crypto.subtle.exportKey('raw', key);
return { return {
encrypted: btoa(String.fromCharCode(...new Uint8Array([...iv, ...new Uint8Array(encrypted)]))), encrypted: btoa(String.fromCharCode(...iv, ...new Uint8Array(encrypted))),
key: btoa(String.fromCharCode(...new Uint8Array(keyData))) 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 submitButton = e.target.querySelector('button[type=\"submit\"]');
const contentInput = document.getElementById('content');
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); const { encrypted, key } = await encryptContent(content);
document.getElementById('encrypted-content').value = encrypted; encryptedInput.value = encrypted;
const csrfToken = document.getElementById('csrf-token').value; const csrfToken = csrfInput.value;
const res = await fetch('/', { const res = await fetch('/', {
method: 'POST', method: 'POST',
@@ -208,12 +243,17 @@ document.getElementById('paste-form').addEventListener('submit', async (e) => {
const html = await res.text(); const html = await res.text();
const doc = new DOMParser().parseFromString(html, 'text/html'); const doc = new DOMParser().parseFromString(html, 'text/html');
const pasteId = doc.querySelector('[data-paste-id]')?.getAttribute('data-paste-id'); const pasteId = doc.querySelector('[data-paste-id]')?.getAttribute('data-paste-id');
if (!pasteId) return; if (!pasteId) throw new Error('No paste ID returned');
const pasteUrl = `${location.origin}/paste/${pasteId}#${base64ToUrlSafe(key)}`; const pasteUrl = `${location.origin}/paste/${pasteId}#${base64ToUrlSafe(key)}`;
document.body.replaceChildren(...doc.body.childNodes); document.body.replaceChildren(...doc.body.childNodes);
showShareUrl(pasteUrl); showShareUrl(pasteUrl);
history.replaceState({}, '', 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

@@ -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,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; }
" "