diff --git a/.github/workflows/rust.yml b/.github/workflows/rust.yml index 40931f1..57c8dfd 100644 --- a/.github/workflows/rust.yml +++ b/.github/workflows/rust.yml @@ -18,3 +18,4 @@ jobs: components: clippy - run: cargo clippy -- -W clippy::pedantic - run: cargo test + - run: WASTEBIN_BASE_URL="http://127.0.0.1:8080/wastebin" cargo test # port is not relevant diff --git a/src/env.rs b/src/env.rs index c69c0e2..8456c8b 100644 --- a/src/env.rs +++ b/src/env.rs @@ -47,6 +47,25 @@ pub enum Error { HttpTimeout(ParseIntError), } +pub struct BasePath(String); + +impl BasePath { + pub fn path(&self) -> &str { + &self.0 + } + + pub fn join(&self, s: &str) -> String { + let b = &self.0; + format!("{b}{s}") + } +} + +impl Default for BasePath { + fn default() -> Self { + BasePath("/".to_string()) + } +} + /// Retrieve reference to initialized metadata. pub fn metadata() -> &'static Metadata<'static> { static DATA: OnceLock = OnceLock::new(); @@ -119,6 +138,40 @@ pub fn base_url() -> Result, Error> { Ok(result) } +pub fn base_path() -> &'static BasePath { + // NOTE: This relies on `VAR_BASE_URL` but repeates parsing to handle errors. + static BASE_PATH: OnceLock = OnceLock::new(); + + BASE_PATH.get_or_init(|| { + std::env::var(VAR_BASE_URL).map_or_else( + |err| { + match err { + VarError::NotPresent => (), + VarError::NotUnicode(_) => { + tracing::warn!("`VAR_BASE_URL` not Unicode, defaulting to '/'") + } + }; + BasePath::default() + }, + |var| match url::Url::parse(&var) { + Ok(url) => { + let path = url.path(); + + if path.ends_with('/') { + BasePath(path.to_string()) + } else { + BasePath(format!("{path}/")) + } + } + Err(err) => { + tracing::error!("error parsing `VAR_BASE_URL`, defaulting to '/': {err}"); + BasePath::default() + } + }, + ) + }) +} + pub fn password_hash_salt() -> String { std::env::var(VAR_PASSWORD_SALT).unwrap_or_else(|_| "somesalt".to_string()) } diff --git a/src/highlight.rs b/src/highlight.rs index 30ac368..c0f6656 100644 --- a/src/highlight.rs +++ b/src/highlight.rs @@ -73,7 +73,7 @@ pub struct Data<'a> { impl<'a> Css<'a> { fn new(name: &str, content: &'a str) -> Self { let name = format!( - "/{name}.{}.css", + "{name}.{}.css", hex::encode(Sha256::digest(content.as_bytes())) .get(0..16) .expect("at least 16 characters") diff --git a/src/id.rs b/src/id.rs index f8ba49a..313044f 100644 --- a/src/id.rs +++ b/src/id.rs @@ -27,7 +27,7 @@ impl Id { entry .extension .as_ref() - .map_or_else(|| format!("/{self}"), |ext| format!("/{self}.{ext}")) + .map_or_else(|| format!("{self}"), |ext| format!("{self}.{ext}")) } } diff --git a/src/main.rs b/src/main.rs index e3a3233..9cafdef 100644 --- a/src/main.rs +++ b/src/main.rs @@ -15,6 +15,8 @@ use tower_http::timeout::TimeoutLayer; use tower_http::trace::TraceLayer; use url::Url; +use self::env::base_path; + mod cache; mod crypto; mod db; @@ -42,14 +44,17 @@ impl FromRef for Key { } pub(crate) fn make_app(max_body_size: usize, timeout: Duration) -> Router { - Router::new().merge(routes::routes()).layer( - ServiceBuilder::new() - .layer(DefaultBodyLimit::max(max_body_size)) - .layer(DefaultBodyLimit::disable()) - .layer(CompressionLayer::new()) - .layer(TraceLayer::new_for_http()) - .layer(TimeoutLayer::new(timeout)), - ) + let base_path = base_path(); + Router::new() + .nest(base_path.path(), routes::routes()) + .layer( + ServiceBuilder::new() + .layer(DefaultBodyLimit::max(max_body_size)) + .layer(DefaultBodyLimit::disable()) + .layer(CompressionLayer::new()) + .layer(TraceLayer::new_for_http()) + .layer(TimeoutLayer::new(timeout)), + ) } async fn shutdown_signal() { diff --git a/src/pages.rs b/src/pages.rs index 338d4c9..6184fa7 100644 --- a/src/pages.rs +++ b/src/pages.rs @@ -11,6 +11,7 @@ use std::default::Default; #[template(path = "error.html")] pub struct Error<'a> { meta: &'a env::Metadata<'a>, + base_path: &'static env::BasePath, error: String, } @@ -21,6 +22,7 @@ impl From for ErrorResponse<'_> { fn from(err: crate::Error) -> Self { let html = Error { meta: env::metadata(), + base_path: env::base_path(), error: err.to_string(), }; @@ -33,12 +35,14 @@ impl From for ErrorResponse<'_> { #[template(path = "index.html")] pub struct Index<'a> { meta: &'a env::Metadata<'a>, + base_path: &'static env::BasePath, } impl<'a> Default for Index<'a> { fn default() -> Self { Self { meta: env::metadata(), + base_path: env::base_path(), } } } @@ -48,6 +52,7 @@ impl<'a> Default for Index<'a> { #[template(path = "formatted.html")] pub struct Paste<'a> { meta: &'a env::Metadata<'a>, + base_path: &'static env::BasePath, id: String, ext: String, can_delete: bool, @@ -61,6 +66,7 @@ impl<'a> Paste<'a> { Self { meta: env::metadata(), + base_path: env::base_path(), id: key.id(), ext: key.ext, can_delete, @@ -74,6 +80,7 @@ impl<'a> Paste<'a> { #[template(path = "encrypted.html")] pub struct Encrypted<'a> { meta: &'a env::Metadata<'a>, + base_path: &'static env::BasePath, id: String, ext: String, query: String, @@ -91,6 +98,7 @@ impl<'a> Encrypted<'a> { Self { meta: env::metadata(), + base_path: env::base_path(), id: key.id(), ext: key.ext, query, @@ -103,6 +111,7 @@ impl<'a> Encrypted<'a> { #[template(path = "qr.html", escape = "none")] pub struct Qr<'a> { meta: &'a env::Metadata<'a>, + base_path: &'static env::BasePath, id: String, ext: String, can_delete: bool, @@ -114,6 +123,7 @@ impl<'a> Qr<'a> { pub fn new(code: qrcodegen::QrCode, key: CacheKey) -> Self { Self { meta: env::metadata(), + base_path: env::base_path(), id: key.id(), ext: key.ext, code, @@ -136,6 +146,7 @@ impl<'a> Qr<'a> { #[template(path = "burn.html")] pub struct Burn<'a> { meta: &'a env::Metadata<'a>, + base_path: &'static env::BasePath, id: String, } @@ -144,6 +155,7 @@ impl<'a> Burn<'a> { pub fn new(id: String) -> Self { Self { meta: env::metadata(), + base_path: env::base_path(), id, } } diff --git a/src/routes/assets.rs b/src/routes/assets.rs index 0f7fe59..a43e990 100644 --- a/src/routes/assets.rs +++ b/src/routes/assets.rs @@ -33,9 +33,10 @@ fn favicon() -> impl IntoResponse { } pub fn routes() -> Router { + let style_name = &data().style.name; Router::new() .route("/favicon.png", get(|| async { favicon() })) - .route(&data().style.name, get(|| async { style_css() })) + .route(&format!("/{style_name}"), get(|| async { style_css() })) .route("/dark.css", get(|| async { dark_css() })) .route("/light.css", get(|| async { light_css() })) } diff --git a/src/routes/form.rs b/src/routes/form.rs index 9d23527..853a396 100644 --- a/src/routes/form.rs +++ b/src/routes/form.rs @@ -1,4 +1,5 @@ use crate::db::write; +use crate::env::base_path; use crate::id::Id; use crate::{pages, AppState, Error}; use axum::extract::{Form, State}; @@ -62,16 +63,17 @@ pub async fn insert( let mut entry: write::Entry = entry.into(); entry.uid = Some(uid); - let url = id.to_url_path(&entry); + let mut url = id.to_url_path(&entry); + let burn_after_reading = entry.burn_after_reading.unwrap_or(false); + if burn_after_reading { + url = format!("burn/{url}"); + } + + let url_with_base = base_path().join(&url); state.db.insert(id, entry).await?; let jar = jar.add(Cookie::new("uid", uid.to_string())); - - if burn_after_reading { - Ok((jar, Redirect::to(&format!("/burn{url}")))) - } else { - Ok((jar, Redirect::to(&url))) - } + Ok((jar, Redirect::to(&url_with_base))) } diff --git a/src/routes/json.rs b/src/routes/json.rs index 7958b04..f7ae9c6 100644 --- a/src/routes/json.rs +++ b/src/routes/json.rs @@ -1,4 +1,5 @@ use crate::db::write; +use crate::env::base_path; use crate::errors::{Error, JsonErrorResponse}; use crate::id::Id; use crate::AppState; @@ -47,8 +48,9 @@ pub async fn insert( .into(); let entry: write::Entry = entry.into(); - let path = id.to_url_path(&entry); + let url = id.to_url_path(&entry); + let path = base_path().join(&url); state.db.insert(id, entry).await?; Ok(Json::from(RedirectResponse { path })) diff --git a/src/routes/mod.rs b/src/routes/mod.rs index f3146bb..520e0ae 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -23,6 +23,7 @@ pub fn routes() -> Router { #[cfg(test)] mod tests { use crate::db::write::Entry; + use crate::env::base_path; use crate::routes; use crate::test_helpers::{make_app, Client}; use http::StatusCode; @@ -33,7 +34,7 @@ mod tests { async fn unknown_paste() -> Result<(), Box> { let client = Client::new(make_app()?).await; - let res = client.get("/000000").send().await?; + let res = client.get(&base_path().join("000000")).send().await?; assert_eq!(res.status(), StatusCode::NOT_FOUND); Ok(()) @@ -50,7 +51,7 @@ mod tests { password: "".to_string(), }; - let res = client.post("/").form(&data).send().await?; + let res = client.post(base_path().path()).form(&data).send().await?; assert_eq!(res.status(), StatusCode::SEE_OTHER); let location = res.headers().get("location").unwrap().to_str()?; @@ -98,16 +99,16 @@ mod tests { password: "".to_string(), }; - let res = client.post("/").form(&data).send().await?; + let res = client.post(base_path().path()).form(&data).send().await?; assert_eq!(res.status(), StatusCode::SEE_OTHER); let location = res.headers().get("location").unwrap().to_str()?; - // Location is the `/burn/foo` page not the paste itself, so ignore the prefix. - let location = location.split_at(5).1; + // Location is the `/burn/foo` page not the paste itself, so remove the prefix. + let location = location.replace("burn/", ""); let res = client - .get(location) + .get(&location) .header(header::ACCEPT, "text/html; charset=utf-8") .send() .await?; @@ -115,7 +116,7 @@ mod tests { assert_eq!(res.status(), StatusCode::OK); let res = client - .get(location) + .get(&location) .header(header::ACCEPT, "text/html; charset=utf-8") .send() .await?; @@ -137,16 +138,16 @@ mod tests { password: password.to_string(), }; - let res = client.post("/").form(&data).send().await?; + let res = client.post(base_path().path()).form(&data).send().await?; assert_eq!(res.status(), StatusCode::SEE_OTHER); let location = res.headers().get("location").unwrap().to_str()?; - // Location is the `/burn/foo` page not the paste itself, so ignore the prefix. - let location = location.split_at(5).1; + // Location is the `/burn/foo` page not the paste itself, so remove the prefix. + let location = location.replace("burn/", ""); let res = client - .get(location) + .get(&location) .header(header::ACCEPT, "text/html; charset=utf-8") .send() .await?; @@ -163,7 +164,7 @@ mod tests { }; let res = client - .post(location) + .post(&location) .form(&data) .header(header::ACCEPT, "text/html; charset=utf-8") .send() @@ -172,7 +173,7 @@ mod tests { assert_eq!(res.status(), StatusCode::OK); let res = client - .get(location) + .get(&location) .header(header::ACCEPT, "text/html; charset=utf-8") .send() .await?; @@ -191,7 +192,7 @@ mod tests { ..Default::default() }; - let res = client.post("/").json(&entry).send().await?; + let res = client.post(base_path().path()).json(&entry).send().await?; assert_eq!(res.status(), StatusCode::OK); let payload = res.json::().await?; @@ -214,7 +215,7 @@ mod tests { ..Default::default() }; - let res = client.post("/").json(&entry).send().await?; + let res = client.post(base_path().path()).json(&entry).send().await?; assert_eq!(res.status(), StatusCode::OK); let payload = res.json::().await?; @@ -242,16 +243,21 @@ mod tests { password: "".to_string(), }; - let res = client.post("/").form(&data).send().await?; + let res = client.post(base_path().path()).form(&data).send().await?; let uid_cookie = res.cookies().find(|cookie| cookie.name() == "uid"); assert!(uid_cookie.is_some()); assert_eq!(res.status(), StatusCode::SEE_OTHER); let location = res.headers().get("location").unwrap().to_str()?; - let res = client.get(&format!("/delete{location}")).send().await?; + let id = location.replace(base_path().path(), ""); + + let res = client + .get(&base_path().join(&format!("delete/{id}"))) + .send() + .await?; assert_eq!(res.status(), StatusCode::SEE_OTHER); - let res = client.get(location).send().await?; + let res = client.get(&base_path().join(&id)).send().await?; assert_eq!(res.status(), StatusCode::NOT_FOUND); Ok(()) @@ -268,7 +274,7 @@ mod tests { password: "".to_string(), }; - let res = client.post("/").form(&data).send().await?; + let res = client.post(base_path().path()).form(&data).send().await?; assert_eq!(res.status(), StatusCode::SEE_OTHER); let location = res.headers().get("location").unwrap().to_str()?; diff --git a/src/routes/paste.rs b/src/routes/paste.rs index 6f4795e..12a5334 100644 --- a/src/routes/paste.rs +++ b/src/routes/paste.rs @@ -1,6 +1,7 @@ use crate::cache::Key as CacheKey; use crate::crypto::Password; use crate::db::read::Entry; +use crate::env::base_path; use crate::highlight::Html; use crate::routes::{form, json}; use crate::{pages, AppState, Error}; @@ -232,5 +233,5 @@ pub async fn delete( state.db.delete(id).await?; - Ok(Redirect::to("/")) + Ok(Redirect::to(base_path().path())) } diff --git a/src/test_helpers.rs b/src/test_helpers.rs index c9d1f32..8c7080a 100644 --- a/src/test_helpers.rs +++ b/src/test_helpers.rs @@ -1,5 +1,6 @@ use crate::cache::Cache; use crate::db::{self, Database}; +use crate::env::base_url; use axum::extract::Request; use axum::response::Response; use axum::Router; @@ -56,7 +57,7 @@ pub(crate) fn make_app() -> Result> { let db = Database::new(db::Open::Memory)?; let cache = Cache::new(NonZeroUsize::new(128).unwrap()); let key = Key::generate(); - let base_url = None; + let base_url = base_url().unwrap(); let state = crate::AppState { db, cache, diff --git a/templates/base.html b/templates/base.html index 3f0805d..c89fdc4 100644 --- a/templates/base.html +++ b/templates/base.html @@ -5,13 +5,13 @@ {{ meta.title }} - - + + {% block head %}{% endblock %}
- +