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 .with_state(database)
32}
33
34pub async fn create_request(
38 jar: CookieJar,
39 headers: HeaderMap,
40 State(database): State<Database>,
41 Json(req): Json<QuestionCreate>,
42) -> impl IntoResponse {
43 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 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 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 let use_anonymous_anyways = req.anonymous; if (auth_user.username == "anonymous") | use_anonymous_anyways {
97 let tag = if was_not_anonymous && use_anonymous_anyways {
98 format!("anonymous#{}", auth_user.id)
100 } else if !existing_tag.is_empty() {
101 existing_tag
103 } else if !was_not_anonymous {
104 auth_user.id
106 } else {
107 if auth_user.username == "anonymous" {
109 auth_user.id
110 } else {
111 auth_user.id
112 }
113 };
114
115 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 (
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
163pub 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 if r.author.id.starts_with("anonymous#") {
177 r.author.id = "anonymous".to_string()
178 }
179
180 r.author.clean();
182 r.recipient.clean();
183
184 Some(r)
186 },
187 },
188 Err(e) => e.into(),
189 })
190}
191
192pub 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
203pub async fn delete_request(
205 jar: CookieJar,
206 Path(id): Path<String>,
207 State(database): State<Database>,
208) -> impl IntoResponse {
209 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 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
237pub async fn delete_inbox_request(
239 jar: CookieJar,
240 Path(id): Path<String>,
241 State(database): State<Database>,
242) -> impl IntoResponse {
243 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 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
273pub async fn delete_my_inbox_request(
275 jar: CookieJar,
276 State(database): State<Database>,
277) -> impl IntoResponse {
278 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 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
311pub 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 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 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 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 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 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(), },
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
389pub async fn ipblock_request(
391 jar: CookieJar,
392 Path(id): Path<String>,
393 State(database): State<Database>,
394) -> impl IntoResponse {
395 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 let question = match database.get_question(id.clone()).await {
414 Ok(q) => q,
415 Err(e) => return Json(e.to_json()),
416 };
417
418 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
445pub 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
465pub 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}