1use crate::{
2 State,
3 config::Config,
4 model::{Entry, EntryMetadata, QuickFlag, QuickFlags},
5};
6use axum::{
7 Extension, Json, Router,
8 extract::{Path, Query},
9 http::{HeaderMap, HeaderValue},
10 response::{Html, IntoResponse},
11 routing::{get, get_service, post},
12};
13use axum_extra::extract::CookieJar;
14use pathbufd::PathBufD;
15use serde::Deserialize;
16use serde_valid::Validate;
17use tera::Context;
18use tetratto_core::model::{ApiReturn, Error};
19use tetratto_shared::{hash::salt, unix_epoch_timestamp};
20
21pub fn routes() -> Router {
22 Router::new()
23 .nest_service(
24 "/public",
25 get_service(tower_http::services::ServeDir::new("./public")),
26 )
27 .fallback(not_found_request)
28 .route("/docs/{name}", get(view_doc_request))
29 .route("/", get(index_request))
31 .route("/{slug}", get(view_request))
32 .route("/{slug}/edit", get(editor_request))
33 .route("/{slug}/claim", get(reclaim_request))
34 .route("/api/v1/render", post(render_request))
36 .route("/api/v1/entries", post(create_request))
37 .route("/api/v1/entries/{slug}", post(edit_request))
38 .route("/api/v1/entries/{slug}", get(exists_request))
39}
40
41fn default_context(config: &Config, build_code: &str) -> Context {
42 let mut ctx = Context::new();
43 ctx.insert("name", &config.name);
44 ctx.insert("theme_color", &config.theme_color);
45 ctx.insert("config", &config);
46 ctx.insert("what_page_slug", &config.what_page_slug);
47 ctx.insert(
48 "tetratto_handler_account_username",
49 &config.tetratto_handler_account_username,
50 );
51 ctx.insert("build_code", &build_code);
52 ctx
53}
54
55async fn not_found_request(Extension(data): Extension<State>) -> impl IntoResponse {
57 let (ref data, ref tera, ref build_code) = *data.read().await;
58
59 let mut ctx = default_context(&data.0.0, &build_code);
60 ctx.insert(
61 "error",
62 &Error::GeneralNotFound("page".to_string()).to_string(),
63 );
64 return Html(tera.render("error.lisp", &ctx).unwrap());
65}
66
67async fn index_request(Extension(data): Extension<State>) -> impl IntoResponse {
68 let (ref data, ref tera, ref build_code) = *data.read().await;
69 Html(
70 tera.render("index.lisp", &default_context(&data.0.0, &build_code))
71 .unwrap(),
72 )
73}
74
75async fn view_doc_request(
76 Extension(data): Extension<State>,
77 Path(name): Path<String>,
78) -> impl IntoResponse {
79 let (ref data, ref tera, ref build_code) = *data.read().await;
80 let path = PathBufD::current().extend(&["docs", &format!("{name}.md")]);
81
82 if !std::fs::exists(&path).unwrap_or(false) {
83 let mut ctx = default_context(&data.0.0, &build_code);
84 ctx.insert(
85 "error",
86 &Error::GeneralNotFound("entry".to_string()).to_string(),
87 );
88 return Html(tera.render("error.lisp", &ctx).unwrap());
89 }
90
91 let text = match std::fs::read_to_string(&path) {
92 Ok(t) => t,
93 Err(e) => {
94 let mut ctx = default_context(&data.0.0, &build_code);
95 ctx.insert("error", &Error::MiscError(e.to_string()).to_string());
96 return Html(tera.render("error.lisp", &ctx).unwrap());
97 }
98 };
99
100 let mut ctx = default_context(&data.0.0, &build_code);
101
102 ctx.insert("text", &text);
103 ctx.insert("file_name", &name);
104
105 return Html(tera.render("doc.lisp", &ctx).unwrap());
106}
107
108#[derive(Deserialize)]
109pub struct ViewQuery {
110 #[serde(default)]
111 pub key: String,
112}
113
114async fn view_request(
115 jar: CookieJar,
116 Extension(data): Extension<State>,
117 Path(mut slug): Path<String>,
118 Query(props): Query<ViewQuery>,
119) -> impl IntoResponse {
120 let (ref data, ref tera, ref build_code) = *data.read().await;
121 let qflags: QuickFlags = match jar.get("Atto-QFlags") {
122 Some(x) => match serde_json::from_str(&x.value_trimmed()) {
123 Ok(x) => x,
124 Err(_) => QuickFlags::default(),
125 },
126 None => QuickFlags::default(),
127 };
128
129 slug = slug.to_lowercase();
130 let viewed_header = (
131 "Set-Cookie".to_string(),
132 format!("Atto-Viewed=true; Path=/{slug}; Max-Age=86400"),
133 );
134
135 let entry = match data.get_entry_by_slug(&slug).await {
136 Ok(x) => x,
137 Err(_) => {
138 let mut ctx = default_context(&data.0.0, &build_code);
139 ctx.insert(
140 "error",
141 &Error::GeneralNotFound("entry".to_string()).to_string(),
142 );
143
144 return (
145 [viewed_header],
146 Html(tera.render("error.lisp", &ctx).unwrap()),
147 );
148 }
149 };
150
151 let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&entry.metadata))
153 {
154 Ok(x) => x,
155 Err(_) => EntryMetadata::default(),
156 };
157
158 if let Err(e) = metadata.validate() {
159 let mut ctx = default_context(&data.0.0, &build_code);
160 ctx.insert("error", &e.to_string());
161 return (
162 [viewed_header],
163 Html(tera.render("error.lisp", &ctx).unwrap()),
164 );
165 }
166
167 if !metadata.option_view_password.is_empty()
169 && metadata.option_view_password != props.key.clone()
170 {
171 let mut ctx = default_context(&data.0.0, &build_code);
172 ctx.insert("entry", &entry);
173 return (
174 [viewed_header],
175 Html(tera.render("password.lisp", &ctx).unwrap()),
176 );
177 }
178
179 if jar.get("Atto-Viewed").is_none() {
181 if let Err(e) = data.incr_entry_views(entry.id).await {
184 let mut ctx = default_context(&data.0.0, &build_code);
185 ctx.insert("error", &e.to_string());
186
187 return (
188 [viewed_header],
189 Html(tera.render("error.lisp", &ctx).unwrap()),
190 );
191 }
192 }
193
194 let mut ctx = default_context(&data.0.0, &build_code);
196
197 ctx.insert("entry", &entry);
198 ctx.insert("metadata", &metadata);
199 ctx.insert("metadata_head", &metadata.head_tags());
200 ctx.insert("metadata_css", &metadata.css());
201 ctx.insert("password", &props.key);
202 ctx.insert("flags", &qflags);
203
204 if metadata.safety_content_warning.is_empty() | qflags.contains(&QuickFlag::AcceptWarning) {
205 (
207 [viewed_header],
208 Html(tera.render("view.lisp", &ctx).unwrap()),
209 )
210 } else {
211 (
213 [viewed_header],
214 Html(tera.render("warning.lisp", &ctx).unwrap()),
215 )
216 }
217}
218
219async fn editor_request(
220 Extension(data): Extension<State>,
221 Path(mut slug): Path<String>,
222 Query(props): Query<ViewQuery>,
223) -> impl IntoResponse {
224 let (ref data, ref tera, ref build_code) = *data.read().await;
225 slug = slug.to_lowercase();
226
227 let entry = match data.get_entry_by_slug(&slug).await {
228 Ok(x) => x,
229 Err(_) => {
230 let mut ctx = default_context(&data.0.0, &build_code);
231 ctx.insert(
232 "error",
233 &Error::GeneralNotFound("entry".to_string()).to_string(),
234 );
235
236 return Html(tera.render("error.lisp", &ctx).unwrap());
237 }
238 };
239
240 let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&entry.metadata))
241 {
242 Ok(x) => x,
243 Err(_) => EntryMetadata::default(),
244 };
245
246 if if !metadata.option_source_password.is_empty() {
248 metadata.option_source_password != props.key
249 } else if !metadata.option_view_password.is_empty() {
250 metadata.option_view_password != props.key
251 } else {
252 false
253 } {
254 let mut ctx = default_context(&data.0.0, &build_code);
255 ctx.insert("entry", &entry);
256 return Html(tera.render("password.lisp", &ctx).unwrap());
257 }
258
259 let mut ctx = default_context(&data.0.0, &build_code);
261
262 ctx.insert("entry", &entry);
263 ctx.insert("password", &props.key);
264
265 Html(tera.render("edit.lisp", &ctx).unwrap())
266}
267
268const MINIMUM_CLAIMABLE_TIME: usize = 604_800_000; async fn reclaim_request(
271 Extension(data): Extension<State>,
272 Path(mut slug): Path<String>,
273) -> impl IntoResponse {
274 let (ref data, ref tera, ref build_code) = *data.read().await;
275 slug = slug.to_lowercase();
276
277 let entry = match data.get_entry_by_slug(&slug).await {
278 Ok(x) => x,
279 Err(_) => {
280 let mut ctx = default_context(&data.0.0, &build_code);
281 ctx.insert(
282 "error",
283 &Error::GeneralNotFound("entry".to_string()).to_string(),
284 );
285
286 return Html(tera.render("error.lisp", &ctx).unwrap());
287 }
288 };
289
290 let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&entry.metadata))
292 {
293 Ok(x) => x,
294 Err(_) => EntryMetadata::default(),
295 };
296
297 if let Err(e) = metadata.validate() {
298 let mut ctx = default_context(&data.0.0, &build_code);
299 ctx.insert("error", &e.to_string());
300 return Html(tera.render("error.lisp", &ctx).unwrap());
301 }
302
303 let mut ctx = default_context(&data.0.0, &build_code);
305
306 ctx.insert("entry", &entry);
307 ctx.insert("metadata", &metadata);
308 ctx.insert(
309 "claimable",
310 &((unix_epoch_timestamp() - entry.edited) > MINIMUM_CLAIMABLE_TIME),
311 );
312
313 Html(tera.render("claim.lisp", &ctx).unwrap())
314}
315
316#[derive(Deserialize)]
318struct RenderMarkdown {
319 content: String,
320 metadata: String,
321}
322
323async fn render_request(Json(req): Json<RenderMarkdown>) -> impl IntoResponse {
324 let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&req.metadata)) {
325 Ok(x) => x,
326 Err(e) => return Html(e.to_string()),
327 };
328
329 if let Err(e) = metadata.validate() {
330 return Html(e.to_string());
331 }
332
333 Html(
334 crate::markdown::render_markdown(&req.content)
335 + &format!("<div id=\"metadata_css\">{}</div>", metadata.css()),
336 )
337}
338
339async fn exists_request(
340 Extension(data): Extension<State>,
341 Path(mut slug): Path<String>,
342) -> impl IntoResponse {
343 let (ref data, _, _) = *data.read().await;
344 slug = slug.to_lowercase();
345
346 Json(ApiReturn {
347 ok: true,
348 message: "Success".to_string(),
349 payload: data.get_entry_by_slug(&slug).await.is_ok(),
350 })
351}
352
353#[derive(Deserialize)]
354struct CreateEntry {
355 content: String,
356 #[serde(default)]
357 metadata: String,
358 #[serde(default = "default_random")]
359 slug: String,
360 #[serde(default = "default_random")]
361 edit_code: String,
362}
363
364fn default_random() -> String {
365 salt()
366}
367
368const CREATE_WAIT_TIME: usize = 15000;
370
371async fn create_request(
372 jar: CookieJar,
373 headers: HeaderMap,
374 Extension(data): Extension<State>,
375 Json(mut req): Json<CreateEntry>,
376) -> std::result::Result<impl IntoResponse, Json<ApiReturn<()>>> {
377 let (ref data, _, _) = *data.read().await;
378 req.slug = req.slug.to_lowercase();
379
380 let real_ip = headers
382 .get(&data.0.0.real_ip_header)
383 .unwrap_or(&HeaderValue::from_static(""))
384 .to_str()
385 .unwrap_or("")
386 .to_string();
387
388 if let Some(cookie) = jar.get("__Secure-Claim-Next") {
397 if unix_epoch_timestamp()
398 != cookie
399 .to_string()
400 .replace("__Secure-Claim-Next=", "")
401 .parse::<usize>()
402 .unwrap_or(0)
403 {
404 return Err(Json(
405 Error::MiscError("You must wait a bit to create another entry".to_string()).into(),
406 ));
407 }
408 }
409
410 if let Err(e) = data
412 .create_entry(Entry::new(
413 req.slug.clone(),
414 req.edit_code.clone(),
415 req.content,
416 req.metadata,
417 real_ip,
418 ))
419 .await
420 {
421 return Err(Json(e.into()));
422 }
423
424 Ok((
426 [(
427 "Set-Cookie",
428 format!(
429 "__Secure-Claim-Next={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age=5",
430 unix_epoch_timestamp() + CREATE_WAIT_TIME
431 ),
432 )],
433 Json(ApiReturn {
434 ok: true,
435 message: "Success".to_string(),
436 payload: Some((req.slug, req.edit_code)),
437 }),
438 ))
439}
440
441#[derive(Deserialize)]
442struct EditEntry {
443 content: String,
444 edit_code: String,
445 #[serde(default)]
446 new_slug: Option<String>,
447 #[serde(default)]
448 new_edit_code: Option<String>,
449 #[serde(default)]
450 new_modify_code: Option<String>,
451 #[serde(default)]
452 metadata: String,
453 #[serde(default)]
454 delete: bool,
455}
456
457async fn edit_request(
458 headers: HeaderMap,
459 Extension(data): Extension<State>,
460 Path(id): Path<usize>,
461 Json(req): Json<EditEntry>,
462) -> impl IntoResponse {
463 let (ref data, _, _) = *data.read().await;
464
465 let real_ip = headers
467 .get(&data.0.0.real_ip_header)
468 .unwrap_or(&HeaderValue::from_static(""))
469 .to_str()
470 .unwrap_or("")
471 .to_string();
472
473 if req.delete {
482 return match data.delete_entry(id, req.edit_code).await {
483 Ok(_) => Json(ApiReturn {
484 ok: true,
485 message: "Success".to_string(),
486 payload: None,
487 }),
488 Err(e) => return Json(e.into()),
489 };
490 }
491
492 match data
494 .update_entry(
495 id,
496 req.edit_code,
497 req.new_slug.unwrap_or_default(),
498 req.content,
499 req.metadata,
500 req.new_edit_code.unwrap_or_default(),
501 req.new_modify_code.unwrap_or_default(),
502 real_ip,
503 )
504 .await
505 {
506 Ok(x) => Json(ApiReturn {
507 ok: true,
508 message: "Success".to_string(),
509 payload: Some(x),
510 }),
511 Err(e) => Json(e.into()),
512 }
513}