Line data Source code
1 : use crate::responders::ResponseError;
2 : use crate::{
3 : WebContext,
4 : article::{Article, ArticleCreateParams, ArticleUpdateParams, create_article, latest_articles},
5 : session::session_new,
6 : user::{User, verify_user},
7 : };
8 : use axum::body::Body;
9 : use axum::response::Response;
10 : use axum::{
11 : Form, Json,
12 : extract::{Multipart, Path, State},
13 : response::{Html, Redirect},
14 : };
15 : use axum_extra::extract::cookie::{Cookie, SignedCookieJar};
16 : use http::{self, StatusCode};
17 : use minijinja::context;
18 : use serde::{Deserialize, Serialize};
19 :
20 : #[cfg(test)]
21 : mod tests;
22 :
23 0 : pub(crate) async fn new_sign_in(
24 0 : State(state): State<WebContext>,
25 0 : ) -> Result<Html<String>, ResponseError> {
26 0 : let tmpl = state.templates.get_template("new_sign_in.html")?;
27 0 : let content = tmpl.render(context!())?;
28 0 : Ok(Html(content))
29 0 : }
30 :
31 : #[derive(Debug, Deserialize)]
32 : pub(crate) struct SignInParams {
33 : username: String,
34 : password: String,
35 : }
36 :
37 : #[axum::debug_handler]
38 5 : pub(crate) async fn sign_in(
39 5 : State(state): State<WebContext>,
40 5 : jar: SignedCookieJar,
41 5 : Form(params): Form<SignInParams>,
42 5 : ) -> Result<(SignedCookieJar, Redirect), ResponseError> {
43 : // - [X] Extract the username and password.
44 : // - [X] Checks their validity.
45 : // - If valid:
46 : // - [x] create a session for the user
47 : // - [x] store the session in a cookie
48 : // - [ ] redirect the user to / or their redirect_to query parameter
49 : // - If invalid:
50 : // - [x] return 401.
51 5 : let db = state.db.lock()?;
52 5 : let ret = verify_user(&db, params.username, params.password.as_bytes())?;
53 5 : if let Some(user) = ret {
54 4 : let session = session_new(&db, user.user_id)?;
55 : // FIXME: Add .secure(true) to restrict to HTTPS connections only
56 4 : let cookie =
57 4 : Cookie::build(("sid", session.session_id)).path("/").http_only(false).secure(false);
58 4 : Ok((jar.add(cookie), Redirect::to("/")))
59 : } else {
60 1 : Err(ResponseError::unauthorized())
61 : }
62 5 : }
63 :
64 : #[axum::debug_handler]
65 1 : pub(crate) async fn index(
66 1 : State(state): State<WebContext>,
67 1 : user: Option<User>,
68 1 : ) -> Result<Html<String>, ResponseError> {
69 1 : let tmpl = state.templates.get_template("index.html")?;
70 1 : let db = state.db.lock()?;
71 1 : let articles: Vec<Article> = latest_articles(&db)?;
72 1 : let content = tmpl.render(context!(latest_articles => articles, user => user))?;
73 1 : Ok(Html(content))
74 1 : }
75 :
76 : #[axum::debug_handler]
77 1 : pub(crate) async fn new_article(
78 1 : State(state): State<WebContext>,
79 1 : user: User,
80 1 : ) -> Result<Html<String>, ResponseError> {
81 1 : let tmpl = state.templates.get_template("new_article.html")?;
82 1 : let content = tmpl.render(context!(user => user))?;
83 1 : Ok(Html(content))
84 1 : }
85 :
86 : #[axum::debug_handler]
87 1 : pub(crate) async fn article_create(
88 1 : State(state): State<WebContext>,
89 1 : user: User,
90 1 : Form(params): Form<ArticleCreateParams>,
91 1 : ) -> Result<Redirect, ResponseError> {
92 1 : let db = state.db.lock()?;
93 1 : let article_id = create_article(&db, params, user.user_id)?;
94 1 : let uri = format!("/article/{}/", article_id);
95 1 : Ok(Redirect::to(&uri))
96 1 : }
97 :
98 : #[axum::debug_handler]
99 2 : pub(crate) async fn article_show(
100 2 : State(state): State<WebContext>,
101 2 : Path(article_id): Path<i64>,
102 2 : user: Option<User>,
103 2 : ) -> Result<Html<String>, ResponseError> {
104 2 : let db = state.db.lock()?;
105 2 : let article = crate::article::find_by_id(&db, article_id)?;
106 1 : let tmpl = state.templates.get_template("show_article.html")?;
107 1 : let content = tmpl.render(context!(article => article, user => user))?;
108 1 : Ok(Html(content))
109 2 : }
110 :
111 : #[axum::debug_handler]
112 0 : pub(crate) async fn article_edit(
113 0 : State(state): State<WebContext>,
114 0 : Path(article_id): Path<i64>,
115 0 : user: User,
116 0 : ) -> Result<Html<String>, ResponseError> {
117 0 : let db = state.db.lock()?;
118 0 : let article = crate::article::find_by_id(&db, article_id)?;
119 0 : let tmpl = state.templates.get_template("edit_article.html")?;
120 0 : let content = tmpl.render(context!(article => article, user => user))?;
121 0 : Ok(Html(content))
122 0 : }
123 :
124 : #[axum::debug_handler]
125 0 : pub(crate) async fn article_update(
126 0 : State(state): State<WebContext>,
127 0 : user: User,
128 0 : Form(params): Form<ArticleUpdateParams>,
129 0 : ) -> Result<Redirect, ResponseError> {
130 0 : let db = state.db.lock()?;
131 0 : crate::article::update_article(&db, ¶ms, user.user_id)?;
132 0 : let uri = format!("/article/{}/", params.page_id);
133 0 : Ok(Redirect::to(&uri))
134 0 : }
135 :
136 : #[derive(Serialize)]
137 : pub struct UploadResponse {
138 : file_id: i64,
139 : filename: String,
140 : url: String,
141 : }
142 : // TODO: Upload default body to 10MiB.
143 : // Cf. https://docs.rs/axum/latest/axum/extract/struct.Multipart.html#large-files
144 : // 2025-05-04T15:02:49.683196Z INFO bliki::handler: field name: "media"
145 : #[axum::debug_handler]
146 1 : pub(crate) async fn upload(
147 1 : State(state): State<WebContext>,
148 1 : _user: User,
149 1 : mut multipart: Multipart,
150 1 : ) -> Result<Json<UploadResponse>, ResponseError> {
151 : // We only have one entry.
152 1 : if let Some(field) = multipart.next_field().await? {
153 : // asset name == "media"; can we check the filename?. We can check the ContentType for the mimetype
154 1 : let _name = field.name().ok_or(ResponseError::server_error())?;
155 : // TODO: Response with 422 if filename or content_type is not present.
156 1 : let filename = field.file_name().ok_or(ResponseError::server_error())?.to_string();
157 1 : let content_type = field.content_type().ok_or(ResponseError::server_error())?.to_string();
158 1 : let data = field.bytes().await?;
159 1 : let db = state.db.lock()?;
160 1 : let file_id =
161 1 : crate::files::store_file(&db, state.upload_dir, data, filename.clone(), content_type)?;
162 1 : let url = format!("/media/{}", file_id);
163 1 : let payload = UploadResponse { file_id, filename, url };
164 1 : Ok(Json(payload))
165 : } else {
166 0 : Err(ResponseError {
167 0 : status: StatusCode::BAD_REQUEST,
168 0 : msg: "missing media file".to_string(),
169 0 : format: crate::responders::Format::Json,
170 0 : })
171 : }
172 1 : }
173 :
174 : #[axum::debug_handler]
175 1 : pub(crate) async fn media_show(
176 1 : State(state): State<WebContext>,
177 1 : Path(file_id): Path<i64>,
178 1 : ) -> Result<Response, ResponseError> {
179 1 : let db = state.db.lock()?;
180 1 : let media_file = crate::files::find_by_id(&db, file_id)?;
181 : // TODO: Stream the response instead
182 1 : let path = state.upload_dir.join(media_file.digest);
183 1 : let contents = std::fs::read(path)?;
184 1 : let body = Body::from(contents);
185 1 : let res = Response::builder().header("Content-Type", &media_file.content_type).body(body)?;
186 1 : Ok(res)
187 1 : }
|