Compare commits
46 Commits
dd42a7524d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 53ab8ef297 | |||
| c51860c2b1 | |||
| 51a5242219 | |||
| baa09c853c | |||
| 7b2e7a7824 | |||
| de72a9de4b | |||
| 26291f7d28 | |||
| 87f98d914d | |||
| f5f5167af3 | |||
| 613594ebf6 | |||
| a23498a0ca | |||
| cb9c9369ba | |||
| 12b6dde893 | |||
| d4c09afc94 | |||
| 80fcf29109 | |||
| 898b582c3b | |||
| 35e015d1e0 | |||
| cdaac1b35d | |||
| 02e28aba4a | |||
| 7244b9bcc5 | |||
| 7ebde59800 | |||
| cf492f9798 | |||
| 53dd416c17 | |||
| 31da68b6c1 | |||
| 2ecfda256b | |||
| ce1fa0b801 | |||
| 3bb857ec19 | |||
| c1a9c2d22f | |||
| 25be9c8a1b | |||
| 77fbc2e0bb | |||
| c60900efb5 | |||
| 891107c442 | |||
| 0de588b2d6 | |||
| 2b24d18df2 | |||
| 4602cb2af1 | |||
| 9e21f6138d | |||
| 15a6e5ff5e | |||
| f63b4394c5 | |||
| 136283b901 | |||
| 20cf357195 | |||
| d7256ef7fe | |||
| 3235f4613e | |||
| 8a4674ec61 | |||
| ecfff3204a | |||
| dae0245565 | |||
| be8889ac85 |
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
*.beam
|
||||
*.ez
|
||||
/build
|
||||
erl_crash.dump
|
||||
22
Dockerfile
Normal file
22
Dockerfile
Normal 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"]
|
||||
101
README.md
101
README.md
@@ -1,17 +1,59 @@
|
||||
# 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
|
||||
|
||||
- **Ephemeral pastes** — Content is deleted immediately after first view
|
||||
- **No persistence** — All data stored in-memory only (restarts clear everything)
|
||||
- **No accounts** — Zero-friction sharing without registration
|
||||
- **Self-hosted** — Run your own instance with full control
|
||||
- **Fast & lightweight** — Built on the BEAM VM for reliability and performance
|
||||
- **Type-safe** — Written in Gleam, a type-safe functional language
|
||||
- Deletes pastes after they are viewed
|
||||
- Data is stored only in memory
|
||||
- No user accounts needed
|
||||
- Easy to run yourself
|
||||
- Fast and reliable
|
||||
- Written in Gleam (type-safe)
|
||||
|
||||
## Quick Start
|
||||
## 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
|
||||
|
||||
### SECRET_KEY_BASE (Required for Production)
|
||||
|
||||
The application uses a secret key base for signing cookies and security tokens.
|
||||
|
||||
You **must** set this for production deployments to ensure security and session persistence across restarts.
|
||||
|
||||
```bash
|
||||
# Generate a secure key (48 bytes of random data)
|
||||
export SECRET_KEY_BASE=$(openssl rand -base64 48)
|
||||
|
||||
# Or set it manually
|
||||
export SECRET_KEY_BASE="your-secret-key-here"
|
||||
```
|
||||
|
||||
**⚠️ Warning**: If not set, a temporary key will be generated on each startup. This:
|
||||
|
||||
- Invalidates all existing user sessions/cookies after restart
|
||||
- Reduces security (new key generated each time)
|
||||
- Generates a warning in the logs
|
||||
|
||||
For development, you can use:
|
||||
|
||||
```bash
|
||||
SECRET_KEY_BASE=dev gleam run
|
||||
```
|
||||
|
||||
## How to run
|
||||
|
||||
### Development
|
||||
|
||||
```bash
|
||||
# Clone and build
|
||||
@@ -20,31 +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
|
||||
|
||||
- **Web framework**: Wisp (Gleam's web framework)
|
||||
- **HTTP server**: Mist (HTTP/1.1 & HTTP/2)
|
||||
- **HTML rendering**: Lustre (type-safe, declarative HTML generation)
|
||||
- **Storage**: In-memory OTP actor (no disk persistence)
|
||||
- **Key generation**: Cryptographically random 16-character keys
|
||||
|
||||
## Security Notes
|
||||
|
||||
- Pastes are stored **unencrypted** in server memory
|
||||
- Data is **never written to disk**
|
||||
- All data is lost on server restart
|
||||
- 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
22
gleam.toml
Normal 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
43
manifest.toml
Normal 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" }
|
||||
BIN
screenshots/content-burned-view.png
Normal file
BIN
screenshots/content-burned-view.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 38 KiB |
BIN
screenshots/get-content-view.png
Normal file
BIN
screenshots/get-content-view.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 21 KiB |
BIN
screenshots/main-view.png
Normal file
BIN
screenshots/main-view.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 36 KiB |
BIN
screenshots/pasted-view.png
Normal file
BIN
screenshots/pasted-view.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 90 KiB |
162
src/handlers.gleam
Normal file
162
src/handlers.gleam
Normal 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
259
src/html.gleam
Normal 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
10
src/key.gleam
Normal 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
73
src/layout.gleam
Normal file
@@ -0,0 +1,73 @@
|
||||
import gleam/option.{type Option, None, Some}
|
||||
|
||||
import lustre/attribute
|
||||
import lustre/element.{type Element, none, text}
|
||||
import lustre/element/html
|
||||
|
||||
import styles
|
||||
|
||||
pub fn page(title: String, children: List(Element(a))) -> String {
|
||||
html.html([attribute.lang("en")], [
|
||||
html.head([], [
|
||||
html.meta([attribute.charset("UTF-8")]),
|
||||
html.meta([
|
||||
attribute.name("viewport"),
|
||||
attribute.content("width=device-width, initial-scale=1.0"),
|
||||
]),
|
||||
html.title([], title),
|
||||
html.link([
|
||||
attribute.rel("preconnect"),
|
||||
attribute.href("https://fonts.googleapis.com"),
|
||||
]),
|
||||
html.link([
|
||||
attribute.rel("preconnect"),
|
||||
attribute.href("https://fonts.gstatic.com"),
|
||||
attribute.attribute("crossorigin", ""),
|
||||
]),
|
||||
html.link([
|
||||
attribute.rel("stylesheet"),
|
||||
attribute.href(
|
||||
"https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap",
|
||||
),
|
||||
]),
|
||||
html.style([], styles.shared_css),
|
||||
]),
|
||||
html.body([], [
|
||||
html.div([attribute.class("container")], children),
|
||||
]),
|
||||
])
|
||||
|> element.to_document_string
|
||||
}
|
||||
|
||||
pub fn logo() -> Element(a) {
|
||||
html.h1([attribute.class("logo")], [text("spasteg")])
|
||||
}
|
||||
|
||||
pub fn card(children: List(Element(a))) -> Element(a) {
|
||||
html.div([attribute.class("card")], children)
|
||||
}
|
||||
|
||||
pub fn header(subtitle: Option(String)) -> Element(a) {
|
||||
html.header([], [
|
||||
logo(),
|
||||
case subtitle {
|
||||
Some(text_content) ->
|
||||
html.p([attribute.class("tagline")], [text(text_content)])
|
||||
None -> none()
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
pub fn footer() -> Element(a) {
|
||||
let lucy_svg =
|
||||
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 2105 2016' fill='none' class='lucy'><path fill='#FFAFF3' d='M842.026 129.177C870.114 49.5833 974.531 31.1719 1028.15 96.3575L1309.17 438.02C1343.94 480.296 1395.4 505.347 1450.14 506.68L1892.78 517.45C1977.29 519.505 2026.92 612.947 1981.45 683.983L1742.87 1056.65C1728.28 1079.43 1718.77 1105.09 1715 1131.88C1711.22 1158.66 1713.27 1185.95 1721 1211.87L1847.34 1635.7C1871.42 1716.45 1797.88 1792.71 1716.03 1771.44L1287.55 1660.11C1261.36 1653.3 1234.01 1652.21 1207.36 1656.91C1180.72 1661.6 1155.39 1671.98 1133.11 1687.34L768.557 1938.51C698.913 1986.48 603.729 1939.98 598.726 1855.86L572.502 1414.38C569.257 1359.74 542.373 1309.24 498.866 1276.01L147.203 1007.41C80.1767 956.216 94.8588 851.43 173.566 820.594L585.833 659.08C636.813 639.107 676.599 597.967 694.813 546.348L842.026 129.177Z'/><path fill='#151515' d='M918.91 20.3875C868.969 29.1948 823.32 62.3526 804.42 115.904L657.186 533.07C642.831 573.741 611.52 606.123 571.327 621.871L159.044 783.395C53.2498 824.843 32.7918 970.356 122.981 1039.24L474.644 1307.81C491.576 1320.73 505.522 1337.15 515.528 1355.96C525.534 1374.76 531.366 1395.5 532.625 1416.76L558.835 1858.23C565.559 1971.49 697.668 2035.92 791.287 1971.44L791.289 1971.43L1155.87 1720.26L1155.87 1720.25C1173.42 1708.15 1193.38 1699.97 1214.37 1696.27C1235.37 1692.57 1256.92 1693.43 1277.55 1698.8L1277.55 1698.8L1706.04 1810.11C1816.06 1838.7 1918.17 1732.96 1885.75 1624.22L1885.75 1624.23L1759.42 1200.41L1759.42 1200.41C1753.33 1180 1751.72 1158.52 1754.69 1137.42C1757.66 1116.33 1765.15 1096.13 1776.65 1078.2L1776.65 1078.19L2015.25 705.513L2015.24 705.511C2076.44 609.933 2007.46 480.197 1893.87 477.434L1451.21 466.681C1408.06 465.633 1367.56 445.914 1340.17 412.608L1059.15 70.9554C1023.08 27.1014 968.841 11.5585 918.896 20.3665M932.34 96.6134C955.035 92.6111 979.819 100.451 997.365 121.779L1278.38 463.422C1320.52 514.663 1382.95 545.042 1449.26 546.655L1891.92 557.408C1947.35 558.754 1977.63 615.896 1947.86 662.382L1709.27 1035.06C1673.48 1090.93 1663.8 1159.69 1682.74 1223.26L1809.08 1647.07C1824.81 1699.85 1779.83 1746.62 1726.15 1732.68L1726.15 1732.68L1297.66 1621.36C1233.45 1604.67 1165.09 1616.73 1110.46 1654.38L745.884 1905.55C700.205 1937.02 641.945 1908.45 638.676 1853.48L612.469 1412.01C608.537 1345.78 575.914 1284.5 523.186 1244.22L171.524 975.65C127.662 942.151 136.58 878.1 188.203 857.867L600.485 696.341C662.256 672.139 710.525 622.24 732.608 559.671L732.608 559.669L879.842 142.504C889.034 116.463 909.64 100.618 932.335 96.6154'/></svg>"
|
||||
html.footer([], [
|
||||
text("Proudly made with Gleam "),
|
||||
element.unsafe_raw_html(
|
||||
"http://www.w3.org/1999/xhtml",
|
||||
"span",
|
||||
[attribute.class("mascot")],
|
||||
lucy_svg,
|
||||
),
|
||||
])
|
||||
}
|
||||
37
src/spasteg.gleam
Normal file
37
src/spasteg.gleam
Normal 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
64
src/storage.gleam
Normal 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
241
src/styles.gleam
Normal 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
26
test/spasteg_test.gleam
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user