Skip to content

Commit 7837f1e

Browse files
committed
Restructure route handler setup
1 parent 79d7c3d commit 7837f1e

File tree

26 files changed

+1155
-1056
lines changed

26 files changed

+1155
-1056
lines changed

src/assets.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ impl Asset {
7878
}
7979

8080
/// Collection of light and dark CSS and main UI style CSS derived from them.
81-
pub struct CssAssets {
81+
pub struct Css {
8282
/// Main UI CSS stylesheet.
8383
pub style: Asset,
8484
/// Light theme colors.
@@ -98,7 +98,7 @@ impl ColorExt for Color {
9898
}
9999
}
100100

101-
impl CssAssets {
101+
impl Css {
102102
/// Create CSS assets for `theme`.
103103
pub fn new(theme: Theme) -> Self {
104104
#[derive(Template)]

src/cache.rs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
use crate::errors::Error;
22
use crate::highlight::Html;
33
use crate::id::Id;
4+
use std::fmt::Display;
45
use std::num::NonZeroUsize;
56
use std::str::FromStr;
67
use std::sync::{Arc, Mutex};
@@ -41,6 +42,16 @@ impl Key {
4142
}
4243
}
4344

45+
impl Display for Key {
46+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
47+
if self.ext.is_empty() {
48+
write!(f, "{}", self.id)
49+
} else {
50+
write!(f, "{}.{}", self.id, self.ext)
51+
}
52+
}
53+
}
54+
4455
impl FromStr for Key {
4556
type Err = Error;
4657

src/db.rs

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -346,6 +346,23 @@ impl Database {
346346
Ok(uid)
347347
}
348348

349+
/// Get title of a paste.
350+
pub async fn get_title(&self, id: Id) -> Result<String, Error> {
351+
let conn = self.conn.clone();
352+
let id = id.as_u32();
353+
354+
let title = spawn_blocking(move || {
355+
conn.lock().query_row(
356+
"SELECT title FROM entries WHERE id=?1",
357+
params![id],
358+
|row| row.get(0),
359+
)
360+
})
361+
.await??;
362+
363+
Ok(title)
364+
}
365+
349366
/// Delete `id`.
350367
pub async fn delete(&self, id: Id) -> Result<(), Error> {
351368
let conn = self.conn.clone();

src/handlers/delete.rs

Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
use crate::handlers::html::{make_error, ErrorResponse};
2+
use crate::{Database, Error, Page};
3+
use axum::extract::{Path, State};
4+
use axum::response::Redirect;
5+
use axum_extra::extract::SignedCookieJar;
6+
7+
pub async fn delete(
8+
Path(id): Path<String>,
9+
State(db): State<Database>,
10+
State(page): State<Page>,
11+
jar: SignedCookieJar,
12+
) -> Result<Redirect, ErrorResponse> {
13+
async {
14+
let id = id.parse()?;
15+
let uid = db.get_uid(id).await?;
16+
let can_delete = jar
17+
.get("uid")
18+
.map(|cookie| cookie.value().parse::<i64>())
19+
.transpose()
20+
.map_err(|err| Error::CookieParsing(err.to_string()))?
21+
.zip(uid)
22+
.is_some_and(|(user_uid, db_uid)| user_uid == db_uid);
23+
24+
if !can_delete {
25+
Err(Error::Delete)?;
26+
}
27+
28+
db.delete(id).await?;
29+
30+
Ok(Redirect::to("/"))
31+
}
32+
.await
33+
.map_err(|err| make_error(err, page.clone()))
34+
}
35+
36+
#[cfg(test)]
37+
mod tests {
38+
use crate::test_helpers::Client;
39+
use reqwest::StatusCode;
40+
41+
#[tokio::test]
42+
async fn delete_via_link() -> Result<(), Box<dyn std::error::Error>> {
43+
let client = Client::new().await;
44+
45+
let data = crate::handlers::insert::form::Entry {
46+
text: "FooBarBaz".to_string(),
47+
extension: None,
48+
expires: "0".to_string(),
49+
password: "".to_string(),
50+
title: "".to_string(),
51+
};
52+
53+
let res = client.post("/").form(&data).send().await?;
54+
let uid_cookie = res.cookies().find(|cookie| cookie.name() == "uid").unwrap();
55+
assert_eq!(uid_cookie.name(), "uid");
56+
assert!(uid_cookie.value().len() > 40);
57+
assert_eq!(uid_cookie.path(), None);
58+
assert!(uid_cookie.http_only());
59+
assert!(uid_cookie.same_site_strict());
60+
assert!(!uid_cookie.secure());
61+
assert_eq!(uid_cookie.domain(), None);
62+
assert_eq!(uid_cookie.expires(), None);
63+
assert_eq!(uid_cookie.max_age(), None);
64+
65+
assert_eq!(res.status(), StatusCode::SEE_OTHER);
66+
67+
let location = res.headers().get("location").unwrap().to_str()?;
68+
let id = location.replace("/", "");
69+
70+
let res = client.get(&format!("/delete/{id}")).send().await?;
71+
assert_eq!(res.status(), StatusCode::SEE_OTHER);
72+
73+
let res = client.get(&format!("/{id}")).send().await?;
74+
assert_eq!(res.status(), StatusCode::NOT_FOUND);
75+
76+
Ok(())
77+
}
78+
}

src/handlers/download.rs

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
use crate::cache::Key;
2+
use crate::crypto::Password;
3+
use crate::handlers::html::{make_error, ErrorResponse, PasswordInput};
4+
use crate::{Database, Error, Page};
5+
use axum::extract::{Form, Path, State};
6+
use axum::http::header;
7+
use axum::response::{AppendHeaders, IntoResponse, Response};
8+
use axum_extra::headers::HeaderValue;
9+
use serde::Deserialize;
10+
11+
#[derive(Deserialize, Debug)]
12+
pub struct PasswordForm {
13+
password: String,
14+
}
15+
16+
/// GET handler for raw content of a paste.
17+
pub async fn download(
18+
Path(id): Path<String>,
19+
State(db): State<Database>,
20+
State(page): State<Page>,
21+
form: Option<Form<PasswordForm>>,
22+
) -> Result<Response, ErrorResponse> {
23+
async {
24+
let password = form.map(|form| Password::from(form.password.as_bytes().to_vec()));
25+
let key: Key = id.parse()?;
26+
27+
match db.get(key.id, password.clone()).await {
28+
Err(Error::NoPassword) => Ok(PasswordInput {
29+
page: page.clone(),
30+
id: key.id.to_string(),
31+
}
32+
.into_response()),
33+
Err(err) => Err(err),
34+
Ok(entry) => {
35+
if entry.must_be_deleted {
36+
db.delete(key.id).await?;
37+
}
38+
39+
Ok(get_download(entry.text, &key.id(), &key.ext).into_response())
40+
}
41+
}
42+
}
43+
.await
44+
.map_err(|err| make_error(err, page))
45+
}
46+
47+
fn get_download(text: String, id: &str, extension: &str) -> impl IntoResponse {
48+
let content_type = "text; charset=utf-8";
49+
let content_disposition =
50+
HeaderValue::from_str(&format!(r#"attachment; filename="{id}.{extension}"#))
51+
.expect("constructing valid header value");
52+
53+
(
54+
AppendHeaders([
55+
(header::CONTENT_TYPE, HeaderValue::from_static(content_type)),
56+
(header::CONTENT_DISPOSITION, content_disposition),
57+
]),
58+
text,
59+
)
60+
}
61+
62+
#[cfg(test)]
63+
mod tests {
64+
use crate::test_helpers::Client;
65+
use reqwest::StatusCode;
66+
67+
#[tokio::test]
68+
async fn download() -> Result<(), Box<dyn std::error::Error>> {
69+
let client = Client::new().await;
70+
71+
let data = crate::handlers::insert::form::Entry {
72+
text: "FooBarBaz".to_string(),
73+
extension: None,
74+
expires: "0".to_string(),
75+
password: "".to_string(),
76+
title: "".to_string(),
77+
};
78+
79+
let res = client.post("/").form(&data).send().await?;
80+
assert_eq!(res.status(), StatusCode::SEE_OTHER);
81+
82+
let location = res.headers().get("location").unwrap().to_str()?;
83+
let res = client.get(&format!("{location}?dl=cpp")).send().await?;
84+
assert_eq!(res.status(), StatusCode::OK);
85+
86+
let content = res.text().await?;
87+
assert_eq!(content, "FooBarBaz");
88+
89+
Ok(())
90+
}
91+
}

src/handlers/html/burn.rs

Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
use crate::cache::Key;
2+
use crate::handlers::html::qr::{code_from, dark_modules};
3+
use crate::handlers::html::{make_error, ErrorResponse};
4+
use crate::{Error, Page};
5+
use askama::Template;
6+
use axum::extract::{Path, State};
7+
8+
/// GET handler for the burn page.
9+
pub async fn burn(Path(id): Path<String>, State(page): State<Page>) -> Result<Burn, ErrorResponse> {
10+
async {
11+
let code = tokio::task::spawn_blocking({
12+
let page = page.clone();
13+
let id = id.clone();
14+
move || code_from(&page.base_url, &id)
15+
})
16+
.await
17+
.map_err(Error::from)??;
18+
19+
let key: Key = id.parse()?;
20+
21+
Ok(Burn {
22+
page: page.clone(),
23+
key,
24+
code,
25+
})
26+
}
27+
.await
28+
.map_err(|err| make_error(err, page))
29+
}
30+
31+
/// Burn page shown if "burn-after-reading" was selected during insertion.
32+
#[derive(Template)]
33+
#[template(path = "burn.html", escape = "none")]
34+
pub struct Burn {
35+
page: Page,
36+
key: Key,
37+
code: qrcodegen::QrCode,
38+
}
39+
40+
impl Burn {
41+
fn dark_modules(&self) -> Vec<(i32, i32)> {
42+
dark_modules(&self.code)
43+
}
44+
}
45+
46+
#[cfg(test)]
47+
mod tests {
48+
use crate::test_helpers::Client;
49+
use reqwest::{header, StatusCode};
50+
use serde::Serialize;
51+
52+
#[tokio::test]
53+
async fn burn() -> Result<(), Box<dyn std::error::Error>> {
54+
let client = Client::new().await;
55+
56+
let data = crate::handlers::insert::form::Entry {
57+
text: "FooBarBaz".to_string(),
58+
extension: None,
59+
expires: "burn".to_string(),
60+
password: "".to_string(),
61+
title: "".to_string(),
62+
};
63+
64+
let res = client.post("/").form(&data).send().await?;
65+
assert_eq!(res.status(), StatusCode::SEE_OTHER);
66+
67+
let location = res.headers().get("location").unwrap().to_str()?;
68+
69+
// Location is the `/burn/foo` page not the paste itself, so remove the prefix.
70+
let location = location.replace("burn/", "");
71+
72+
let res = client
73+
.get(&location)
74+
.header(header::ACCEPT, "text/html; charset=utf-8")
75+
.send()
76+
.await?;
77+
78+
assert_eq!(res.status(), StatusCode::OK);
79+
80+
let res = client
81+
.get(&location)
82+
.header(header::ACCEPT, "text/html; charset=utf-8")
83+
.send()
84+
.await?;
85+
86+
assert_eq!(res.status(), StatusCode::NOT_FOUND);
87+
88+
Ok(())
89+
}
90+
91+
#[tokio::test]
92+
async fn burn_encrypted() -> Result<(), Box<dyn std::error::Error>> {
93+
let client = Client::new().await;
94+
let password = "asd";
95+
96+
let data = crate::handlers::insert::form::Entry {
97+
text: "FooBarBaz".to_string(),
98+
extension: None,
99+
expires: "burn".to_string(),
100+
password: password.to_string(),
101+
title: "".to_string(),
102+
};
103+
104+
let res = client.post("/").form(&data).send().await?;
105+
assert_eq!(res.status(), StatusCode::SEE_OTHER);
106+
107+
let location = res.headers().get("location").unwrap().to_str()?;
108+
109+
// Location is the `/burn/foo` page not the paste itself, so remove the prefix.
110+
let location = location.replace("burn/", "");
111+
112+
let res = client
113+
.get(&location)
114+
.header(header::ACCEPT, "text/html; charset=utf-8")
115+
.send()
116+
.await?;
117+
118+
assert_eq!(res.status(), StatusCode::OK);
119+
120+
#[derive(Debug, Serialize)]
121+
struct Form {
122+
password: String,
123+
}
124+
125+
let data = Form {
126+
password: password.to_string(),
127+
};
128+
129+
let res = client
130+
.post(&location)
131+
.form(&data)
132+
.header(header::ACCEPT, "text/html; charset=utf-8")
133+
.send()
134+
.await?;
135+
136+
assert_eq!(res.status(), StatusCode::OK);
137+
138+
let res = client
139+
.get(&location)
140+
.header(header::ACCEPT, "text/html; charset=utf-8")
141+
.send()
142+
.await?;
143+
144+
assert_eq!(res.status(), StatusCode::NOT_FOUND);
145+
146+
Ok(())
147+
}
148+
}

0 commit comments

Comments
 (0)