From e3f73f281ae286dbb331e991c9b97b1812fcaf71 Mon Sep 17 00:00:00 2001 From: Matthias Vogelgesang Date: Sun, 9 Feb 2025 00:30:07 +0100 Subject: [PATCH] Restructure routes, handlers foo --- src/db.rs | 17 +++ src/handlers/delete.rs | 34 +++++ src/handlers/download.rs | 60 +++++++++ src/handlers/html/burn.rs | 41 ++++++ src/handlers/html/index.rs | 96 ++++++++++++++ src/handlers/html/mod.rs | 38 ++++++ src/handlers/html/paste.rs | 141 ++++++++++++++++++++ src/handlers/html/qr.rs | 72 ++++++++++ src/handlers/mod.rs | 4 + src/handlers/raw.rs | 43 ++++++ src/main.rs | 29 +++-- src/pages.rs | 237 --------------------------------- src/routes/assets.rs | 2 - src/routes/form.rs | 5 +- src/routes/mod.rs | 12 -- src/routes/paste.rs | 260 +------------------------------------ templates/encrypted.html | 6 +- templates/paste.html | 8 +- templates/qr.html | 2 +- 19 files changed, 577 insertions(+), 530 deletions(-) create mode 100644 src/handlers/delete.rs create mode 100644 src/handlers/download.rs create mode 100644 src/handlers/html/burn.rs create mode 100644 src/handlers/html/index.rs create mode 100644 src/handlers/html/mod.rs create mode 100644 src/handlers/html/paste.rs create mode 100644 src/handlers/html/qr.rs create mode 100644 src/handlers/mod.rs create mode 100644 src/handlers/raw.rs delete mode 100644 src/pages.rs delete mode 100644 src/routes/assets.rs diff --git a/src/db.rs b/src/db.rs index b8d7a6f..d270fef 100644 --- a/src/db.rs +++ b/src/db.rs @@ -346,6 +346,23 @@ impl Database { Ok(uid) } + /// Get title of a paste. + pub async fn get_title(&self, id: Id) -> Result { + let conn = self.conn.clone(); + let id = id.as_u32(); + + let title = spawn_blocking(move || { + conn.lock().query_row( + "SELECT title FROM entries WHERE id=?1", + params![id], + |row| Ok(row.get(0)?), + ) + }) + .await??; + + Ok(title) + } + /// Delete `id`. pub async fn delete(&self, id: Id) -> Result<(), Error> { let conn = self.conn.clone(); diff --git a/src/handlers/delete.rs b/src/handlers/delete.rs new file mode 100644 index 0000000..f0236ab --- /dev/null +++ b/src/handlers/delete.rs @@ -0,0 +1,34 @@ +use crate::handlers::html::{make_error, ErrorResponse}; +use crate::{Database, Error, Page}; +use axum::extract::{Path, State}; +use axum::response::Redirect; +use axum_extra::extract::SignedCookieJar; + +pub async fn delete( + Path(id): Path, + State(db): State, + State(page): State, + jar: SignedCookieJar, +) -> Result { + async { + let id = id.parse()?; + let uid = db.get_uid(id).await?; + let can_delete = jar + .get("uid") + .map(|cookie| cookie.value().parse::()) + .transpose() + .map_err(|err| Error::CookieParsing(err.to_string()))? + .zip(uid) + .is_some_and(|(user_uid, db_uid)| user_uid == db_uid); + + if !can_delete { + Err(Error::Delete)?; + } + + db.delete(id).await?; + + Ok(Redirect::to("/")) + } + .await + .map_err(|err| make_error(err, page.clone())) +} diff --git a/src/handlers/download.rs b/src/handlers/download.rs new file mode 100644 index 0000000..d0c2019 --- /dev/null +++ b/src/handlers/download.rs @@ -0,0 +1,60 @@ +use crate::cache::Key; +use crate::crypto::Password; +use crate::handlers::html::{make_error, ErrorResponse, PasswordInput}; +use crate::{Database, Error, Page}; +use axum::extract::{Form, Path, State}; +use axum::http::header; +use axum::response::{AppendHeaders, IntoResponse, Response}; +use axum_extra::headers::HeaderValue; +use serde::Deserialize; + +#[derive(Deserialize, Debug)] +pub struct PasswordForm { + password: String, +} + +/// GET handler for raw content of a paste. +pub async fn download( + Path(id): Path, + State(db): State, + State(page): State, + form: Option>, +) -> Result { + async { + let password = form.map(|form| Password::from(form.password.as_bytes().to_vec())); + let key: Key = id.parse()?; + + match db.get(key.id, password.clone()).await { + Err(Error::NoPassword) => Ok(PasswordInput { + page: page.clone(), + id: key.id.to_string(), + } + .into_response()), + Err(err) => Err(err), + Ok(entry) => { + if entry.must_be_deleted { + db.delete(key.id).await?; + } + + Ok(get_download(entry.text, &key.id(), &key.ext).into_response()) + } + } + } + .await + .map_err(|err| make_error(err, page)) +} + +fn get_download(text: String, id: &str, extension: &str) -> impl IntoResponse { + let content_type = "text; charset=utf-8"; + let content_disposition = + HeaderValue::from_str(&format!(r#"attachment; filename="{id}.{extension}"#)) + .expect("constructing valid header value"); + + ( + AppendHeaders([ + (header::CONTENT_TYPE, HeaderValue::from_static(content_type)), + (header::CONTENT_DISPOSITION, content_disposition), + ]), + text, + ) +} diff --git a/src/handlers/html/burn.rs b/src/handlers/html/burn.rs new file mode 100644 index 0000000..421c131 --- /dev/null +++ b/src/handlers/html/burn.rs @@ -0,0 +1,41 @@ +use crate::handlers::html::qr::{code_from, dark_modules}; +use crate::handlers::html::{make_error, ErrorResponse}; +use crate::{Error, Page}; +use askama::Template; +use axum::extract::{Path, State}; + +/// GET handler for the burn page. +pub async fn burn(Path(id): Path, State(page): State) -> Result { + async { + let code = tokio::task::spawn_blocking({ + let page = page.clone(); + let id = id.clone(); + move || code_from(&page.base_url, id) + }) + .await + .map_err(Error::from)??; + + Ok(Burn { + page: page.clone(), + id, + code, + }) + } + .await + .map_err(|err| make_error(err, page)) +} + +/// Burn page shown if "burn-after-reading" was selected during insertion. +#[derive(Template)] +#[template(path = "burn.html", escape = "none")] +pub struct Burn { + page: Page, + id: String, + code: qrcodegen::QrCode, +} + +impl Burn { + fn dark_modules(&self) -> Vec<(i32, i32)> { + dark_modules(&self.code) + } +} diff --git a/src/handlers/html/index.rs b/src/handlers/html/index.rs new file mode 100644 index 0000000..7335349 --- /dev/null +++ b/src/handlers/html/index.rs @@ -0,0 +1,96 @@ +use crate::{AppState, Highlighter, Page}; +use askama::Template; +use axum::extract::State; +use std::num::{NonZero, NonZeroU32}; +use std::sync::OnceLock; + +/// GET handler for the index page. +pub async fn index( + State(state): State, + State(page): State, + State(highlighter): State, +) -> Index { + Index { + page, + max_expiration: state.max_expiration, + highlighter, + } +} + +/// Index page displaying a form for paste insertion and a selection box for languages. +#[derive(Template)] +#[template(path = "index.html")] +pub struct Index { + page: Page, + max_expiration: Option, + highlighter: Highlighter, +} + +#[derive(Debug, Clone, Copy, Eq, PartialEq)] +enum Expiration { + None, + Burn, + Time(NonZeroU32), +} + +impl std::fmt::Display for Expiration { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Expiration::None => write!(f, ""), + Expiration::Burn => write!(f, "burn"), + Expiration::Time(t) => write!(f, "{t}"), + } + } +} + +#[allow(clippy::unwrap_used)] +const EXPIRATION_OPTIONS: [(&str, Expiration); 8] = [ + ("never", Expiration::None), + ("10 minutes", Expiration::Time(NonZero::new(600).unwrap())), + ("1 hour", Expiration::Time(NonZero::new(3600).unwrap())), + ("1 day", Expiration::Time(NonZero::new(86400).unwrap())), + ("1 week", Expiration::Time(NonZero::new(604_800).unwrap())), + ( + "1 month", + Expiration::Time(NonZero::new(2_592_000).unwrap()), + ), + ( + "1 year", + Expiration::Time(NonZero::new(31_536_000).unwrap()), + ), + ("🔥 after reading", Expiration::Burn), +]; + +impl Index { + fn expiry_options(&self) -> &str { + static EXPIRATION_OPTIONS_HTML: OnceLock = OnceLock::new(); + + EXPIRATION_OPTIONS_HTML.get_or_init(|| { + + let mut option_set = String::new(); + let mut wrote_first = false; + + option_set.push('\n'); + + for (opt_name, opt_val) in EXPIRATION_OPTIONS { + if self.max_expiration.is_none() + || opt_val == Expiration::Burn + || matches!((self.max_expiration, opt_val), (Some(exp), Expiration::Time(time)) if time <= exp) + { + option_set.push_str(""); + option_set.push_str(opt_name); + option_set.push_str("\n"); + } + } + + option_set + }) + } +} diff --git a/src/handlers/html/mod.rs b/src/handlers/html/mod.rs new file mode 100644 index 0000000..70af630 --- /dev/null +++ b/src/handlers/html/mod.rs @@ -0,0 +1,38 @@ +pub mod burn; +pub mod index; +pub mod paste; +pub mod qr; + +pub use burn::burn; +pub use index::index; +pub use qr::qr; + +use crate::{errors, Page}; +use askama::Template; +use axum::http::StatusCode; + +/// Error page showing a message. +#[derive(Template)] +#[template(path = "error.html")] +pub struct Error { + pub page: Page, + pub description: String, +} + +/// Page showing password input. +#[derive(Template)] +#[template(path = "encrypted.html")] +pub struct PasswordInput { + pub page: Page, + pub id: String, +} + +/// Error response carrying a status code and the page itself. +pub type ErrorResponse = (StatusCode, Error); + +/// Create an error response from `error` consisting of [`StatusCode`] derive from `error` as well +/// as a rendered page with a description. +pub fn make_error(error: errors::Error, page: Page) -> ErrorResponse { + let description = error.to_string(); + (error.into(), Error { page, description }) +} diff --git a/src/handlers/html/paste.rs b/src/handlers/html/paste.rs new file mode 100644 index 0000000..1f50f19 --- /dev/null +++ b/src/handlers/html/paste.rs @@ -0,0 +1,141 @@ +use crate::cache::Key; +use crate::crypto::Password; +use crate::db::read::Entry; +use crate::handlers::html::{make_error, ErrorResponse, PasswordInput}; +use crate::highlight::Html; +use crate::{Cache, Database, Error, Highlighter, Page}; +use askama::Template; +use axum::extract::{Form, Path, State}; +use axum::response::{IntoResponse, Response}; +use axum_extra::extract::SignedCookieJar; +use http::{header, HeaderMap}; +use serde::Deserialize; + +#[derive(Deserialize, Debug)] +pub struct PasswordForm { + password: String, +} + +/// Paste view showing the formatted paste. +#[derive(Template)] +#[template(path = "formatted.html")] +pub struct Paste { + page: Page, + id: String, + can_delete: bool, + html: String, + title: String, +} + +#[expect(clippy::too_many_arguments)] +pub async fn get( + State(cache): State, + State(page): State, + State(db): State, + State(highlighter): State, + Path(id): Path, + headers: HeaderMap, + jar: SignedCookieJar, + form: Option>, +) -> Result { + async { + let password = form.map(|form| Password::from(form.password.as_bytes().to_vec())); + let key: Key = id.parse()?; + + match db.get(key.id, password.clone()).await { + Ok(entry) => { + if entry.must_be_deleted { + db.delete(key.id).await?; + } + + let accept_html = headers + .get(header::ACCEPT) + .map(|value| value.to_str().ok()) + .flatten() + .map_or(false, |value| value.contains("text/html")); + + if accept_html { + return Ok(get_html( + page.clone(), + cache, + highlighter, + key, + entry, + jar, + password.is_some(), + ) + .await + .into_response()); + } + + Ok(entry.text.into_response()) + } + Err(Error::NoPassword) => Ok(PasswordInput { + page: page.clone(), + id: key.id.to_string(), + } + .into_response()), + Err(err) => Err(err), + } + } + .await + .map_err(|err| make_error(err, page)) +} + +impl Paste { + /// Construct new paste view from cache `key` and paste `html`. + pub fn new(key: Key, html: Html, can_delete: bool, title: String, page: Page) -> Self { + let html = html.into_inner(); + + Self { + page, + id: key.id(), + can_delete, + html, + title, + } + } +} + +async fn get_html( + page: Page, + cache: Cache, + highlighter: Highlighter, + key: Key, + entry: Entry, + jar: SignedCookieJar, + is_protected: bool, +) -> Result { + async { + let can_delete = jar + .get("uid") + .map(|cookie| cookie.value().parse::()) + .transpose() + .map_err(|err| Error::CookieParsing(err.to_string()))? + .zip(entry.uid) + .is_some_and(|(user_uid, owner_uid)| user_uid == owner_uid); + + if let Some(html) = cache.get(&key) { + tracing::trace!(?key, "found cached item"); + + let title = entry.title.unwrap_or_default(); + return Ok(Paste::new(key, html, can_delete, title, page.clone()).into_response()); + } + + // TODO: turn this upside-down, i.e. cache it but only return a cached version if we were able + // to decrypt the content. Highlighting is probably still much slower than decryption. + let can_be_cached = !entry.must_be_deleted; + let ext = key.ext.clone(); + let title = entry.title.clone().unwrap_or_default(); + let html = highlighter.highlight(entry, ext).await?; + + if can_be_cached && !is_protected { + tracing::trace!(?key, "cache item"); + cache.put(key.clone(), html.clone()); + } + + Ok(Paste::new(key, html, can_delete, title, page.clone()).into_response()) + } + .await + .map_err(|err| make_error(err, page)) +} diff --git a/src/handlers/html/qr.rs b/src/handlers/html/qr.rs new file mode 100644 index 0000000..3f0701b --- /dev/null +++ b/src/handlers/html/qr.rs @@ -0,0 +1,72 @@ +use crate::cache::Key; +use crate::db::Database; +use crate::handlers::html::{make_error, ErrorResponse}; +use crate::{Error, Page}; +use askama::Template; +use axum::extract::{Path, State}; +use qrcodegen::QrCode; +use url::Url; + +/// GET handler for a QR page. +pub async fn qr( + Path(id): Path, + State(page): State, + State(db): State, +) -> Result { + async { + let code = { + let page = page.clone(); + let id = id.clone(); + + tokio::task::spawn_blocking(move || code_from(&page.base_url, id)) + .await + .map_err(Error::from)?? + }; + + let key: Key = id.parse()?; + let title = db.get_title(key.id).await?; + + Ok(Qr { + page: page.clone(), + id, + can_delete: false, + code, + title, + }) + } + .await + .map_err(|err| make_error(err, page.clone())) +} + +/// Paste view showing the formatted paste as well as a bunch of links. +#[derive(Template)] +#[template(path = "qr.html", escape = "none")] +pub struct Qr { + page: Page, + id: String, + can_delete: bool, + code: qrcodegen::QrCode, + title: String, +} + +impl Qr { + fn dark_modules(&self) -> Vec<(i32, i32)> { + dark_modules(&self.code) + } +} + +pub fn code_from(url: &Url, id: String) -> Result { + Ok(QrCode::encode_text( + url.join(&id)?.as_str(), + qrcodegen::QrCodeEcc::High, + )?) +} + +/// Return module coordinates that are dark. +pub fn dark_modules(code: &QrCode) -> Vec<(i32, i32)> { + let size = code.size(); + (0..size) + .flat_map(|x| (0..size).map(move |y| (x, y))) + .filter(|(x, y)| code.get_module(*x, *y)) + .collect() +} diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs new file mode 100644 index 0000000..4045806 --- /dev/null +++ b/src/handlers/mod.rs @@ -0,0 +1,4 @@ +pub mod delete; +pub mod download; +pub mod html; +pub mod raw; diff --git a/src/handlers/raw.rs b/src/handlers/raw.rs new file mode 100644 index 0000000..0b19b53 --- /dev/null +++ b/src/handlers/raw.rs @@ -0,0 +1,43 @@ +use crate::cache::Key; +use crate::crypto::Password; +use crate::handlers::html::{make_error, ErrorResponse, PasswordInput}; +use crate::{Database, Error, Page}; +use axum::extract::{Form, Path, State}; +use axum::response::{IntoResponse, Response}; +use serde::Deserialize; + +#[derive(Deserialize, Debug)] +pub struct PasswordForm { + password: String, +} + +/// GET handler for raw content of a paste. +pub async fn raw( + Path(id): Path, + State(db): State, + State(page): State, + form: Option>, +) -> Result { + async { + let password = form.map(|form| Password::from(form.password.as_bytes().to_vec())); + let key: Key = id.parse()?; + + match db.get(key.id, password.clone()).await { + Ok(entry) => { + if entry.must_be_deleted { + db.delete(key.id).await?; + } + + Ok(entry.text.into_response()) + } + Err(Error::NoPassword) => Ok(PasswordInput { + page: page.clone(), + id: key.id.to_string(), + } + .into_response()), + Err(err) => Err(err), + } + } + .await + .map_err(|err| make_error(err, page)) +} diff --git a/src/main.rs b/src/main.rs index 126a914..525c2b2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ use crate::assets::{Asset, CssAssets, Kind}; use crate::cache::Cache; use crate::db::Database; use crate::errors::Error; +use crate::handlers::{delete, download, html, raw}; use axum::extract::{DefaultBodyLimit, FromRef, Request, State}; use axum::http::{HeaderName, HeaderValue, StatusCode}; use axum::middleware::{from_fn, from_fn_with_state, Next}; @@ -29,9 +30,9 @@ mod crypto; mod db; mod env; mod errors; +mod handlers; mod highlight; mod id; -mod pages; pub(crate) mod routes; #[cfg(test)] mod test_helpers; @@ -160,12 +161,18 @@ async fn handle_service_errors(State(page): State, req: Request, next: Nex match response.status() { StatusCode::PAYLOAD_TOO_LARGE => ( StatusCode::PAYLOAD_TOO_LARGE, - pages::Error::new("payload exceeded limit".to_string(), page), + html::Error { + page, + description: String::from("payload exceeded limit"), + }, ) .into_response(), StatusCode::UNSUPPORTED_MEDIA_TYPE => ( StatusCode::UNSUPPORTED_MEDIA_TYPE, - pages::Error::new("unsupported media type".to_string(), page), + html::Error { + page, + description: String::from("unsupported media type"), + }, ) .into_response(), _ => response, @@ -235,15 +242,13 @@ async fn serve( .route(state.page.assets.css.light.route(), get(light_css)) .route(state.page.assets.index_js.route(), get(index_js)) .route(state.page.assets.paste_js.route(), get(paste_js)) - .route("/", get(routes::index).post(routes::paste::insert)) - .route( - "/:id", - get(routes::paste::get) - .post(routes::paste::get) - .delete(routes::paste::delete), - ) - .route("/burn/:id", get(routes::paste::burn_created)) - .route("/delete/:id", get(routes::paste::delete)) + .route("/", get(html::index).post(routes::paste::insert)) + .route("/:id", get(html::paste::get).delete(delete::delete)) + .route("/dl/:id", get(download::download)) + .route("/qr/:id", get(html::qr)) + .route("/raw/:id", get(raw::raw)) + .route("/burn/:id", get(html::burn)) + .route("/delete/:id", get(delete::delete)) .layer( ServiceBuilder::new() .layer(DefaultBodyLimit::max(max_body_size)) diff --git a/src/pages.rs b/src/pages.rs deleted file mode 100644 index ccdc21c..0000000 --- a/src/pages.rs +++ /dev/null @@ -1,237 +0,0 @@ -use crate::cache::Key as CacheKey; -use crate::highlight::Html; -use crate::routes::paste::{Format, QueryData}; -use crate::{errors, Highlighter, Page}; -use askama::Template; -use axum::http::StatusCode; -use std::num::{NonZero, NonZeroU32}; -use std::sync::OnceLock; - -/// Error page showing a message. -#[derive(Template)] -#[template(path = "error.html")] -pub struct Error { - page: Page, - description: String, -} - -/// Error response carrying a status code and the page itself. -pub type ErrorResponse = (StatusCode, Error); - -/// Create an error response from `error` consisting of [`StatusCode`] derive from `error` as well -/// as a rendered page with a description. -pub fn make_error(error: errors::Error, page: Page) -> ErrorResponse { - let description = error.to_string(); - (error.into(), Error { page, description }) -} - -impl Error { - /// Create new [`Error`] from `description`. - pub fn new(description: String, page: Page) -> Self { - Self { page, description } - } -} - -/// Index page displaying a form for paste insertion and a selection box for languages. -#[derive(Template)] -#[template(path = "index.html")] -pub struct Index { - page: Page, - max_expiration: Option, - highlighter: Highlighter, -} - -impl Index { - pub fn new(max_expiration: Option, page: Page, highlighter: Highlighter) -> Self { - Self { - page, - max_expiration, - highlighter, - } - } -} - -#[derive(Debug, Clone, Copy, Eq, PartialEq)] -enum Expiration { - None, - Burn, - Time(NonZeroU32), -} - -impl std::fmt::Display for Expiration { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Expiration::None => write!(f, ""), - Expiration::Burn => write!(f, "burn"), - Expiration::Time(t) => write!(f, "{t}"), - } - } -} - -#[allow(clippy::unwrap_used)] -const EXPIRATION_OPTIONS: [(&str, Expiration); 8] = [ - ("never", Expiration::None), - ("10 minutes", Expiration::Time(NonZero::new(600).unwrap())), - ("1 hour", Expiration::Time(NonZero::new(3600).unwrap())), - ("1 day", Expiration::Time(NonZero::new(86400).unwrap())), - ("1 week", Expiration::Time(NonZero::new(604_800).unwrap())), - ( - "1 month", - Expiration::Time(NonZero::new(2_592_000).unwrap()), - ), - ( - "1 year", - Expiration::Time(NonZero::new(31_536_000).unwrap()), - ), - ("🔥 after reading", Expiration::Burn), -]; - -impl Index { - fn expiry_options(&self) -> &str { - static EXPIRATION_OPTIONS_HTML: OnceLock = OnceLock::new(); - - EXPIRATION_OPTIONS_HTML.get_or_init(|| { - - let mut option_set = String::new(); - let mut wrote_first = false; - - option_set.push('\n'); - - for (opt_name, opt_val) in EXPIRATION_OPTIONS { - if self.max_expiration.is_none() - || opt_val == Expiration::Burn - || matches!((self.max_expiration, opt_val), (Some(exp), Expiration::Time(time)) if time <= exp) - { - option_set.push_str(""); - option_set.push_str(opt_name); - option_set.push_str("\n"); - } - } - - option_set - }) - } -} - -/// Paste view showing the formatted paste as well as a bunch of links. -#[derive(Template)] -#[template(path = "formatted.html")] -pub struct Paste { - page: Page, - id: String, - ext: String, - can_delete: bool, - html: String, - title: String, -} - -impl Paste { - /// Construct new paste view from cache `key` and paste `html`. - pub fn new(key: CacheKey, html: Html, can_delete: bool, title: String, page: Page) -> Self { - let html = html.into_inner(); - - Self { - page, - id: key.id(), - ext: key.ext, - can_delete, - html, - title, - } - } -} - -/// View showing password input. -#[derive(Template)] -#[template(path = "encrypted.html")] -pub struct Encrypted { - page: Page, - id: String, - ext: String, - query: String, -} - -impl Encrypted { - /// Construct new paste view from cache `key` and paste `html`. - pub fn new(key: CacheKey, query: &QueryData, page: Page) -> Self { - let query = match query.fmt { - Some(Format::Raw) => "?fmt=raw".to_string(), - Some(Format::Qr) => "?fmt=qr".to_string(), - Some(Format::Dl) => "?fmt=dl".to_string(), - None => String::new(), - }; - - Self { - page, - id: key.id(), - ext: key.ext, - query, - } - } -} - -/// Return module coordinates that are dark. -fn dark_modules(code: &qrcodegen::QrCode) -> Vec<(i32, i32)> { - let size = code.size(); - (0..size) - .flat_map(|x| (0..size).map(move |y| (x, y))) - .filter(|(x, y)| code.get_module(*x, *y)) - .collect() -} - -/// Paste view showing the formatted paste as well as a bunch of links. -#[derive(Template)] -#[template(path = "qr.html", escape = "none")] -pub struct Qr { - page: Page, - id: String, - ext: String, - can_delete: bool, - code: qrcodegen::QrCode, - title: String, -} - -impl Qr { - /// Construct new QR code view from `code`. - pub fn new(code: qrcodegen::QrCode, key: CacheKey, title: String, page: Page) -> Self { - Self { - page, - id: key.id(), - ext: key.ext, - code, - can_delete: false, - title, - } - } - - fn dark_modules(&self) -> Vec<(i32, i32)> { - dark_modules(&self.code) - } -} - -/// Burn page shown if "burn-after-reading" was selected during insertion. -#[derive(Template)] -#[template(path = "burn.html", escape = "none")] -pub struct Burn { - page: Page, - id: String, - code: qrcodegen::QrCode, -} - -impl Burn { - /// Construct new burn page linking to `id`. - pub fn new(code: qrcodegen::QrCode, id: String, page: Page) -> Self { - Self { page, id, code } - } - - fn dark_modules(&self) -> Vec<(i32, i32)> { - dark_modules(&self.code) - } -} diff --git a/src/routes/assets.rs b/src/routes/assets.rs deleted file mode 100644 index 139597f..0000000 --- a/src/routes/assets.rs +++ /dev/null @@ -1,2 +0,0 @@ - - diff --git a/src/routes/form.rs b/src/routes/form.rs index 778fcda..631ca6e 100644 --- a/src/routes/form.rs +++ b/src/routes/form.rs @@ -1,6 +1,7 @@ use crate::db::write; +use crate::handlers::html::make_error; use crate::id::Id; -use crate::{pages, AppState, Error}; +use crate::{AppState, Error}; use axum::extract::{Form, State}; use axum::response::{IntoResponse, Redirect}; use axum_extra::extract::cookie::{Cookie, SameSite, SignedCookieJar}; @@ -94,5 +95,5 @@ pub async fn insert( Ok((jar, Redirect::to(&url))) } .await - .map_err(|err| pages::make_error(err, state.page.clone())) + .map_err(|err| make_error(err, state.page.clone())) } diff --git a/src/routes/mod.rs b/src/routes/mod.rs index dbc65ba..9f409e6 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,19 +1,7 @@ -use crate::pages::Index; -use crate::AppState; -use axum::extract::State; - mod form; mod json; pub(crate) mod paste; -pub async fn index(state: State) -> Index { - Index::new( - state.max_expiration, - state.page.clone(), - state.highlighter.clone(), - ) -} - #[cfg(test)] mod tests { use crate::db::write::Entry; diff --git a/src/routes/paste.rs b/src/routes/paste.rs index 8a9b4fd..b9b0eba 100644 --- a/src/routes/paste.rs +++ b/src/routes/paste.rs @@ -1,216 +1,14 @@ -use crate::cache::{Cache, Key as CacheKey}; -use crate::crypto::Password; -use crate::db::read::Entry; -use crate::pages::{self, make_error, Burn}; use crate::routes::{form, json}; -use crate::{AppState, Database, Error, Highlighter, Page}; +use crate::AppState; use axum::body::Body; -use axum::extract::{Form, Json, Path, Query, State}; -use axum::http::header::{self, HeaderMap}; +use axum::extract::{Form, Json, State}; +use axum::http::header::HeaderMap; use axum::http::{Request, StatusCode}; -use axum::response::{AppendHeaders, IntoResponse, Redirect, Response}; +use axum::response::{IntoResponse, Response}; use axum::RequestExt; use axum_extra::extract::cookie::SignedCookieJar; use axum_extra::headers; -use axum_extra::headers::{HeaderMapExt, HeaderValue}; -use serde::Deserialize; - -#[derive(Deserialize, Debug)] -pub enum Format { - #[serde(rename(deserialize = "raw"))] - Raw, - #[serde(rename(deserialize = "qr"))] - Qr, - #[serde(rename(deserialize = "dl"))] - Dl, -} - -#[derive(Deserialize, Debug)] -pub struct QueryData { - pub fmt: Option, -} - -#[derive(Deserialize, Debug)] -pub struct PasswordForm { - password: String, -} - -fn qr_code_from(page: &Page, id: String, ext: Option) -> Result { - let name = if let Some(ext) = ext { - format!("{id}.{ext}") - } else { - id - }; - - Ok(qrcodegen::QrCode::encode_text( - page.base_url.join(&name)?.as_str(), - qrcodegen::QrCodeEcc::High, - )?) -} - -async fn get_qr( - page: Page, - key: CacheKey, - title: String, -) -> Result { - let err_page = page.clone(); - - async { - let id = key.id(); - let ext = key.ext.is_empty().then_some(key.ext.clone()); - - let qr_code = { - let page = page.clone(); - - tokio::task::spawn_blocking(move || qr_code_from(&page, id, ext)) - .await - .map_err(Error::from)?? - }; - - Ok(pages::Qr::new(qr_code, key, title, page)) - } - .await - .map_err(|err| make_error(err, err_page)) -} - -fn get_download(text: String, id: &str, extension: &str) -> impl IntoResponse { - let content_type = "text; charset=utf-8"; - let content_disposition = - HeaderValue::from_str(&format!(r#"attachment; filename="{id}.{extension}"#)) - .expect("constructing valid header value"); - - ( - AppendHeaders([ - (header::CONTENT_TYPE, HeaderValue::from_static(content_type)), - (header::CONTENT_DISPOSITION, content_disposition), - ]), - text, - ) -} - -async fn get_html( - page: Page, - cache: Cache, - highlighter: Highlighter, - key: CacheKey, - entry: Entry, - jar: SignedCookieJar, - is_protected: bool, -) -> Result { - async { - let can_delete = jar - .get("uid") - .map(|cookie| cookie.value().parse::()) - .transpose() - .map_err(|err| Error::CookieParsing(err.to_string()))? - .zip(entry.uid) - .is_some_and(|(user_uid, owner_uid)| user_uid == owner_uid); - - if let Some(html) = cache.get(&key) { - tracing::trace!(?key, "found cached item"); - return Ok(pages::Paste::new( - key, - html, - can_delete, - entry.title.unwrap_or_default(), - page.clone(), - ) - .into_response()); - } - - // TODO: turn this upside-down, i.e. cache it but only return a cached version if we were able - // to decrypt the content. Highlighting is probably still much slower than decryption. - let can_be_cached = !entry.must_be_deleted; - let ext = key.ext.clone(); - let title = entry.title.clone().unwrap_or_default(); - let html = highlighter.highlight(entry, ext).await?; - - if can_be_cached && !is_protected { - tracing::trace!(?key, "cache item"); - cache.put(key.clone(), html.clone()); - } - - Ok(pages::Paste::new(key, html, can_delete, title, page.clone()).into_response()) - } - .await - .map_err(|err| make_error(err, page)) -} - -#[expect(clippy::too_many_arguments)] -pub async fn get( - State(cache): State, - State(page): State, - State(db): State, - State(highlighter): State, - Path(id): Path, - headers: HeaderMap, - jar: SignedCookieJar, - Query(query): Query, - form: Option>, -) -> Result { - async { - let password = form - .map(|form| form.password.clone()) - .or_else(|| { - headers - .get("Wastebin-Password") - .and_then(|header| header.to_str().ok().map(std::string::ToString::to_string)) - }) - .map(|password| Password::from(password.as_bytes().to_vec())); - let key: CacheKey = id.parse()?; - - match db.get(key.id, password.clone()).await { - Err(Error::NoPassword) => { - Ok(pages::Encrypted::new(key, &query, page.clone()).into_response()) - } - Err(err) => Err(err), - Ok(entry) => { - if entry.must_be_deleted { - db.delete(key.id).await?; - } - - match query.fmt { - Some(Format::Raw) => return Ok(entry.text.into_response()), - Some(Format::Qr) => { - return Ok(get_qr( - page.clone(), - key, - entry.title.clone().unwrap_or_default(), - ) - .await - .into_response()) - } - Some(Format::Dl) => { - return Ok(get_download(entry.text, &key.id(), &key.ext).into_response()); - } - None => (), - } - - if let Some(value) = headers.get(header::ACCEPT) { - if let Ok(value) = value.to_str() { - if value.contains("text/html") { - return Ok(get_html( - page.clone(), - cache, - highlighter, - key, - entry, - jar, - password.is_some(), - ) - .await - .into_response()); - } - } - } - - Ok(entry.text.into_response()) - } - } - } - .await - .map_err(|err| make_error(err, page)) -} +use axum_extra::headers::HeaderMapExt; pub async fn insert( state: State, @@ -253,51 +51,3 @@ pub async fn insert( Err(StatusCode::UNSUPPORTED_MEDIA_TYPE.into_response()) } } - -pub async fn delete( - Path(id): Path, - State(db): State, - State(page): State, - jar: SignedCookieJar, -) -> Result { - async { - let id = id.parse()?; - let uid = db.get_uid(id).await?; - let can_delete = jar - .get("uid") - .map(|cookie| cookie.value().parse::()) - .transpose() - .map_err(|err| Error::CookieParsing(err.to_string()))? - .zip(uid) - .is_some_and(|(user_uid, db_uid)| user_uid == db_uid); - - if !can_delete { - Err(Error::Delete)?; - } - - db.delete(id).await?; - - Ok(Redirect::to("/")) - } - .await - .map_err(|err| make_error(err, page.clone())) -} - -pub async fn burn_created( - Path(id): Path, - State(page): State, -) -> Result { - async { - let id_clone = id.clone(); - let qr_code = tokio::task::spawn_blocking({ - let page = page.clone(); - move || qr_code_from(&page, id, None) - }) - .await - .map_err(Error::from)??; - - Ok(Burn::new(qr_code, id_clone, page.clone())) - } - .await - .map_err(|err| make_error(err, page)) -} diff --git a/templates/encrypted.html b/templates/encrypted.html index 73808ed..f62274d 100644 --- a/templates/encrypted.html +++ b/templates/encrypted.html @@ -2,11 +2,7 @@ {% block content %}
-{% if ext.is_empty() %} -
-{% else %} - -{% endif %} +