1use std::env::var;
2
3use crate::{
4 State,
5 model::{Entry, EntryMetadata},
6};
7use axum::{
8 Extension, Json, Router,
9 extract::{Path, Query},
10 http::{HeaderMap, HeaderValue},
11 response::{Html, IntoResponse},
12 routing::{get, get_service, post},
13};
14use axum_extra::extract::CookieJar;
15use pathbufd::PathBufD;
16use serde::Deserialize;
17use serde_valid::Validate;
18use tera::Context;
19use tetratto_core::{
20 model::{
21 ApiReturn, Error,
22 apps::{AppDataQueryResult, AppDataSelectMode, AppDataSelectQuery},
23 },
24 sdk::{DataClient, SimplifiedQuery},
25};
26use tetratto_shared::{
27 hash::{hash, salt},
28 unix_epoch_timestamp,
29};
30
31pub const NAME_REGEX: &str = r"[^\w_\-\.,!]+";
32
33pub fn routes() -> Router {
34 Router::new()
35 .nest_service(
36 "/public",
37 get_service(tower_http::services::ServeDir::new("./public")),
38 )
39 .fallback(not_found_request)
40 .route("/docs/{name}", get(view_doc_request))
41 .route("/", get(index_request))
43 .route("/{slug}", get(view_request))
44 .route("/{slug}/edit", get(editor_request))
45 .route("/api/v1/util/ip", get(util_ip))
47 .route("/api/v1/render", post(render_request))
48 .route("/api/v1/entries", post(create_request))
49 .route("/api/v1/entries/{slug}", post(edit_request))
50 .route("/api/v1/entries/{slug}", get(exists_request))
51}
52
53fn default_context(data: &DataClient, build_code: &str) -> Context {
54 let mut ctx = Context::new();
55 ctx.insert("name", &var("NAME").unwrap_or("Fluffle".to_string()));
56 ctx.insert(
57 "theme_color",
58 &var("THEME_COLOR").unwrap_or("#a3b3ff".to_string()),
59 );
60 ctx.insert("tetratto", &data.host);
61 ctx.insert(
62 "what_page_slug",
63 &var("WHAT_SLUG").unwrap_or("what".to_string()),
64 );
65 ctx.insert("build_code", &build_code);
66 ctx
67}
68
69async fn not_found_request(Extension(data): Extension<State>) -> impl IntoResponse {
71 let (ref data, ref tera, ref build_code) = *data.read().await;
72
73 let mut ctx = default_context(&data, &build_code);
74 ctx.insert(
75 "error",
76 &Error::GeneralNotFound("page".to_string()).to_string(),
77 );
78 return Html(tera.render("error.lisp", &ctx).unwrap());
79}
80
81async fn index_request(Extension(data): Extension<State>) -> impl IntoResponse {
82 let (ref data, ref tera, ref build_code) = *data.read().await;
83 Html(
84 tera.render("index.lisp", &default_context(&data, &build_code))
85 .unwrap(),
86 )
87}
88
89async fn view_doc_request(
90 Extension(data): Extension<State>,
91 Path(name): Path<String>,
92) -> impl IntoResponse {
93 let (ref data, ref tera, ref build_code) = *data.read().await;
94 let path = PathBufD::current().extend(&["docs", &format!("{name}.md")]);
95
96 if !std::fs::exists(&path).unwrap_or(false) {
97 let mut ctx = default_context(&data, &build_code);
98 ctx.insert(
99 "error",
100 &Error::GeneralNotFound("entry".to_string()).to_string(),
101 );
102 return Html(tera.render("error.lisp", &ctx).unwrap());
103 }
104
105 let text = match std::fs::read_to_string(&path) {
106 Ok(t) => t,
107 Err(e) => {
108 let mut ctx = default_context(&data, &build_code);
109 ctx.insert("error", &Error::MiscError(e.to_string()).to_string());
110 return Html(tera.render("error.lisp", &ctx).unwrap());
111 }
112 };
113
114 let mut ctx = default_context(&data, &build_code);
115
116 ctx.insert("text", &text);
117 ctx.insert("file_name", &name);
118
119 return Html(tera.render("doc.lisp", &ctx).unwrap());
120}
121
122#[derive(Deserialize)]
123pub struct ViewQuery {
124 #[serde(default)]
125 pub key: String,
126}
127
128async fn view_request(
129 Extension(data): Extension<State>,
130 Path(mut slug): Path<String>,
131 Query(props): Query<ViewQuery>,
132) -> impl IntoResponse {
133 let (ref data, ref tera, ref build_code) = *data.read().await;
134 slug = slug.to_lowercase();
135
136 let entry = match data
137 .query(&SimplifiedQuery {
138 query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug.to_lowercase())),
139 mode: AppDataSelectMode::One(0),
140 })
141 .await
142 {
143 Ok(r) => match r {
144 AppDataQueryResult::One(r) => serde_json::from_str::<Entry>(&r.value).unwrap(),
145 AppDataQueryResult::Many(_) => unreachable!(),
146 },
147 Err(_) => {
148 let mut ctx = default_context(&data, &build_code);
149 ctx.insert(
150 "error",
151 &Error::GeneralNotFound("entry".to_string()).to_string(),
152 );
153
154 return Html(tera.render("error.lisp", &ctx).unwrap());
155 }
156 };
157
158 let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&entry.metadata))
160 {
161 Ok(x) => x,
162 Err(_) => EntryMetadata::default(),
163 };
164
165 if let Err(e) = metadata.validate() {
166 let mut ctx = default_context(&data, &build_code);
167 ctx.insert("error", &e.to_string());
168 return Html(tera.render("error.lisp", &ctx).unwrap());
169 }
170
171 if !metadata.option_view_password.is_empty()
173 && metadata.option_view_password != props.key.clone()
174 {
175 let mut ctx = default_context(&data, &build_code);
176 ctx.insert("entry", &entry);
177 return Html(tera.render("password.lisp", &ctx).unwrap());
178 }
179
180 let views = if !metadata.option_disable_views {
182 match data
183 .query(&SimplifiedQuery {
184 query: AppDataSelectQuery::KeyIs(format!("entries.views('{}')", slug)),
185 mode: AppDataSelectMode::One(0),
186 })
187 .await
188 {
189 Ok(r) => match r {
190 AppDataQueryResult::One(r) => {
191 let views = r.value.parse::<usize>().unwrap();
193
194 if let Err(e) = data.update(r.id, (views + 1).to_string()).await {
195 let mut ctx = default_context(&data, &build_code);
196 ctx.insert("error", &e.to_string());
197
198 return Html(tera.render("error.lisp", &ctx).unwrap());
199 }
200
201 views
202 }
203 AppDataQueryResult::Many(_) => unreachable!(),
204 },
205 Err(e) => {
206 let mut ctx = default_context(&data, &build_code);
207 ctx.insert("error", &e.to_string());
208
209 return Html(tera.render("error.lisp", &ctx).unwrap());
210 }
211 }
212 } else {
213 0
214 };
215
216 let mut ctx = default_context(&data, &build_code);
218
219 ctx.insert("entry", &entry);
220 ctx.insert("views", &views);
221 ctx.insert("metadata", &metadata);
222 ctx.insert("metadata_head", &metadata.head_tags());
223 ctx.insert("metadata_css", &metadata.css());
224 ctx.insert("password", &props.key);
225
226 Html(tera.render("view.lisp", &ctx).unwrap())
227}
228
229async fn editor_request(
230 Extension(data): Extension<State>,
231 Path(mut slug): Path<String>,
232 Query(props): Query<ViewQuery>,
233) -> impl IntoResponse {
234 let (ref data, ref tera, ref build_code) = *data.read().await;
235 slug = slug.to_lowercase();
236
237 let entry = match data
238 .query(&SimplifiedQuery {
239 query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug.to_lowercase())),
240 mode: AppDataSelectMode::One(0),
241 })
242 .await
243 {
244 Ok(r) => match r {
245 AppDataQueryResult::One(r) => serde_json::from_str::<Entry>(&r.value).unwrap(),
246 AppDataQueryResult::Many(_) => unreachable!(),
247 },
248 Err(_) => {
249 let mut ctx = default_context(&data, &build_code);
250 ctx.insert(
251 "error",
252 &Error::GeneralNotFound("entry".to_string()).to_string(),
253 );
254
255 return Html(tera.render("error.lisp", &ctx).unwrap());
256 }
257 };
258
259 let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&entry.metadata))
260 {
261 Ok(x) => x,
262 Err(_) => EntryMetadata::default(),
263 };
264
265 if if !metadata.option_source_password.is_empty() {
267 metadata.option_source_password != props.key
268 } else if !metadata.option_view_password.is_empty() {
269 metadata.option_view_password != props.key
270 } else {
271 false
272 } {
273 let mut ctx = default_context(&data, &build_code);
274 ctx.insert("entry", &entry);
275 return Html(tera.render("password.lisp", &ctx).unwrap());
276 }
277
278 let mut ctx = default_context(&data, &build_code);
280
281 ctx.insert("entry", &entry);
282 ctx.insert("password", &props.key);
283
284 Html(tera.render("edit.lisp", &ctx).unwrap())
285}
286
287#[derive(Deserialize)]
289struct RenderMarkdown {
290 content: String,
291 metadata: String,
292}
293
294async fn render_request(Json(req): Json<RenderMarkdown>) -> impl IntoResponse {
295 let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&req.metadata)) {
296 Ok(x) => x,
297 Err(e) => return Html(e.to_string()),
298 };
299
300 if let Err(e) = metadata.validate() {
301 return Html(e.to_string());
302 }
303
304 Html(
305 crate::markdown::render_markdown(&req.content)
306 + &format!("<div id=\"metadata_css\">{}</div>", metadata.css()),
307 )
308}
309
310async fn exists_request(
311 Extension(data): Extension<State>,
312 Path(mut slug): Path<String>,
313) -> impl IntoResponse {
314 let (ref data, _, _) = *data.read().await;
315 slug = slug.to_lowercase();
316
317 Json(ApiReturn {
318 ok: true,
319 message: "Success".to_string(),
320 payload: data
321 .query(&SimplifiedQuery {
322 query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug)),
323 mode: AppDataSelectMode::One(0),
324 })
325 .await
326 .is_ok(),
327 })
328}
329
330async fn util_ip(headers: HeaderMap) -> impl IntoResponse {
331 headers
332 .get(var("REAL_IP_HEADER").unwrap_or("CF-Connecting-IP".to_string()))
333 .unwrap_or(&HeaderValue::from_static(""))
334 .to_str()
335 .unwrap_or("")
336 .to_string()
337}
338
339#[derive(Deserialize)]
340struct CreateEntry {
341 content: String,
342 #[serde(default)]
343 metadata: String,
344 #[serde(default = "default_random")]
345 slug: String,
346 #[serde(default = "default_random")]
347 edit_code: String,
348}
349
350fn default_random() -> String {
351 salt()
352}
353
354fn hash_passwords(metadata: &mut EntryMetadata) -> (bool, String) {
355 let do_update_metadata = (!metadata.option_view_password.is_empty()
357 || !metadata.option_source_password.is_empty())
358 && (!metadata.option_view_password.starts_with("h:")
359 || !metadata.option_source_password.starts_with("h:"));
360
361 if !metadata.option_view_password.is_empty() && !metadata.option_view_password.starts_with("h:")
362 {
363 metadata.option_view_password =
364 format!("h:{}", hash(metadata.option_view_password.clone()));
365 }
366
367 if !metadata.option_source_password.is_empty()
368 && !metadata.option_source_password.starts_with("h:")
369 {
370 metadata.option_source_password =
371 format!("h:{}", hash(metadata.option_source_password.clone()));
372 }
373
374 if do_update_metadata {
375 if let Ok(x) = toml::to_string_pretty(&metadata) {
376 return (true, x);
377 };
378 }
379
380 (false, String::new())
381}
382
383const CREATE_WAIT_TIME: usize = 15000;
385
386async fn create_request(
387 jar: CookieJar,
388 headers: HeaderMap,
389 Extension(data): Extension<State>,
390 Json(mut req): Json<CreateEntry>,
391) -> std::result::Result<impl IntoResponse, Json<ApiReturn<()>>> {
392 let (ref data, _, _) = *data.read().await;
393 req.slug = req.slug.to_lowercase();
394
395 let real_ip = headers
397 .get(var("REAL_IP_HEADER").unwrap_or("CF-Connecting-IP".to_string()))
398 .unwrap_or(&HeaderValue::from_static(""))
399 .to_str()
400 .unwrap_or("")
401 .to_string();
402
403 if !real_ip.is_empty() {
405 if data.check_ip(&real_ip.as_str()).await.unwrap_or(false) {
406 return Err(Json(Error::NotAllowed.into()));
407 }
408 }
409
410 if let Some(cookie) = jar.get("__Secure-Claim-Next") {
412 if unix_epoch_timestamp()
413 != cookie
414 .to_string()
415 .replace("__Secure-Claim-Next=", "")
416 .parse::<usize>()
417 .unwrap_or(0)
418 {
419 return Err(Json(
420 Error::MiscError("You must wait a bit to create another entry".to_string()).into(),
421 ));
422 }
423 }
424
425 if req.slug.len() < 2 {
427 return Err(Json(Error::DataTooShort("slug".to_string()).into()));
428 }
429
430 if req.slug.len() > 32 {
431 return Err(Json(Error::DataTooLong("slug".to_string()).into()));
432 }
433
434 if req.content.len() < 2 {
435 return Err(Json(Error::DataTooShort("content".to_string()).into()));
436 }
437
438 if req.content.len() > 150_000 {
439 return Err(Json(Error::DataTooLong("content".to_string()).into()));
440 }
441
442 let regex = regex::RegexBuilder::new(NAME_REGEX)
444 .multi_line(true)
445 .build()
446 .unwrap();
447
448 if regex.captures(&req.slug).is_some() {
449 return Err(Json(
450 Error::MiscError("This slug contains invalid characters".to_string()).into(),
451 ));
452 }
453
454 let mut metadata: EntryMetadata =
456 match toml::from_str(&EntryMetadata::ini_to_toml(&req.metadata)) {
457 Ok(x) => x,
458 Err(e) => return Err(Json(Error::MiscError(e.to_string()).into())),
459 };
460
461 if let Err(e) = metadata.validate() {
462 return Err(Json(Error::MiscError(e.to_string()).into()));
463 }
464
465 let (do_update_metadata, updated) = hash_passwords(&mut metadata);
466 if do_update_metadata {
467 req.metadata = updated;
468 }
469
470 if data
472 .query(&SimplifiedQuery {
473 query: AppDataSelectQuery::KeyIs(format!("entries('{}')", req.slug)),
474 mode: AppDataSelectMode::One(0),
475 })
476 .await
477 .is_ok()
478 {
479 return Err(Json(
480 Error::MiscError("Slug already in use".to_string()).into(),
481 ));
482 }
483
484 let created = unix_epoch_timestamp();
486 let salt = salt();
487
488 if let Err(e) = data
489 .insert(
490 format!("entries('{}')", req.slug),
491 serde_json::to_string(&Entry {
492 slug: req.slug.clone(),
493 edit_code: hash(req.edit_code.clone() + &salt),
494 salt,
495 created,
496 edited: created,
497 content: req.content,
498 metadata: req.metadata,
499 last_edit_from: real_ip,
500 modify_code: String::new(),
501 })
502 .unwrap(),
503 )
504 .await
505 {
506 return Err(Json(e.into()));
507 }
508
509 if let Err(e) = data
510 .insert(format!("entries.views('{}')", req.slug), 0.to_string())
511 .await
512 {
513 return Err(Json(e.into()));
514 }
515
516 Ok((
518 [(
519 "Set-Cookie",
520 format!(
521 "__Secure-Claim-Next={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age=5",
522 unix_epoch_timestamp() + CREATE_WAIT_TIME
523 ),
524 )],
525 Json(ApiReturn {
526 ok: true,
527 message: "Success".to_string(),
528 payload: Some((req.slug, req.edit_code)),
529 }),
530 ))
531}
532
533#[derive(Deserialize)]
534struct EditEntry {
535 content: String,
536 edit_code: String,
537 #[serde(default)]
538 new_slug: Option<String>,
539 #[serde(default)]
540 new_edit_code: Option<String>,
541 #[serde(default)]
542 new_modify_code: Option<String>,
543 #[serde(default)]
544 metadata: String,
545 #[serde(default)]
546 delete: bool,
547}
548
549async fn edit_request(
550 headers: HeaderMap,
551 Extension(data): Extension<State>,
552 Path(mut slug): Path<String>,
553 Json(mut req): Json<EditEntry>,
554) -> impl IntoResponse {
555 let (ref data, _, _) = *data.read().await;
556 slug = slug.to_lowercase();
557
558 let real_ip = headers
560 .get(var("REAL_IP_HEADER").unwrap_or("CF-Connecting-IP".to_string()))
561 .unwrap_or(&HeaderValue::from_static(""))
562 .to_str()
563 .unwrap_or("")
564 .to_string();
565
566 if !real_ip.is_empty() {
568 if data.check_ip(&real_ip.as_str()).await.unwrap_or(false) {
569 return Json(Error::NotAllowed.into());
570 }
571 }
572
573 if req.content.len() < 2 {
575 return Json(Error::DataTooShort("content".to_string()).into());
576 }
577
578 if req.content.len() > 150_000 {
579 return Json(Error::DataTooLong("content".to_string()).into());
580 }
581
582 let mut metadata: EntryMetadata =
584 match toml::from_str(&EntryMetadata::ini_to_toml(&req.metadata)) {
585 Ok(x) => x,
586 Err(e) => return Json(Error::MiscError(e.to_string()).into()),
587 };
588
589 if let Err(e) = metadata.validate() {
590 return Json(Error::MiscError(e.to_string()).into());
591 }
592
593 let (do_update_metadata, updated) = hash_passwords(&mut metadata);
594 if do_update_metadata {
595 req.metadata = updated;
596 }
597
598 let (id, mut entry) = match data
600 .query(&SimplifiedQuery {
601 query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug)),
602 mode: AppDataSelectMode::One(0),
603 })
604 .await
605 {
606 Ok(r) => match r {
607 AppDataQueryResult::One(r) => (r.id, serde_json::from_str::<Entry>(&r.value).unwrap()),
608 AppDataQueryResult::Many(_) => unreachable!(),
609 },
610 Err(e) => return Json(e.into()),
611 };
612
613 let edit_code = hash(req.edit_code.clone() + &entry.salt);
614 let using_modify_code = edit_code == entry.modify_code;
615
616 if edit_code
618 != *if using_modify_code {
619 &entry.modify_code
620 } else {
621 &entry.edit_code
622 }
623 {
624 return Json(Error::NotAllowed.into());
625 }
626
627 if !using_modify_code {
629 if req.delete {
631 let views_id = match data
632 .query(&SimplifiedQuery {
633 query: AppDataSelectQuery::KeyIs(format!("entries.views('{}')", slug)),
634 mode: AppDataSelectMode::One(0),
635 })
636 .await
637 {
638 Ok(r) => match r {
639 AppDataQueryResult::One(r) => r.id,
640 AppDataQueryResult::Many(_) => unreachable!(),
641 },
642 Err(e) => return Json(e.into()),
643 };
644
645 return match data.remove(id).await {
646 Ok(_) => match data.remove(views_id).await {
647 Ok(_) => Json(ApiReturn {
648 ok: true,
649 message: "Success".to_string(),
650 payload: None,
651 }),
652 Err(e) => Json(e.into()),
653 },
654 Err(e) => Json(e.into()),
655 };
656 }
657
658 if let Some(mut new_slug) = req.new_slug {
660 new_slug = new_slug.to_lowercase();
661
662 if new_slug.len() < 2 {
663 return Json(Error::DataTooShort("slug".to_string()).into());
664 }
665
666 if new_slug.len() > 32 {
667 return Json(Error::DataTooLong("slug".to_string()).into());
668 }
669
670 let regex = regex::RegexBuilder::new(NAME_REGEX)
672 .multi_line(true)
673 .build()
674 .unwrap();
675
676 if regex.captures(&new_slug).is_some() {
677 return Json(
678 Error::MiscError("This slug contains invalid characters".to_string()).into(),
679 );
680 }
681
682 if data
684 .query(&SimplifiedQuery {
685 query: AppDataSelectQuery::KeyIs(format!("entries('{}')", new_slug)),
686 mode: AppDataSelectMode::One(0),
687 })
688 .await
689 .is_ok()
690 {
691 return Json(Error::MiscError("Slug already in use".to_string()).into());
692 }
693
694 let views_id = match data
695 .query(&SimplifiedQuery {
696 query: AppDataSelectQuery::KeyIs(format!("entries.views('{}')", slug)),
697 mode: AppDataSelectMode::One(0),
698 })
699 .await
700 {
701 Ok(r) => match r {
702 AppDataQueryResult::One(r) => r.id,
703 AppDataQueryResult::Many(_) => unreachable!(),
704 },
705 Err(e) => return Json(e.into()),
706 };
707
708 if let Err(e) = data.rename(id, format!("entries('{}')", new_slug)).await {
710 return Json(e.into());
711 }
712
713 if let Err(e) = data
714 .rename(views_id, format!("entries.views('{}')", new_slug))
715 .await
716 {
717 return Json(e.into());
718 }
719
720 entry.slug = new_slug;
721 }
722
723 if let Some(new_edit_code) = req.new_edit_code {
724 entry.salt = salt();
725 entry.edit_code = hash(new_edit_code + &entry.salt);
726 }
727
728 if let Some(new_modify_code) = req.new_modify_code {
730 entry.modify_code = hash(new_modify_code + &entry.salt);
731 }
732 }
733
734 entry.content = req.content;
736 entry.edited = unix_epoch_timestamp();
737
738 if !using_modify_code {
739 entry.metadata = req.metadata;
740 entry.last_edit_from = real_ip;
741 }
742
743 if let Err(e) = data
744 .update(id, serde_json::to_string(&entry).unwrap())
745 .await
746 {
747 return Json(e.into());
748 }
749
750 Json(ApiReturn {
752 ok: true,
753 message: "Success".to_string(),
754 payload: Some(entry.slug),
755 })
756}