Compare commits

...

44 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
9e21f6138d first sources: batman 2026-03-01 08:50:06 +01:00
15a6e5ff5e fix: get rid of the flow chart 2026-03-01 08:49:55 +01:00
f63b4394c5 fix: avoid using quotation marks in the description, even escaped one 2026-03-01 08:49:10 +01:00
136283b901 fix: I should have had more coffee. 2026-03-01 08:44:38 +01:00
20cf357195 docs: update readme 2026-03-01 08:43:43 +01:00
d7256ef7fe docs: update readme 2026-03-01 08:43:04 +01:00
3235f4613e test: we should write some 2026-03-01 08:40:41 +01:00
8a4674ec61 gleam: add manifest 2026-03-01 08:40:35 +01:00
ecfff3204a feat: add gleam.toml 2026-03-01 08:40:30 +01:00
16 changed files with 998 additions and 37 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
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
@@ -11,18 +13,15 @@ A secure, self-hostable "burn after reading" paste service with ephemeral storag
- Fast and reliable
- Written in Gleam (type-safe)
## Flowchart
## Architecture
```mermaid
graph TD
A[User creates paste] --> B[Paste is stored in memory]
B --> C[Paste is deleted after first view]
C --> D[User shares paste URL]
D --> E[User views paste]
E --> F[Paste is deleted]
```
Note: the creator can now see their post - it will be burned immediately.
| 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
@@ -52,7 +51,9 @@ For development, you can use:
SECRET_KEY_BASE=dev gleam run
```
## Quick Start
## How to run
### Development
```bash
# Clone and build
@@ -61,37 +62,38 @@ cd spasteg
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
1. Visit `http://localhost:3000`
1. Visit <http://localhost:3000>
2. Enter your text in the form
3. Click "Create Paste"
4. Share the generated URL
5. The paste auto-destructs after first access
## 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
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.

22
gleam.toml Normal file
View File

@@ -0,0 +1,22 @@
name = "spasteg"
version = "1.0.0"
description = "A secure self-hostable burn-after-reading paste service written in Gleam"
licences = ["GPL-3.0"]
repository = { type = "gitea", user = "Kharec", repo = "spasteg", host = "https://git.kharec.info" }
[dependencies]
gleam_stdlib = ">= 0.44.0 and < 2.0.0"
gleam_crypto = ">= 1.5.1 and < 2.0.0"
wisp = ">= 1.0.0"
mist = ">= 2.0.0"
gleam_otp = ">= 0.10.0"
gleam_http = ">= 3.5.0"
gleam_erlang = ">= 1.0.0"
gleam_json = ">= 2.0.0"
lustre = ">= 4.0.0"
logging = ">= 1.3.0 and < 2.0.0"
envoy = ">= 1.1.0 and < 2.0.0"
[dev-dependencies]
gleeunit = ">= 1.0.0 and < 2.0.0"

43
manifest.toml Normal file
View File

@@ -0,0 +1,43 @@
# This file was generated by Gleam
# You typically do not need to edit this file
packages = [
{ name = "directories", version = "1.2.0", build_tools = ["gleam"], requirements = ["envoy", "gleam_stdlib", "platform", "simplifile"], otp_app = "directories", source = "hex", outer_checksum = "D13090CFCDF6759B87217E8DDD73A75903A700148A82C1D33799F333E249BF9E" },
{ name = "envoy", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "envoy", source = "hex", outer_checksum = "850DA9D29D2E5987735872A2B5C81035146D7FE19EFC486129E44440D03FD832" },
{ name = "exception", version = "2.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "exception", source = "hex", outer_checksum = "329D269D5C2A314F7364BD2711372B6F2C58FA6F39981572E5CA68624D291F8C" },
{ name = "filepath", version = "1.1.2", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "filepath", source = "hex", outer_checksum = "B06A9AF0BF10E51401D64B98E4B627F1D2E48C154967DA7AF4D0914780A6D40A" },
{ name = "gleam_crypto", version = "1.5.1", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_crypto", source = "hex", outer_checksum = "50774BAFFF1144E7872814C566C5D653D83A3EBF23ACC3156B757A1B6819086E" },
{ name = "gleam_erlang", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_erlang", source = "hex", outer_checksum = "1124AD3AA21143E5AF0FC5CF3D9529F6DB8CA03E43A55711B60B6B7B3874375C" },
{ name = "gleam_http", version = "4.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_http", source = "hex", outer_checksum = "82EA6A717C842456188C190AFB372665EA56CE13D8559BF3B1DD9E40F619EE0C" },
{ name = "gleam_json", version = "3.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_json", source = "hex", outer_checksum = "44FDAA8847BE8FC48CA7A1C089706BD54BADCC4C45B237A992EDDF9F2CDB2836" },
{ name = "gleam_otp", version = "1.2.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_stdlib"], otp_app = "gleam_otp", source = "hex", outer_checksum = "BA6A294E295E428EC1562DC1C11EA7530DCB981E8359134BEABC8493B7B2258E" },
{ name = "gleam_stdlib", version = "0.69.0", build_tools = ["gleam"], requirements = [], otp_app = "gleam_stdlib", source = "hex", outer_checksum = "AAB0962BEBFAA67A2FBEE9EEE218B057756808DC9AF77430F5182C6115B3A315" },
{ name = "gleam_yielder", version = "1.1.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleam_yielder", source = "hex", outer_checksum = "8E4E4ECFA7982859F430C57F549200C7749823C106759F4A19A78AEA6687717A" },
{ name = "gleeunit", version = "1.9.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "gleeunit", source = "hex", outer_checksum = "DA9553CE58B67924B3C631F96FE3370C49EB6D6DC6B384EC4862CC4AAA718F3C" },
{ name = "glisten", version = "8.0.3", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_otp", "gleam_stdlib", "logging", "telemetry"], otp_app = "glisten", source = "hex", outer_checksum = "86B838196592D9EBDE7A1D2369AE3A51E568F7DD2D168706C463C42D17B95312" },
{ name = "gramps", version = "6.0.0", build_tools = ["gleam"], requirements = ["gleam_crypto", "gleam_erlang", "gleam_http", "gleam_stdlib"], otp_app = "gramps", source = "hex", outer_checksum = "8B7195978FBFD30B43DF791A8A272041B81E45D245314D7A41FC57237AA882A0" },
{ name = "houdini", version = "1.2.0", build_tools = ["gleam"], requirements = [], otp_app = "houdini", source = "hex", outer_checksum = "5DB1053F1AF828049C2B206D4403C18970ABEF5C18671CA3C2D2ED0DD64F6385" },
{ name = "hpack_erl", version = "0.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "hpack", source = "hex", outer_checksum = "D6137D7079169D8C485C6962DFE261AF5B9EF60FBC557344511C1E65E3D95FB0" },
{ name = "logging", version = "1.3.0", build_tools = ["gleam"], requirements = ["gleam_stdlib"], otp_app = "logging", source = "hex", outer_checksum = "1098FBF10B54B44C2C7FDF0B01C1253CAFACDACABEFB4B0D027803246753E06D" },
{ name = "lustre", version = "5.6.0", build_tools = ["gleam"], requirements = ["gleam_erlang", "gleam_json", "gleam_otp", "gleam_stdlib", "houdini"], otp_app = "lustre", source = "hex", outer_checksum = "EE558CD4DB9F09FCC16417ADF0183A3C2DAC3E4B21ED3AC0CAE859792AB810CA" },
{ name = "marceau", version = "1.3.0", build_tools = ["gleam"], requirements = [], otp_app = "marceau", source = "hex", outer_checksum = "2D1C27504BEF45005F5DFB18591F8610FB4BFA91744878210BDC464412EC44E9" },
{ name = "mist", version = "5.0.4", build_tools = ["gleam"], requirements = ["exception", "gleam_erlang", "gleam_http", "gleam_otp", "gleam_stdlib", "gleam_yielder", "glisten", "gramps", "hpack_erl", "logging"], otp_app = "mist", source = "hex", outer_checksum = "7CED4B2D81FD547ADB093D97B9928B9419A7F58B8562A30A6CC17A252B31AD05" },
{ name = "platform", version = "1.0.0", build_tools = ["gleam"], requirements = [], otp_app = "platform", source = "hex", outer_checksum = "8339420A95AD89AAC0F82F4C3DB8DD401041742D6C3F46132A8739F6AEB75391" },
{ name = "simplifile", version = "2.3.2", build_tools = ["gleam"], requirements = ["filepath", "gleam_stdlib"], otp_app = "simplifile", source = "hex", outer_checksum = "E049B4DACD4D206D87843BCF4C775A50AE0F50A52031A2FFB40C9ED07D6EC70A" },
{ name = "telemetry", version = "1.3.0", build_tools = ["rebar3"], requirements = [], otp_app = "telemetry", source = "hex", outer_checksum = "7015FC8919DBE63764F4B4B87A95B7C0996BD539E0D499BE6EC9D7F3875B79E6" },
{ name = "wisp", version = "2.2.0", build_tools = ["gleam"], requirements = ["directories", "exception", "filepath", "gleam_crypto", "gleam_erlang", "gleam_http", "gleam_json", "gleam_stdlib", "houdini", "logging", "marceau", "mist", "simplifile"], otp_app = "wisp", source = "hex", outer_checksum = "655163D4DE19E3DD4AC75813A991BFD5523CB4FF2FC5F9F58FD6FB39D5D1806D" },
]
[requirements]
envoy = { version = ">= 1.1.0 and < 2.0.0" }
gleam_crypto = { version = ">= 1.5.1 and < 2.0.0" }
gleam_erlang = { version = ">= 1.0.0" }
gleam_http = { version = ">= 3.5.0" }
gleam_json = { version = ">= 2.0.0" }
gleam_otp = { version = ">= 0.10.0" }
gleam_stdlib = { version = ">= 0.44.0 and < 2.0.0" }
gleeunit = { version = ">= 1.0.0 and < 2.0.0" }
logging = { version = ">= 1.3.0 and < 2.0.0" }
lustre = { version = ">= 4.0.0" }
mist = { version = ">= 2.0.0" }
wisp = { version = ">= 1.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

162
src/handlers.gleam Normal file
View File

@@ -0,0 +1,162 @@
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 {
list.key_find(request.headers, "x-forwarded-for")
|> result.try_recover(fn(_) { list.key_find(request.headers, "x-real-ip") })
|> result.unwrap("unknown")
}
pub fn handle(
request: wisp.Request,
storage: process.Subject(storage.StorageMsg),
_secret_key_base: String,
) -> wisp.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
}
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 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_form = list.key_find(form.values, "csrf_token")
case csrf_cookie, csrf_form {
Ok(cookie_token), Ok(form_token) if cookie_token == form_token -> Ok(Nil)
_, _ -> 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()
process.send(storage, storage.CheckRateLimit(ip, rate_reply))
case process.receive(rate_reply, 1000) {
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 =
list.key_find(form.values, "encrypted_content")
|> result.unwrap("")
case string.length(encrypted_content) {
0 -> Error(wisp.bad_request("Missing content"))
n if n > 10_000_000 -> Error(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) -> Ok(new_key)
_ ->
Error(
wisp.internal_server_error()
|> wisp.html_body("Failed to create paste"),
)
}
}
}
}
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])
}
}

259
src/html.gleam Normal file
View File

@@ -0,0 +1,259 @@
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("Beware")]),
text(" — This paste will be burned 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 content has already been burned 🔥"),
]),
html.a([attribute.href("/"), attribute.class("btn-primary")], [
text("Create New Paste"),
]),
]),
]),
])
}
fn decrypt_js(encrypted_content: String) -> String {
"
(async () => {
const encryptedContent = '" <> escape_js_string(encrypted_content) <> "';
const hash = location.hash.slice(1);
const els = {
loading: document.getElementById('loading'),
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) {
els.loading.classList.add('hidden');
els.error.classList.remove('hidden');
return;
}
try {
let keyBase64 = hash.replace(/-/g, '+').replace(/_/g, '/');
while (keyBase64.length % 4) keyBase64 += '=';
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 key = await crypto.subtle.importKey('raw', keyBytes, { name: 'AES-GCM' }, false, ['decrypt']);
const decrypted = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, key, encryptedBytes.slice(12));
els.content.textContent = new TextDecoder().decode(decrypted);
els.loading.classList.add('hidden');
els.error.classList.add('hidden');
els.content.classList.remove('hidden');
} catch (e) {
console.error('Decryption failed:', e);
els.loading.classList.add('hidden');
els.error.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")
|> string.replace("<", "\\u003c")
|> string.replace(">", "\\u003e")
|> string.replace("&", "\\u0026")
}
fn crypto_js() -> String {
"
async function encryptContent(content) {
try {
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 }, key, data);
const keyData = await crypto.subtle.exportKey('raw', key);
return {
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) {
return base64.split('+').join('-').split('/').join('_').replace(/=+$/g, '');
}
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();
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);
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;
}
});
"
}

10
src/key.gleam Normal file
View File

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

73
src/layout.gleam Normal file
View 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,
),
])
}

37
src/spasteg.gleam Normal file
View File

@@ -0,0 +1,37 @@
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.bind("0.0.0.0")
|> mist.port(3000)
|> mist.start
process.sleep_forever()
}

64
src/storage.gleam Normal file
View File

@@ -0,0 +1,64 @@
import gleam/dict.{type Dict}
import gleam/erlang/process
import gleam/option.{type Option}
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)))
CheckRateLimit(ip: String, reply: process.Subject(Bool))
}
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) {
case msg {
CreatePaste(key, content, reply) -> {
let new_pastes =
state.pastes
|> dict.insert(key, content)
process.send(reply, True)
actor.continue(StorageState(new_pastes, state.rate_limits))
}
GetPaste(key, reply) -> {
let content = lookup_paste(state, key)
let new_pastes =
state.pastes
|> dict.delete(key)
process.send(reply, content)
actor.continue(StorageState(new_pastes, state.rate_limits))
}
CheckRateLimit(ip, reply) -> {
let count =
state.rate_limits
|> dict.get(ip)
|> option.from_result
|> option.unwrap(0)
let allowed = count < max_requests_per_minute
let new_limits = case allowed {
True ->
state.rate_limits
|> dict.insert(ip, count + 1)
False -> state.rate_limits
}
process.send(reply, allowed)
actor.continue(StorageState(state.pastes, new_limits))
}
}
}
pub fn start() {
actor.new(StorageState(dict.new(), dict.new()))
|> actor.on_message(handle_message)
|> actor.start
}

241
src/styles.gleam Normal file
View File

@@ -0,0 +1,241 @@
// this css is AI-generated, sorry, really not a front guy
pub const shared_css = "
:root {
--bg: #0d1117;
--bg2: #161b22;
--bg3: #21262d;
--fg: #e6edf3;
--dim: #8b949e;
--accent: #58a6ff;
--green: #238636;
--border: #30363d;
--warning: #d29922;
--error: #f85149;
--mono: 'JetBrains Mono', monospace;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: system-ui, sans-serif;
background: var(--bg);
color: var(--fg);
min-height: 100vh;
line-height: 1.6;
}
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;
}
"

26
test/spasteg_test.gleam Normal file
View File

@@ -0,0 +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
}