diff --git a/.gitignore b/.gitignore index ea8c4bf..ee3e0df 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,5 @@ /target +/pastable.db +/pastable.db-shm +/pastable.db-wal +/pastable.db-journal diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..1c960fb --- /dev/null +++ b/AGENTS.md @@ -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. \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock index 630a85a..8fa366a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -17,6 +17,18 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "askama" version = "0.16.1" @@ -70,6 +82,30 @@ dependencies = [ "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]] name = "atoi" version = "2.0.0" @@ -149,6 +185,18 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "basic-toml" version = "0.1.10" @@ -167,6 +215,15 @@ dependencies = [ "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]] name = "block-buffer" version = "0.10.4" @@ -613,6 +670,12 @@ dependencies = [ "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]] name = "httparse" version = "1.10.1" @@ -887,6 +950,16 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "mio" version = "1.2.5" @@ -942,16 +1015,30 @@ dependencies = [ "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]] name = "pastable" version = "0.1.0" dependencies = [ + "argon2", "askama", + "askama_web", "axum", + "base64 0.23.1", "irosashi", "serde", "sqlx", "tokio", + "tower-http", ] [[package]] @@ -960,6 +1047,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "pin-project-lite" version = "0.2.17" @@ -1226,7 +1324,7 @@ version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "05b44e85bf579a8eeb4ceaa77a3a523baf2bf0e9bac7e40f405d537b5d2d5ccb" dependencies = [ - "base64", + "base64 0.22.1", "bytes", "cfg-if", "crc", @@ -1326,7 +1424,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "87a2bdd6e83f6b3ea525ca9fee568030508b58355a43d0b2c1674d5f79dcd65e" dependencies = [ "atoi", - "base64", + "base64 0.22.1", "bitflags", "byteorder", "crc", @@ -1507,6 +1605,19 @@ dependencies = [ "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]] name = "tower" version = "0.5.3" @@ -1523,6 +1634,31 @@ dependencies = [ "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]] name = "tower-layer" version = "0.3.3" @@ -1573,6 +1709,12 @@ version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + [[package]] name = "unicode-bidi" version = "0.3.18" diff --git a/Cargo.toml b/Cargo.toml index 678e98c..44b69f3 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,9 +4,13 @@ version = "0.1.0" edition = "2024" [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" +base64 = "0.23.1" irosashi = "0.3.1" serde = { version = "1.0.229", features = ["derive"] } sqlx = { version = "0.9.0", features = ["runtime-tokio", "sqlite"] } tokio = { version = "1.54.1", features = ["rt-multi-thread"] } +tower-http = { version = "0.7.1", features = ["fs"] } diff --git a/README.md b/README.md index 18bcf8a..8d86269 100644 --- a/README.md +++ b/README.md @@ -1,26 +1,153 @@ # 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` extractor. ## 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 -- 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. -OR: form parameters `content`, and optional `language`, `expires` (UNIX timestamp) and `max_views` (integer) -Returns generated id as plain text. -- 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 -- 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 `/to` - create a new link (plain text raw body) -OR: form parameter `href`. -Returns generated id as plain text. -- POST `/to/:id` - same but uses the ID if available, else it returns a random id. +## API + +All paths are relative to the server URL. `{id}` is a path parameter containing a paste or short-link ID. + +### Create a paste + +| Method | Path | Behavior | +| --- | --- | --- | +| `POST` | `/` | Create a paste with a generated ID. | +| `POST` | `/{id}` | Use the requested ID if available; otherwise generate a random ID. | +| `POST` | `/once` | Create a paste with `max_views` fixed to `1`. | +| `POST` | `/once/{id}` | Request a custom ID and set `max_views` to `1`. Generate a random ID if the requested ID is taken. | + +**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. diff --git a/src/auth.rs b/src/auth.rs new file mode 100644 index 0000000..3ac21be --- /dev/null +++ b/src/auth.rs @@ -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, + mut request: Request, + next: Next, +) -> Result { + 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, StatusCode> { + sqlx::query_scalar("SELECT pass_hash FROM users WHERE username = ?") + .bind(username) + .fetch_optional(db) + .await + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) +} diff --git a/src/db.rs b/src/db.rs index 5066fa4..e101043 100644 --- a/src/db.rs +++ b/src/db.rs @@ -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 { + let options = database_url + .parse::()? + .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 */ 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( r#" CREATE TABLE IF NOT EXISTS notes ( @@ -16,12 +61,12 @@ pub async fn init_db(pool: &SqlitePool) -> Result<(), sqlx::Error> { views INTEGER NOT NULL DEFAULT 0, created_at INTEGER NOT NULL, updated_at INTEGER, - password TEXT + owner TEXT REFERENCES users(username) ) - "# + "#, ) .execute(pool) .await?; Ok(()) -} \ No newline at end of file +} diff --git a/src/main.rs b/src/main.rs index 267b8ce..c8589d0 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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 tower_http::services::ServeDir; + +mod auth; mod db; mod paste; +mod routes; +use crate::routes::{get_note, get_redirect, home_page}; #[tokio::main] -async fn main() { +async fn main() -> Result<(), Box> { + let pool = db::connect_db("sqlite://pastable.db").await?; + tokio::spawn(db::cleanup_notes(pool.clone())); + let app = Router::new() - .route("/", get(|| async {})) - .route("/{id}", get(get_note)); + .route("/", get(home_page)) + .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"); - serve(listener, app).await.unwrap(); + serve(listener, app).await?; + Ok(()) } - -async fn get_note(Path(id): Path, 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") -} \ No newline at end of file diff --git a/src/paste.rs b/src/paste.rs index f2f9447..eaffc82 100644 --- a/src/paste.rs +++ b/src/paste.rs @@ -1,8 +1,21 @@ -/** - * Paste type - */ -#[repr(u8)] +#[derive(sqlx::FromRow)] +pub struct Paste { + pub id: String, + #[sqlx(rename = "type")] + pub paste_type: PasteType, + pub content: String, + pub language: Option, + pub expiration: Option, + pub max_views: Option, + pub views: i64, + pub created_at: i64, + pub updated_at: Option, + pub owner: Option, +} + +#[derive(sqlx::Type)] +#[repr(i64)] pub enum PasteType { TextV1 = 0, - UrlV1 = 100 -} \ No newline at end of file + UrlV1 = 100, +} diff --git a/src/routes.rs b/src/routes.rs new file mode 100644 index 0000000..5e99eee --- /dev/null +++ b/src/routes.rs @@ -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; diff --git a/src/routes/get_note.rs b/src/routes/get_note.rs new file mode 100644 index 0000000..f2961b8 --- /dev/null +++ b/src/routes/get_note.rs @@ -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, + Path(id): Path, + headers: HeaderMap, +) -> Result { + 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() +} diff --git a/src/routes/get_redirect.rs b/src/routes/get_redirect.rs new file mode 100644 index 0000000..69a3d44 --- /dev/null +++ b/src/routes/get_redirect.rs @@ -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, + Path(id): Path, +) -> Result { + 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)) +} diff --git a/src/routes/home.rs b/src/routes/home.rs new file mode 100644 index 0000000..b034d4c --- /dev/null +++ b/src/routes/home.rs @@ -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 +} diff --git a/static/app.css b/static/app.css new file mode 100644 index 0000000..3c5d1ad --- /dev/null +++ b/static/app.css @@ -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); +} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..ae3e09a --- /dev/null +++ b/templates/base.html @@ -0,0 +1,22 @@ + + + + + + + {% block title %}Pastable{% endblock %} + + + +
+ Pastable +
+
+ {% block content %} + {% endblock %} +
+
+ Free and open source, with ❤ by Plabble. +
+ + diff --git a/templates/home.html b/templates/home.html new file mode 100644 index 0000000..b497ea2 --- /dev/null +++ b/templates/home.html @@ -0,0 +1,4 @@ +{% extends "base.html" %} +{% block content %} +

Pastable

+{% endblock %} diff --git a/templates/note.html b/templates/note.html new file mode 100644 index 0000000..248ee5b --- /dev/null +++ b/templates/note.html @@ -0,0 +1,4 @@ +{% extends "base.html" %} +{% block content %} +{{ code|safe }} +{% endblock %}