fluffle/
routes.rs

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        // pages
30        .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        // api
35        .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
55// pages
56async 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    // check metadata
152    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    // ...
168    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    // pull views
180    if jar.get("Atto-Viewed").is_none() {
181        // the Atto-Viewed cookie tells us if we've already viewed this
182        // entry recently (at all in the past week)
183        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    // ...
195    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        // regular view
206        (
207            [viewed_header],
208            Html(tera.render("view.lisp", &ctx).unwrap()),
209        )
210    } else {
211        // warning
212        (
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    // ...
247    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    // ...
260    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; // 1 week
269
270async 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    // check metadata
291    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    // ...
304    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// api
317#[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
368/// The time that must be waited between each entry creation.
369const 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    // get real ip
381    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    // check for ip ban
389    // if !real_ip.is_empty() {
390    //     if data.check_ip(&real_ip.as_str()).await.unwrap_or(false) {
391    //         return Err(Json(Error::NotAllowed.into()));
392    //     }
393    // }
394
395    // check wait time
396    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    // create
411    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    // return
425    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    // get real ip
466    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    // check for ip ban
474    // if !real_ip.is_empty() {
475    //     if data.check_ip(&real_ip.as_str()).await.unwrap_or(false) {
476    //         return Json(Error::NotAllowed.into());
477    //     }
478    // }
479
480    // handle delete
481    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    // return
493    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}