fluffle/
routes.rs

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        // pages
42        .route("/", get(index_request))
43        .route("/{slug}", get(view_request))
44        .route("/{slug}/edit", get(editor_request))
45        // api
46        .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
69// pages
70async 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    // check metadata
159    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    // ...
172    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    // pull views
181    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                    // count view
192                    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    // ...
217    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    // ...
266    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    // ...
279    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// api
288#[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    // hash passwords
356    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
383/// The time that must be waited between each entry creation.
384const 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    // get real ip
396    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    // check for ip ban
404    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    // check wait time
411    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    // check lengths
426    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    // check slug
443    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    // check metadata
455    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    // check for existing
471    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    // create
485    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    // return
517    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    // get real ip
559    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    // check for ip ban
567    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    // check content length
574    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    // check metadata
583    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    // ...
599    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    // check edit code
617    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    // ...
628    if !using_modify_code {
629        // handle delete
630        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        // check edited slug and edit code
659        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            // check slug
671            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            // check for existing
683            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            // rename
709            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        // update modify code
729        if let Some(new_modify_code) = req.new_modify_code {
730            entry.modify_code = hash(new_modify_code + &entry.salt);
731        }
732    }
733
734    // update
735    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    // return
751    Json(ApiReturn {
752        ok: true,
753        message: "Success".to_string(),
754        payload: Some(entry.slug),
755    })
756}