rb/routing/api/
questions.rs

1use crate::database::Database;
2use crate::model::{anonymous_profile, DatabaseError, QuestionCreate};
3use axum::http::{HeaderMap, HeaderValue};
4use authbeam::model::{IpBlockCreate, NotificationCreate};
5use carp::CarpGraph;
6use databeam::prelude::DefaultReturn;
7
8use axum::response::{IntoResponse, Redirect};
9use axum::{
10    extract::{Path, State},
11    routing::{delete, get, post},
12    Json, Router,
13};
14use hcaptcha_no_wasm::Hcaptcha;
15
16use axum_extra::extract::cookie::CookieJar;
17use pathbufd::pathd;
18
19pub fn routes(database: Database) -> Router {
20    Router::new()
21        .route("/", post(create_request))
22        .route("/{id}", get(get_request))
23        .route("/{id}", delete(delete_request))
24        .route("/inbox/{id}/clear", post(delete_inbox_request))
25        .route("/inbox/me/clear", post(delete_my_inbox_request))
26        .route("/{id}/report", post(report_request))
27        .route("/{id}/ipblock", post(ipblock_request))
28        .route("/{id}/media.svg", get(carpgraph_svg_request))
29        .route("/{id}/media.carpgraph", get(carpgraph_request))
30        // ...
31        .with_state(database)
32}
33
34// routes
35
36/// [`Database::create_question`]
37pub async fn create_request(
38    jar: CookieJar,
39    headers: HeaderMap,
40    State(database): State<Database>,
41    Json(req): Json<QuestionCreate>,
42) -> impl IntoResponse {
43    // get user from token
44    let mut was_not_anonymous = false;
45
46    let auth_user = match jar.get("__Secure-Token") {
47        Some(c) => match database
48            .auth
49            .get_profile_by_unhashed(c.value_trimmed())
50            .await
51        {
52            Ok(ua) => {
53                was_not_anonymous = true;
54                ua
55            }
56            Err(_) => anonymous_profile(database.create_anonymous().0),
57        },
58        None => anonymous_profile(database.create_anonymous().0),
59    };
60
61    let existing_tag = match jar.get("__Secure-Question-Tag") {
62        Some(c) => c.value_trimmed().to_string(),
63        None => String::new(),
64    };
65
66    // get real ip
67    let real_ip = if let Some(ref real_ip_header) = database.config.real_ip_header {
68        headers
69            .get(real_ip_header.to_owned())
70            .unwrap_or(&HeaderValue::from_static(""))
71            .to_str()
72            .unwrap_or("")
73            .to_string()
74    } else {
75        String::new()
76    };
77
78    // check ip
79    if database.auth.get_ipban_by_ip(&real_ip).await.is_ok() {
80        return (
81            [
82                ("Content-Type".to_string(), "text/plain".to_string()),
83                ("Set-Cookie".to_string(), String::new()),
84            ],
85            Json(DefaultReturn {
86                success: false,
87                message: DatabaseError::Banned.to_string(),
88                payload: None,
89            }),
90        );
91    }
92
93    // get correct username
94    let use_anonymous_anyways = req.anonymous; // this is the "Hide your name" field
95
96    if (auth_user.username == "anonymous") | use_anonymous_anyways {
97        let tag = if was_not_anonymous && use_anonymous_anyways {
98            // use real username as tag
99            format!("anonymous#{}", auth_user.id)
100        } else if !existing_tag.is_empty() {
101            // use existing tag
102            existing_tag
103        } else if !was_not_anonymous {
104            // use id as tag
105            auth_user.id
106        } else {
107            // use id as tag
108            if auth_user.username == "anonymous" {
109                auth_user.id
110            } else {
111                auth_user.id
112            }
113        };
114
115        // create as anonymous
116        return (
117            [
118                ("Content-Type".to_string(), "text/plain".to_string()),
119                (
120                    "Set-Cookie".to_string(),
121                    format!(
122                        "__Secure-Question-Tag={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age={}",
123                        tag,
124                        60 * 60 * 24 * 365
125                    ),
126                ),
127            ],
128            Json(match database.create_question(req, tag, real_ip).await {
129                Ok(r) => DefaultReturn {
130                    success: true,
131                    message: String::new(),
132                    payload: Some(r),
133                },
134                Err(e) => e.into(),
135            }),
136        );
137    }
138
139    // ...
140    (
141        [
142            ("Content-Type".to_string(), "text/plain".to_string()),
143            (
144                "Set-Cookie".to_string(),
145                format!(
146                    "__Secure-Question-Tag={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age={}",
147                    auth_user.username.replace("anonymous#", ""),
148                    60 * 60 * 24 * 365
149                ),
150            ),
151        ],
152        Json(match database.create_question(req, auth_user.id, real_ip).await {
153            Ok(r) => DefaultReturn {
154                success: true,
155                message: String::new(),
156                payload: Some(r),
157            },
158            Err(e) => e.into(),
159        })
160    )
161}
162
163/// [`Database::get_question`]
164pub async fn get_request(
165    Path(id): Path<String>,
166    State(database): State<Database>,
167) -> impl IntoResponse {
168    Json(match database.get_question(id).await {
169        Ok(mut r) => DefaultReturn {
170            success: true,
171            message: String::new(),
172            payload: {
173                r.ip = String::new();
174
175                // hide anonymous author id
176                if r.author.id.starts_with("anonymous#") {
177                    r.author.id = "anonymous".to_string()
178                }
179
180                // hide tokens, password, salt, and metadata
181                r.author.clean();
182                r.recipient.clean();
183
184                // return
185                Some(r)
186            },
187        },
188        Err(e) => e.into(),
189    })
190}
191
192/// Redirect to the full ID of a question through its short ID
193pub async fn expand_request(
194    Path(id): Path<String>,
195    State(database): State<Database>,
196) -> impl IntoResponse {
197    match database.get_question(id).await {
198        Ok(r) => Redirect::to(&format!("/@{}/q/{}", r.author.username, r.id)),
199        Err(_) => Redirect::to("/"),
200    }
201}
202
203/// [`Database::delete_question`]
204pub async fn delete_request(
205    jar: CookieJar,
206    Path(id): Path<String>,
207    State(database): State<Database>,
208) -> impl IntoResponse {
209    // get user from token
210    let auth_user = match jar.get("__Secure-Token") {
211        Some(c) => match database
212            .auth
213            .get_profile_by_unhashed(c.value_trimmed())
214            .await
215        {
216            Ok(ua) => ua,
217            Err(_) => {
218                return Json(DatabaseError::NotAllowed.into());
219            }
220        },
221        None => {
222            return Json(DatabaseError::NotAllowed.into());
223        }
224    };
225
226    // ...
227    Json(match database.delete_question(id, auth_user).await {
228        Ok(r) => DefaultReturn {
229            success: true,
230            message: String::new(),
231            payload: Some(r),
232        },
233        Err(e) => e.into(),
234    })
235}
236
237/// [`Database::delete_questions_by_recipient`]
238pub async fn delete_inbox_request(
239    jar: CookieJar,
240    Path(id): Path<String>,
241    State(database): State<Database>,
242) -> impl IntoResponse {
243    // get user from token
244    let auth_user = match jar.get("__Secure-Token") {
245        Some(c) => match database
246            .auth
247            .get_profile_by_unhashed(c.value_trimmed())
248            .await
249        {
250            Ok(ua) => ua,
251            Err(_) => {
252                return Json(DatabaseError::NotAllowed.into());
253            }
254        },
255        None => {
256            return Json(DatabaseError::NotAllowed.into());
257        }
258    };
259
260    // ...
261    Json(
262        match database.delete_questions_by_recipient(&id, auth_user).await {
263            Ok(r) => DefaultReturn {
264                success: true,
265                message: String::new(),
266                payload: Some(r),
267            },
268            Err(e) => e.into(),
269        },
270    )
271}
272
273/// [`Database::delete_questions_by_recipient`]
274pub async fn delete_my_inbox_request(
275    jar: CookieJar,
276    State(database): State<Database>,
277) -> impl IntoResponse {
278    // get user from token
279    let auth_user = match jar.get("__Secure-Token") {
280        Some(c) => match database
281            .auth
282            .get_profile_by_unhashed(c.value_trimmed())
283            .await
284        {
285            Ok(ua) => ua,
286            Err(_) => {
287                return Json(DatabaseError::NotAllowed.into());
288            }
289        },
290        None => {
291            return Json(DatabaseError::NotAllowed.into());
292        }
293    };
294
295    // ...
296    Json(
297        match database
298            .delete_questions_by_recipient(&auth_user.id.clone(), auth_user)
299            .await
300        {
301            Ok(r) => DefaultReturn {
302                success: true,
303                message: String::new(),
304                payload: Some(r),
305            },
306            Err(e) => e.into(),
307        },
308    )
309}
310
311/// Report a question
312pub async fn report_request(
313    headers: HeaderMap,
314    Path(id): Path<String>,
315    State(database): State<Database>,
316    Json(req): Json<super::CreateReport>,
317) -> impl IntoResponse {
318    // check hcaptcha
319    if let Err(e) = req
320        .valid_response(&database.config.captcha.secret, None)
321        .await
322    {
323        return Json(DefaultReturn {
324            success: false,
325            message: e.to_string(),
326            payload: (),
327        });
328    }
329
330    // get question
331    if let Err(_) = database.get_question(id.clone()).await {
332        return Json(DefaultReturn {
333            success: false,
334            message: DatabaseError::NotFound.to_string(),
335            payload: (),
336        });
337    };
338
339    // get real ip
340    let real_ip = if let Some(ref real_ip_header) = database.config.real_ip_header {
341        headers
342            .get(real_ip_header.to_owned())
343            .unwrap_or(&HeaderValue::from_static(""))
344            .to_str()
345            .unwrap_or("")
346            .to_string()
347    } else {
348        String::new()
349    };
350
351    // check ip
352    if database.auth.get_ipban_by_ip(&real_ip).await.is_ok() {
353        return Json(DefaultReturn {
354            success: false,
355            message: DatabaseError::Banned.to_string(),
356            payload: (),
357        });
358    }
359
360    // report
361    match database
362        .auth
363        .create_notification(
364            NotificationCreate {
365                title: format!("**QUESTION REPORT**: {id}"),
366                content: format!("{}\n\n***\n\n[{real_ip}](/+i/{real_ip})", req.content),
367                address: format!("/question/{id}"),
368                recipient: "*".to_string(), // all staff
369            },
370            None,
371        )
372        .await
373    {
374        Ok(_) => {
375            return Json(DefaultReturn {
376                success: true,
377                message: "Question reported!".to_string(),
378                payload: (),
379            })
380        }
381        Err(_) => Json(DefaultReturn {
382            success: false,
383            message: DatabaseError::NotFound.to_string(),
384            payload: (),
385        }),
386    }
387}
388
389/// IP block a question's author
390pub async fn ipblock_request(
391    jar: CookieJar,
392    Path(id): Path<String>,
393    State(database): State<Database>,
394) -> impl IntoResponse {
395    // get user from token
396    let auth_user = match jar.get("__Secure-Token") {
397        Some(c) => match database
398            .auth
399            .get_profile_by_unhashed(c.value_trimmed())
400            .await
401        {
402            Ok(ua) => ua,
403            Err(_) => {
404                return Json(DatabaseError::NotAllowed.into());
405            }
406        },
407        None => {
408            return Json(DatabaseError::NotAllowed.into());
409        }
410    };
411
412    // get question
413    let question = match database.get_question(id.clone()).await {
414        Ok(q) => q,
415        Err(e) => return Json(e.to_json()),
416    };
417
418    // block
419    match database
420        .auth
421        .create_ipblock(
422            IpBlockCreate {
423                ip: question.ip,
424                context: question.content,
425            },
426            auth_user,
427        )
428        .await
429    {
430        Ok(_) => {
431            return Json(DefaultReturn {
432                success: true,
433                message: "IP blocked!".to_string(),
434                payload: (),
435            })
436        }
437        Err(_) => Json(DefaultReturn {
438            success: false,
439            message: DatabaseError::Other.to_string(),
440            payload: (),
441        }),
442    }
443}
444
445/// Read a question's image (as svg)
446pub async fn carpgraph_svg_request(
447    State(database): State<Database>,
448    Path(id): Path<String>,
449) -> impl IntoResponse {
450    let bytes = match std::fs::read(pathd!(
451        "{}/carpgraph/{}.carpgraph",
452        database.config.media_dir,
453        id
454    )) {
455        Ok(b) => b,
456        Err(_) => return ([("Content-Type", "image/svg+xml")], String::new()),
457    };
458
459    (
460        [("Content-Type", "image/svg+xml")],
461        carp::Graph::from_bytes(bytes).to_svg(),
462    )
463}
464
465/// Read a question's image
466pub async fn carpgraph_request(
467    State(database): State<Database>,
468    Path(id): Path<String>,
469) -> impl IntoResponse {
470    let bytes = match std::fs::read(pathd!(
471        "{}/carpgraph/{}.carpgraph",
472        database.config.media_dir,
473        id
474    )) {
475        Ok(b) => b,
476        Err(_) => return ([("Content-Type", "image/carpgraph")], Vec::new()),
477    };
478
479    ([("Content-Type", "image/carpgraph")], bytes)
480}