WIP
This commit is contained in:
17 files changed
+670
-46
No files matched your search
@@ -1 +1,5 @@
|
|||||||
/target
|
/target
|
||||||
|
/pastable.db
|
||||||
|
/pastable.db-shm
|
||||||
|
/pastable.db-wal
|
||||||
|
/pastable.db-journal
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
# Rules
|
||||||
|
|
||||||
|
- Write clean, idomatic rust
|
||||||
|
- Prevent dependencies if possible
|
||||||
|
- Do not repeat yourself
|
||||||
|
- Don't write code we don't need
|
||||||
|
- Don't write tests. Rust is good.
|
||||||
|
- Write readable code
|
||||||
|
- Be a minimalist.
|
||||||
Generated
+144
-2
@@ -17,6 +17,18 @@ version = "0.2.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "argon2"
|
||||||
|
version = "0.6.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"blake2",
|
||||||
|
"cpufeatures 0.3.1",
|
||||||
|
"password-hash",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "askama"
|
name = "askama"
|
||||||
version = "0.16.1"
|
version = "0.16.1"
|
||||||
@@ -70,6 +82,30 @@ dependencies = [
|
|||||||
"winnow",
|
"winnow",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "askama_web"
|
||||||
|
version = "0.16.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "33d7972c9dc206f9832a16dbba72d589ce8cc313732bd82db36638e70a0b222c"
|
||||||
|
dependencies = [
|
||||||
|
"askama",
|
||||||
|
"askama_web_derive",
|
||||||
|
"axum-core",
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "askama_web_derive"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940074e50ab8a902980dec23d265697dd1e8789e66be664d183dbb24c79b8e7c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "atoi"
|
name = "atoi"
|
||||||
version = "2.0.0"
|
version = "2.0.0"
|
||||||
@@ -149,6 +185,18 @@ version = "0.22.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.23.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64ct"
|
||||||
|
version = "1.8.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "basic-toml"
|
name = "basic-toml"
|
||||||
version = "0.1.10"
|
version = "0.1.10"
|
||||||
@@ -167,6 +215,15 @@ dependencies = [
|
|||||||
"serde_core",
|
"serde_core",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "blake2"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f"
|
||||||
|
dependencies = [
|
||||||
|
"digest 0.11.3",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "block-buffer"
|
name = "block-buffer"
|
||||||
version = "0.10.4"
|
version = "0.10.4"
|
||||||
@@ -613,6 +670,12 @@ dependencies = [
|
|||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-range-header"
|
||||||
|
version = "0.4.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "httparse"
|
name = "httparse"
|
||||||
version = "1.10.1"
|
version = "1.10.1"
|
||||||
@@ -887,6 +950,16 @@ version = "0.3.18"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d5848f5a9e8117cf94f2f90faac24a87ff81600ff8628c1e3deaab572e76bfa1"
|
checksum = "d5848f5a9e8117cf94f2f90faac24a87ff81600ff8628c1e3deaab572e76bfa1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "mime_guess"
|
||||||
|
version = "2.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||||
|
dependencies = [
|
||||||
|
"mime",
|
||||||
|
"unicase",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mio"
|
name = "mio"
|
||||||
version = "1.2.5"
|
version = "1.2.5"
|
||||||
@@ -942,16 +1015,30 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "password-hash"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b"
|
||||||
|
dependencies = [
|
||||||
|
"getrandom",
|
||||||
|
"phc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pastable"
|
name = "pastable"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
|
"argon2",
|
||||||
"askama",
|
"askama",
|
||||||
|
"askama_web",
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64 0.23.1",
|
||||||
"irosashi",
|
"irosashi",
|
||||||
"serde",
|
"serde",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"tokio",
|
"tokio",
|
||||||
|
"tower-http",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -960,6 +1047,17 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "phc"
|
||||||
|
version = "0.6.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892"
|
||||||
|
dependencies = [
|
||||||
|
"base64ct",
|
||||||
|
"ctutils",
|
||||||
|
"getrandom",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "pin-project-lite"
|
name = "pin-project-lite"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@@ -1226,7 +1324,7 @@ version = "0.9.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
|
checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bytes",
|
"bytes",
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"crc",
|
"crc",
|
||||||
@@ -1326,7 +1424,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
|
checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"atoi",
|
"atoi",
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"byteorder",
|
"byteorder",
|
||||||
"crc",
|
"crc",
|
||||||
@@ -1507,6 +1605,19 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-util"
|
||||||
|
version = "0.7.20"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"futures-sink",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower"
|
name = "tower"
|
||||||
version = "0.5.3"
|
version = "0.5.3"
|
||||||
@@ -1523,6 +1634,31 @@ dependencies = [
|
|||||||
"tracing",
|
"tracing",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-http"
|
||||||
|
version = "0.7.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"bytes",
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"http-body-util",
|
||||||
|
"http-range-header",
|
||||||
|
"httpdate",
|
||||||
|
"mime",
|
||||||
|
"mime_guess",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"tokio",
|
||||||
|
"tokio-util",
|
||||||
|
"tower-layer",
|
||||||
|
"tower-service",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower-layer"
|
name = "tower-layer"
|
||||||
version = "0.3.3"
|
version = "0.3.3"
|
||||||
@@ -1573,6 +1709,12 @@ version = "1.20.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicase"
|
||||||
|
version = "2.10.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicode-bidi"
|
name = "unicode-bidi"
|
||||||
version = "0.3.18"
|
version = "0.3.18"
|
||||||
|
|||||||
+5
-1
@@ -4,9 +4,13 @@ version = "0.1.0"
|
|||||||
edition = "2024"
|
edition = "2024"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
askama = "0.16.1"
|
argon2 = "0.6.0"
|
||||||
|
askama = { version = "0.16.1" }
|
||||||
|
askama_web = { version = "0.16.0", features = ["axum-0.8"] }
|
||||||
axum = "0.8.9"
|
axum = "0.8.9"
|
||||||
|
base64 = "0.23.1"
|
||||||
irosashi = "0.3.1"
|
irosashi = "0.3.1"
|
||||||
serde = { version = "1.0.229", features = ["derive"] }
|
serde = { version = "1.0.229", features = ["derive"] }
|
||||||
sqlx = { version = "0.9.0", features = ["runtime-tokio", "sqlite"] }
|
sqlx = { version = "0.9.0", features = ["runtime-tokio", "sqlite"] }
|
||||||
tokio = { version = "1.54.1", features = ["rt-multi-thread"] }
|
tokio = { version = "1.54.1", features = ["rt-multi-thread"] }
|
||||||
|
tower-http = { version = "0.7.1", features = ["fs"] }
|
||||||
@@ -1,26 +1,153 @@
|
|||||||
# Pastable
|
# Pastable
|
||||||
|
|
||||||
Pastable is a free and open-source Plabble initiative to Pastebin.
|
Pastable is a free and open-source Plabble initiative for sharing text, code, short links, and one-time secrets.
|
||||||
|
|
||||||
|
## Run locally
|
||||||
|
|
||||||
|
With Rust and Cargo installed:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
The server listens on `0.0.0.0:3000` and is available locally at `http://localhost:3000`.
|
||||||
|
|
||||||
|
On startup, the server creates the SQLite database and initializes its tables before accepting requests.
|
||||||
|
The default database is `pastable.db` in the working directory. Set `DATABASE_URL` to use a different location:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
DATABASE_URL=sqlite://data.db cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
Route handlers can access the shared database pool with the `State(pool): State<SqlitePool>` extractor.
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
- Paste text and code
|
|
||||||
- Optional expiration
|
|
||||||
- Self destruct option (view N times)
|
|
||||||
- URL shortener
|
|
||||||
- One-time password share
|
|
||||||
|
|
||||||
## Routes
|
- Share text and code, with optional syntax highlighting.
|
||||||
|
- Set an expiration time or a maximum number of views.
|
||||||
|
- Share a secret that can only be viewed once.
|
||||||
|
- Create short links.
|
||||||
|
- Use HTTP Basic authentication to associate pastes with a user, update them, and list them.
|
||||||
|
|
||||||
- GET `/` - Home page with explanation
|
## API
|
||||||
- GET `/:id` - based on Accept header, syntax-highlighted HTML or plain text
|
|
||||||
- POST `/` - create a new paste (default no expiration and infinite views), plain text raw body.
|
All paths are relative to the server URL. `{id}` is a path parameter containing a paste or short-link ID.
|
||||||
OR: form parameters `content`, and optional `language`, `expires` (UNIX timestamp) and `max_views` (integer)
|
|
||||||
Returns generated id as plain text.
|
### Create a paste
|
||||||
- POST `/:id` - same as POST `/`, but uses the ID if available. Else it returns a random id.
|
|
||||||
- POST `/once` - same as POST `/`, but sets `max_views` to 1
|
| Method | Path | Behavior |
|
||||||
- POST `/once/:id` - same as POST `/:id`, but sets `max_views` to 1
|
| --- | --- | --- |
|
||||||
- GET `/to/:id` - navigate to shortcut link (redirects using HTTP if found)
|
| `POST` | `/` | Create a paste with a generated ID. |
|
||||||
- POST `/to` - create a new link (plain text raw body)
|
| `POST` | `/{id}` | Use the requested ID if available; otherwise generate a random ID. |
|
||||||
OR: form parameter `href`.
|
| `POST` | `/once` | Create a paste with `max_views` fixed to `1`. |
|
||||||
Returns generated id as plain text.
|
| `POST` | `/once/{id}` | Request a custom ID and set `max_views` to `1`. Generate a random ID if the requested ID is taken. |
|
||||||
- POST `/to/:id` - same but uses the ID if available, else it returns a random id.
|
|
||||||
|
**Request body:** raw text, or an `application/x-www-form-urlencoded` form. Use a form to supply optional parameters.
|
||||||
|
|
||||||
|
| Form parameter | Type | Required | Default | Description |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `content` | String | Yes | — | Text or code to store. For raw text requests, the entire body supplies this value. |
|
||||||
|
| `language` | String | No | Not set | Language for syntax highlighting. |
|
||||||
|
| `expires` | Integer | No | No expiration | Expiration time in seconds |
|
||||||
|
| `max_views` | Integer | No | Unlimited views | Maximum number of views before self-destruction. The `/once` routes force this to `1`. |
|
||||||
|
|
||||||
|
**Authentication:** optional HTTP Basic authentication. If supplied, the paste is associated with that user. An account is created if the user does not exist.
|
||||||
|
|
||||||
|
**Returns:** the assigned ID as plain text. This is an ID, not a full URL. Always use the returned ID, since a requested custom ID may already be taken.
|
||||||
|
|
||||||
|
Create a paste from raw text:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl --request POST 'http://localhost:3000/' \
|
||||||
|
--header 'Content-Type: text/plain' \
|
||||||
|
--data-binary 'Hello, world!'
|
||||||
|
```
|
||||||
|
|
||||||
|
Create a paste with optional parameters:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl 'http://localhost:3000/' \
|
||||||
|
--data-urlencode 'content=println!("Hello, world!");' \
|
||||||
|
--data-urlencode 'language=rust' \
|
||||||
|
--data-urlencode 'max_views=5'
|
||||||
|
```
|
||||||
|
|
||||||
|
Request a custom ID for a one-time secret:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl --request POST 'http://localhost:3000/once/my-secret' \
|
||||||
|
--header 'Content-Type: text/plain' \
|
||||||
|
--data-binary 'A secret to share once'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Read a paste
|
||||||
|
|
||||||
|
`GET /{id}` retrieves a paste by its ID.
|
||||||
|
|
||||||
|
| Parameter | Location | Required | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `id` | Path | Yes | The paste ID returned when it was created. |
|
||||||
|
| `Accept` | Header | No | Use `text/html` for syntax-highlighted HTML or `text/plain` for plain text. |
|
||||||
|
|
||||||
|
**Returns:** the paste as HTML or plain text, based on the `Accept` header. The default representation is not defined yet.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl 'http://localhost:3000/my-paste' \
|
||||||
|
--header 'Accept: text/plain'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Create a short link
|
||||||
|
|
||||||
|
| Method | Path | Behavior |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `POST` | `/to` | Create a short link with a generated ID. |
|
||||||
|
| `POST` | `/to/{id}` | Use the requested ID if available; otherwise generate a random ID. |
|
||||||
|
|
||||||
|
**Request body:** the destination URL as raw text, or an `application/x-www-form-urlencoded` form with the following parameter:
|
||||||
|
|
||||||
|
| Form parameter | Type | Required | Description |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| `href` | String | Yes | Destination URL. For raw text requests, the entire body supplies this value. |
|
||||||
|
|
||||||
|
**Returns:** the assigned ID as plain text. Use it in `/to/{id}` to follow the link.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl 'http://localhost:3000/to' \
|
||||||
|
--data-urlencode 'href=https://example.com'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Follow a short link
|
||||||
|
|
||||||
|
`GET /to/{id}` looks up a short link by its ID.
|
||||||
|
|
||||||
|
**Returns:** an HTTP redirect to the destination URL when found. The redirect status code and behavior for missing links are not defined yet.
|
||||||
|
|
||||||
|
### Update a paste
|
||||||
|
|
||||||
|
`PUT /{id}` updates a paste by its ID.
|
||||||
|
|
||||||
|
**Authentication:** HTTP Basic authentication of the creator is required.
|
||||||
|
|
||||||
|
The same request body as [the POST request](#create-a-paste)
|
||||||
|
|
||||||
|
### Delete a paste
|
||||||
|
|
||||||
|
`DELETE` delete a paste by its ID.
|
||||||
|
|
||||||
|
**Authentication:** HTTP Basic authentication of the creator is required.
|
||||||
|
|
||||||
|
### List pastes
|
||||||
|
|
||||||
|
`GET /list` returns a list of notes.
|
||||||
|
|
||||||
|
**Authentication:** HTTP Basic authentication is required.
|
||||||
|
|
||||||
|
**Returns:** a plain-text list of the notes that are present and their content.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl --user 'alice:example-password' 'http://localhost:3000/list'
|
||||||
|
```
|
||||||
|
|
||||||
|
### Home page
|
||||||
|
|
||||||
|
`GET /` returns the home page.
|
||||||
+88
@@ -0,0 +1,88 @@
|
|||||||
|
use argon2::{
|
||||||
|
Argon2,
|
||||||
|
password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash},
|
||||||
|
};
|
||||||
|
use axum::{
|
||||||
|
extract::{Request, State},
|
||||||
|
http::{StatusCode, header::AUTHORIZATION},
|
||||||
|
middleware::Next,
|
||||||
|
response::Response,
|
||||||
|
};
|
||||||
|
use base64::{Engine, engine::general_purpose::STANDARD};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct User {
|
||||||
|
pub username: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check Bearer auth.
|
||||||
|
* If present, check password.
|
||||||
|
* If user doesn't exist, create it.
|
||||||
|
*/
|
||||||
|
pub async fn authorize(
|
||||||
|
State(db): State<SqlitePool>,
|
||||||
|
mut request: Request,
|
||||||
|
next: Next,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
let Some(header) = request.headers().get(AUTHORIZATION) else {
|
||||||
|
return Ok(next.run(request).await);
|
||||||
|
};
|
||||||
|
|
||||||
|
let (_, encoded) = header
|
||||||
|
.to_str()
|
||||||
|
.ok()
|
||||||
|
.and_then(|value| value.split_once(' '))
|
||||||
|
.filter(|(scheme, _)| scheme.eq_ignore_ascii_case("Basic"))
|
||||||
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let decoded = STANDARD
|
||||||
|
.decode(encoded)
|
||||||
|
.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let credentials = String::from_utf8(decoded).map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||||
|
let (username, password) = credentials
|
||||||
|
.split_once(':')
|
||||||
|
.filter(|(username, _)| !username.is_empty())
|
||||||
|
.ok_or(StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
let argon2 = Argon2::default();
|
||||||
|
let mut hash = get_password_hash(&db, username).await?;
|
||||||
|
|
||||||
|
if hash.is_none() {
|
||||||
|
let new_hash: PasswordHash = argon2
|
||||||
|
.hash_password(password.as_bytes())
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
sqlx::query(
|
||||||
|
"INSERT INTO users (username, pass_hash) VALUES (?, ?) ON CONFLICT(username) DO NOTHING",
|
||||||
|
)
|
||||||
|
.bind(username)
|
||||||
|
.bind(new_hash.to_string())
|
||||||
|
.execute(&db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
hash = get_password_hash(&db, username).await?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let hash = hash.ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
let hash = PasswordHash::new(&hash).map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
argon2
|
||||||
|
.verify_password(password.as_bytes(), &hash)
|
||||||
|
.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
request.extensions_mut().insert(User {
|
||||||
|
username: username.to_owned(),
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(next.run(request).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn get_password_hash(db: &SqlitePool, username: &str) -> Result<Option<String>, StatusCode> {
|
||||||
|
sqlx::query_scalar("SELECT pass_hash FROM users WHERE username = ?")
|
||||||
|
.bind(username)
|
||||||
|
.fetch_optional(db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)
|
||||||
|
}
|
||||||
@@ -1,9 +1,54 @@
|
|||||||
use sqlx::SqlitePool;
|
use sqlx::{
|
||||||
|
SqlitePool,
|
||||||
|
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions},
|
||||||
|
};
|
||||||
|
use tokio::time::interval;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
pub async fn cleanup_notes(pool: SqlitePool) {
|
||||||
|
let mut interval = interval(Duration::from_secs(60));
|
||||||
|
loop {
|
||||||
|
interval.tick().await;
|
||||||
|
if let Err(error) =
|
||||||
|
sqlx::query("DELETE FROM notes WHERE expiration <= unixepoch() OR views >= max_views")
|
||||||
|
.execute(&pool)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
eprintln!("Failed to clean up expired notes: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Connect to the database
|
||||||
|
*/
|
||||||
|
pub async fn connect_db(database_url: &str) -> Result<SqlitePool, sqlx::Error> {
|
||||||
|
let options = database_url
|
||||||
|
.parse::<SqliteConnectOptions>()?
|
||||||
|
.create_if_missing(true)
|
||||||
|
.journal_mode(SqliteJournalMode::Wal)
|
||||||
|
.foreign_keys(true);
|
||||||
|
|
||||||
|
let pool = SqlitePoolOptions::new().connect_with(options).await?;
|
||||||
|
init_db(&pool).await?;
|
||||||
|
Ok(pool)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Initialize database
|
* Initialize database
|
||||||
*/
|
*/
|
||||||
pub async fn init_db(pool: &SqlitePool) -> Result<(), sqlx::Error> {
|
pub async fn init_db(pool: &SqlitePool) -> Result<(), sqlx::Error> {
|
||||||
|
sqlx::query(
|
||||||
|
r#"
|
||||||
|
CREATE TABLE IF NOT EXISTS users (
|
||||||
|
username TEXT PRIMARY KEY,
|
||||||
|
pass_hash TEXT NOT NULL
|
||||||
|
)
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
CREATE TABLE IF NOT EXISTS notes (
|
CREATE TABLE IF NOT EXISTS notes (
|
||||||
@@ -16,9 +61,9 @@ pub async fn init_db(pool: &SqlitePool) -> Result<(), sqlx::Error> {
|
|||||||
views INTEGER NOT NULL DEFAULT 0,
|
views INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
updated_at INTEGER,
|
updated_at INTEGER,
|
||||||
password TEXT
|
owner TEXT REFERENCES users(username)
|
||||||
)
|
)
|
||||||
"#
|
"#,
|
||||||
)
|
)
|
||||||
.execute(pool)
|
.execute(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
+22
-15
@@ -1,24 +1,31 @@
|
|||||||
use axum::{Router, extract::Path, http::{HeaderMap, header::ACCEPT}, response::{Html, IntoResponse}, routing::get, serve};
|
use axum::{Router, middleware, routing::get, serve};
|
||||||
use tokio::net::TcpListener;
|
use tokio::net::TcpListener;
|
||||||
|
use tower_http::services::ServeDir;
|
||||||
|
|
||||||
|
mod auth;
|
||||||
mod db;
|
mod db;
|
||||||
mod paste;
|
mod paste;
|
||||||
|
mod routes;
|
||||||
|
use crate::routes::{get_note, get_redirect, home_page};
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
|
let pool = db::connect_db("sqlite://pastable.db").await?;
|
||||||
|
tokio::spawn(db::cleanup_notes(pool.clone()));
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/", get(|| async {}))
|
.route("/", get(home_page))
|
||||||
.route("/{id}", get(get_note));
|
.route("/to/{id}", get(get_redirect))
|
||||||
|
.route("/{id}", get(get_note))
|
||||||
|
.nest_service("/static", ServeDir::new("static"))
|
||||||
|
.layer(middleware::from_fn_with_state(
|
||||||
|
pool.clone(),
|
||||||
|
auth::authorize,
|
||||||
|
))
|
||||||
|
.with_state(pool);
|
||||||
|
|
||||||
let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap();
|
let listener = TcpListener::bind("0.0.0.0:3000").await?;
|
||||||
println!("Listening on http://localhost:3000");
|
println!("Listening on http://localhost:3000");
|
||||||
serve(listener, app).await.unwrap();
|
serve(listener, app).await?;
|
||||||
}
|
Ok(())
|
||||||
|
|
||||||
async fn get_note(Path(id): Path<String>, headers: HeaderMap) -> impl IntoResponse {
|
|
||||||
let return_html = headers.get(ACCEPT)
|
|
||||||
.and_then(|v|v.to_str().ok())
|
|
||||||
.and_then(|h| Some(h.contains("text/html")))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
Html::from("test")
|
|
||||||
}
|
}
|
||||||
+18
-5
@@ -1,8 +1,21 @@
|
|||||||
/**
|
#[derive(sqlx::FromRow)]
|
||||||
* Paste type
|
pub struct Paste {
|
||||||
*/
|
pub id: String,
|
||||||
#[repr(u8)]
|
#[sqlx(rename = "type")]
|
||||||
|
pub paste_type: PasteType,
|
||||||
|
pub content: String,
|
||||||
|
pub language: Option<String>,
|
||||||
|
pub expiration: Option<i64>,
|
||||||
|
pub max_views: Option<i64>,
|
||||||
|
pub views: i64,
|
||||||
|
pub created_at: i64,
|
||||||
|
pub updated_at: Option<i64>,
|
||||||
|
pub owner: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(sqlx::Type)]
|
||||||
|
#[repr(i64)]
|
||||||
pub enum PasteType {
|
pub enum PasteType {
|
||||||
TextV1 = 0,
|
TextV1 = 0,
|
||||||
UrlV1 = 100
|
UrlV1 = 100,
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
mod get_note;
|
||||||
|
mod get_redirect;
|
||||||
|
mod home;
|
||||||
|
|
||||||
|
pub use get_note::get_note;
|
||||||
|
pub use get_redirect::get_redirect;
|
||||||
|
pub use home::home_page;
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
use askama::Template;
|
||||||
|
use askama_web::WebTemplate;
|
||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::{HeaderMap, StatusCode, header::ACCEPT},
|
||||||
|
response::{IntoResponse, Response},
|
||||||
|
};
|
||||||
|
use irosashi::{CodeToHtmlOptions, Highlighter};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::paste::{Paste, PasteType};
|
||||||
|
|
||||||
|
#[derive(Template, WebTemplate)]
|
||||||
|
#[template(path = "note.html")]
|
||||||
|
pub struct NotePage {
|
||||||
|
code: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn get_note(
|
||||||
|
State(db): State<SqlitePool>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
headers: HeaderMap,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
let mut transaction = db
|
||||||
|
.begin()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
let paste = sqlx::query_as::<_, Paste>(
|
||||||
|
"UPDATE notes SET views = views + 1
|
||||||
|
WHERE type = ? AND id = ?
|
||||||
|
AND (expiration IS NULL OR expiration > unixepoch())
|
||||||
|
AND (max_views IS NULL OR views < max_views)
|
||||||
|
RETURNING *",
|
||||||
|
)
|
||||||
|
.bind(PasteType::TextV1)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&mut *transaction)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
if paste.max_views.is_some_and(|max| paste.views >= max) {
|
||||||
|
sqlx::query("DELETE FROM notes WHERE id = ?")
|
||||||
|
.bind(&paste.id)
|
||||||
|
.execute(&mut *transaction)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
transaction
|
||||||
|
.commit()
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
if return_html(headers) {
|
||||||
|
let highlighter = Highlighter::new().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
let code = highlighter
|
||||||
|
.code_to_html(
|
||||||
|
&paste.content,
|
||||||
|
&CodeToHtmlOptions::new(
|
||||||
|
paste.language.as_deref().unwrap_or("text"),
|
||||||
|
"github-light",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
Ok(NotePage { code }.into_response())
|
||||||
|
} else {
|
||||||
|
Ok(paste.content.into_response())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn return_html(headers: HeaderMap) -> bool {
|
||||||
|
headers
|
||||||
|
.get(ACCEPT)
|
||||||
|
.and_then(|v| v.to_str().ok())
|
||||||
|
.map(|h| h.contains("text/html"))
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
use axum::{
|
||||||
|
extract::{Path, State},
|
||||||
|
http::StatusCode,
|
||||||
|
response::Redirect,
|
||||||
|
};
|
||||||
|
use sqlx::SqlitePool;
|
||||||
|
|
||||||
|
use crate::paste::PasteType;
|
||||||
|
|
||||||
|
pub async fn get_redirect(
|
||||||
|
State(db): State<SqlitePool>,
|
||||||
|
Path(id): Path<String>,
|
||||||
|
) -> Result<Redirect, StatusCode> {
|
||||||
|
let url =
|
||||||
|
sqlx::query_scalar::<_, String>("SELECT content FROM notes WHERE type = ? AND id = ?")
|
||||||
|
.bind(PasteType::UrlV1)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&db)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
Ok(Redirect::temporary(&url))
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
use askama::Template;
|
||||||
|
use askama_web::WebTemplate;
|
||||||
|
|
||||||
|
#[derive(Template, WebTemplate)]
|
||||||
|
#[template(path = "home.html")]
|
||||||
|
pub struct HomePage;
|
||||||
|
|
||||||
|
pub async fn home_page() -> HomePage {
|
||||||
|
HomePage
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
:root {
|
||||||
|
--background: #fafaf7;
|
||||||
|
--text: #252823;
|
||||||
|
--muted: #60675c;
|
||||||
|
--accent: #376345;
|
||||||
|
--surface: #eef1e9;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
max-width: 42rem;
|
||||||
|
margin: 2rem auto;
|
||||||
|
padding: 0 1rem;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text);
|
||||||
|
background: var(--background);
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
margin-block: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
background: var(--surface);
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<meta name="description" content="Pastable">
|
||||||
|
<title>{% block title %}Pastable{% endblock %}</title>
|
||||||
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header>
|
||||||
|
<a href="/">Pastable</a>
|
||||||
|
</header>
|
||||||
|
<main>
|
||||||
|
{% block content %}
|
||||||
|
{% endblock %}
|
||||||
|
</main>
|
||||||
|
<footer>
|
||||||
|
<small>Free and open source, with ❤ by <a href="https://plabble.org">Plabble</a>.</small>
|
||||||
|
</footer>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
<h1>Pastable</h1>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
{% block content %}
|
||||||
|
{{ code|safe }}
|
||||||
|
{% endblock %}
|
||||||
Reference in new issue
Block a user