use crate::{ State, model::{Entry, EntryMetadata}, }; use axum::{ Extension, Json, Router, extract::Path, response::{Html, IntoResponse}, routing::{get, get_service, post}, }; use axum_extra::extract::CookieJar; use pathbufd::PathBufD; use serde::Deserialize; use serde_valid::Validate; use tera::Context; use tetratto_core::{ model::{ ApiReturn, Error, apps::{AppDataQueryResult, AppDataSelectMode, AppDataSelectQuery}, }, sdk::{DataClient, SimplifiedQuery}, }; use tetratto_shared::{ hash::{hash, salt}, unix_epoch_timestamp, }; pub const NAME_REGEX: &str = r"[^\w_\-\.,!]+"; pub fn routes() -> Router { Router::new() .nest_service( "/public", get_service(tower_http::services::ServeDir::new("./public")), ) .fallback(not_found_request) .route("/docs/{name}", get(view_doc_request)) // pages .route("/", get(index_request)) .route("/{slug}", get(view_request)) .route("/{slug}/edit", get(editor_request)) // api .route("/api/v1/render", post(render_request)) .route("/api/v1/entries", post(create_request)) .route("/api/v1/entries/{slug}", post(edit_request)) .route("/api/v1/entries/{slug}", get(exists_request)) } fn default_context(data: &DataClient, build_code: &str) -> Context { let mut ctx = Context::new(); ctx.insert( "name", &std::env::var("NAME").unwrap_or("Attobin".to_string()), ); ctx.insert("tetratto", &data.host); ctx.insert( "what_page_slug", &std::env::var("WHAT_SLUG").unwrap_or("what".to_string()), ); ctx.insert("build_code", &build_code); ctx } // pages async fn not_found_request(Extension(data): Extension) -> impl IntoResponse { let (ref data, ref tera, ref build_code) = *data.read().await; let mut ctx = default_context(&data, &build_code); ctx.insert( "error", &Error::GeneralNotFound("page".to_string()).to_string(), ); return Html(tera.render("error.lisp", &ctx).unwrap()); } async fn index_request(Extension(data): Extension) -> impl IntoResponse { let (ref data, ref tera, ref build_code) = *data.read().await; Html( tera.render("index.lisp", &default_context(&data, &build_code)) .unwrap(), ) } async fn view_doc_request( Extension(data): Extension, Path(name): Path, ) -> impl IntoResponse { let (ref data, ref tera, ref build_code) = *data.read().await; let path = PathBufD::current().extend(&["docs", &format!("{name}.md")]); if !std::fs::exists(&path).unwrap_or(false) { let mut ctx = default_context(&data, &build_code); ctx.insert( "error", &Error::GeneralNotFound("entry".to_string()).to_string(), ); return Html(tera.render("error.lisp", &ctx).unwrap()); } let text = match std::fs::read_to_string(&path) { Ok(t) => t, Err(e) => { let mut ctx = default_context(&data, &build_code); ctx.insert("error", &Error::MiscError(e.to_string()).to_string()); return Html(tera.render("error.lisp", &ctx).unwrap()); } }; let mut ctx = default_context(&data, &build_code); ctx.insert("text", &text); ctx.insert("file_name", &name); return Html(tera.render("doc.lisp", &ctx).unwrap()); } async fn view_request( Extension(data): Extension, Path(slug): Path, ) -> impl IntoResponse { let (ref data, ref tera, ref build_code) = *data.read().await; let entry = match data .query(&SimplifiedQuery { query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug)), mode: AppDataSelectMode::One(0), }) .await { Ok(r) => match r { AppDataQueryResult::One(r) => serde_json::from_str::(&r.value).unwrap(), AppDataQueryResult::Many(_) => unreachable!(), }, Err(_) => { let mut ctx = default_context(&data, &build_code); ctx.insert( "error", &Error::GeneralNotFound("entry".to_string()).to_string(), ); return Html(tera.render("error.lisp", &ctx).unwrap()); } }; // check metadata let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&entry.metadata)) { Ok(x) => x, Err(e) => { let mut ctx = default_context(&data, &build_code); ctx.insert("error", &e.to_string()); return Html(tera.render("error.lisp", &ctx).unwrap()); } }; if let Err(e) = metadata.validate() { let mut ctx = default_context(&data, &build_code); ctx.insert("error", &e.to_string()); return Html(tera.render("error.lisp", &ctx).unwrap()); } // pull views let views = if !metadata.option_disable_views { match data .query(&SimplifiedQuery { query: AppDataSelectQuery::KeyIs(format!("entries.views('{}')", slug)), mode: AppDataSelectMode::One(0), }) .await { Ok(r) => match r { AppDataQueryResult::One(r) => { // count view let views = r.value.parse::().unwrap(); if let Err(e) = data.update(r.id, (views + 1).to_string()).await { let mut ctx = default_context(&data, &build_code); ctx.insert("error", &e.to_string()); return Html(tera.render("error.lisp", &ctx).unwrap()); } views } AppDataQueryResult::Many(_) => unreachable!(), }, Err(e) => { let mut ctx = default_context(&data, &build_code); ctx.insert("error", &e.to_string()); return Html(tera.render("error.lisp", &ctx).unwrap()); } } } else { 0 }; // ... let mut ctx = default_context(&data, &build_code); ctx.insert("entry", &entry); ctx.insert("views", &views); ctx.insert("metadata", &metadata); ctx.insert("metadata_head", &metadata.head_tags()); ctx.insert("metadata_css", &metadata.css()); Html(tera.render("view.lisp", &ctx).unwrap()) } async fn editor_request( Extension(data): Extension, Path(slug): Path, ) -> impl IntoResponse { let (ref data, ref tera, ref build_code) = *data.read().await; let entry = match data .query(&SimplifiedQuery { query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug)), mode: AppDataSelectMode::One(0), }) .await { Ok(r) => match r { AppDataQueryResult::One(r) => serde_json::from_str::(&r.value).unwrap(), AppDataQueryResult::Many(_) => unreachable!(), }, Err(_) => { let mut ctx = default_context(&data, &build_code); ctx.insert( "error", &Error::GeneralNotFound("entry".to_string()).to_string(), ); return Html(tera.render("error.lisp", &ctx).unwrap()); } }; // ... let mut ctx = default_context(&data, &build_code); ctx.insert("entry", &entry); Html(tera.render("edit.lisp", &ctx).unwrap()) } // api #[derive(Deserialize)] struct RenderMarkdown { content: String, metadata: String, } async fn render_request(Json(req): Json) -> impl IntoResponse { let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&req.metadata)) { Ok(x) => x, Err(e) => return Html(e.to_string()), }; if let Err(e) = metadata.validate() { return Html(e.to_string()); } Html(crate::markdown::render_markdown(&req.content) + &metadata.css()) } async fn exists_request( Extension(data): Extension, Path(slug): Path, ) -> impl IntoResponse { let (ref data, _, _) = *data.read().await; Json(ApiReturn { ok: true, message: "Success".to_string(), payload: data .query(&SimplifiedQuery { query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug)), mode: AppDataSelectMode::One(0), }) .await .is_ok(), }) } #[derive(Deserialize)] struct CreateEntry { content: String, #[serde(default)] metadata: String, #[serde(default = "default_random")] slug: String, #[serde(default = "default_random")] edit_code: String, } fn default_random() -> String { salt() } /// The time that must be waited between each entry creation. const CREATE_WAIT_TIME: usize = 5000; async fn create_request( jar: CookieJar, Extension(data): Extension, Json(req): Json, ) -> std::result::Result>> { let (ref data, _, _) = *data.read().await; // check wait time if let Some(cookie) = jar.get("__Secure-Claim-Next") { if unix_epoch_timestamp() != cookie .to_string() .replace("__Secure-Claim-Next=", "") .parse::() .unwrap_or(0) { return Err(Json( Error::MiscError("You must wait a bit to create another entry".to_string()).into(), )); } } // check lengths if req.slug.len() < 2 { return Err(Json(Error::DataTooShort("slug".to_string()).into())); } if req.slug.len() > 32 { return Err(Json(Error::DataTooLong("slug".to_string()).into())); } if req.content.len() < 2 { return Err(Json(Error::DataTooShort("content".to_string()).into())); } if req.content.len() > 150_000 { return Err(Json(Error::DataTooLong("content".to_string()).into())); } // check slug let regex = regex::RegexBuilder::new(NAME_REGEX) .multi_line(true) .build() .unwrap(); if regex.captures(&req.slug).is_some() { return Err(Json( Error::MiscError("This slug contains invalid characters".to_string()).into(), )); } // check metadata let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&req.metadata)) { Ok(x) => x, Err(e) => return Err(Json(Error::MiscError(e.to_string()).into())), }; if let Err(e) = metadata.validate() { return Err(Json(Error::MiscError(e.to_string()).into())); } // check for existing if data .query(&SimplifiedQuery { query: AppDataSelectQuery::KeyIs(format!("entries('{}')", req.slug)), mode: AppDataSelectMode::One(0), }) .await .is_ok() { return Err(Json( Error::MiscError("Slug already in use".to_string()).into(), )); } // create let created = unix_epoch_timestamp(); let salt = salt(); if let Err(e) = data .insert( format!("entries('{}')", req.slug), serde_json::to_string(&Entry { slug: req.slug.clone(), edit_code: hash(req.edit_code.clone() + &salt), salt, created, edited: created, content: req.content, metadata: req.metadata, }) .unwrap(), ) .await { return Err(Json(e.into())); } if let Err(e) = data .insert(format!("entries.views('{}')", req.slug), 0.to_string()) .await { return Err(Json(e.into())); } // return Ok(( [( "Set-Cookie", format!( "__Secure-Claim-Next={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age=5", unix_epoch_timestamp() + CREATE_WAIT_TIME ), )], Json(ApiReturn { ok: true, message: "Success".to_string(), payload: Some((req.slug, req.edit_code)), }), )) } #[derive(Deserialize)] struct EditEntry { content: String, edit_code: String, #[serde(default)] new_slug: Option, #[serde(default)] new_edit_code: Option, #[serde(default)] metadata: String, #[serde(default)] delete: bool, } async fn edit_request( Extension(data): Extension, Path(slug): Path, Json(req): Json, ) -> impl IntoResponse { let (ref data, _, _) = *data.read().await; // check content length if req.content.len() < 2 { return Json(Error::DataTooShort("content".to_string()).into()); } if req.content.len() > 150_000 { return Json(Error::DataTooLong("content".to_string()).into()); } // check metadata let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&req.metadata)) { Ok(x) => x, Err(e) => return Json(Error::MiscError(e.to_string()).into()), }; if let Err(e) = metadata.validate() { return Json(Error::MiscError(e.to_string()).into()); } // ... let (id, mut entry) = match data .query(&SimplifiedQuery { query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug)), mode: AppDataSelectMode::One(0), }) .await { Ok(r) => match r { AppDataQueryResult::One(r) => (r.id, serde_json::from_str::(&r.value).unwrap()), AppDataQueryResult::Many(_) => unreachable!(), }, Err(e) => return Json(e.into()), }; // check edit code if hash(req.edit_code.clone() + &entry.salt) != entry.edit_code { return Json(Error::NotAllowed.into()); } // handle delete if req.delete { let views_id = match data .query(&SimplifiedQuery { query: AppDataSelectQuery::KeyIs(format!("entries.views('{}')", slug)), mode: AppDataSelectMode::One(0), }) .await { Ok(r) => match r { AppDataQueryResult::One(r) => r.id, AppDataQueryResult::Many(_) => unreachable!(), }, Err(e) => return Json(e.into()), }; return match data.remove(id).await { Ok(_) => match data.remove(views_id).await { Ok(_) => Json(ApiReturn { ok: true, message: "Success".to_string(), payload: None, }), Err(e) => Json(e.into()), }, Err(e) => Json(e.into()), }; } // check edited slug and edit code if let Some(new_slug) = req.new_slug { if new_slug.len() < 2 { return Json(Error::DataTooShort("slug".to_string()).into()); } if new_slug.len() > 32 { return Json(Error::DataTooLong("slug".to_string()).into()); } // check slug let regex = regex::RegexBuilder::new(NAME_REGEX) .multi_line(true) .build() .unwrap(); if regex.captures(&new_slug).is_some() { return Json( Error::MiscError("This slug contains invalid characters".to_string()).into(), ); } // check for existing if data .query(&SimplifiedQuery { query: AppDataSelectQuery::KeyIs(format!("entries('{}')", new_slug)), mode: AppDataSelectMode::One(0), }) .await .is_ok() { return Json(Error::MiscError("Slug already in use".to_string()).into()); } let views_id = match data .query(&SimplifiedQuery { query: AppDataSelectQuery::KeyIs(format!("entries.views('{}')", slug)), mode: AppDataSelectMode::One(0), }) .await { Ok(r) => match r { AppDataQueryResult::One(r) => r.id, AppDataQueryResult::Many(_) => unreachable!(), }, Err(e) => return Json(e.into()), }; // rename if let Err(e) = data.rename(id, format!("entries('{}')", new_slug)).await { return Json(e.into()); } if let Err(e) = data .rename(views_id, format!("entries.views('{}')", new_slug)) .await { return Json(e.into()); } entry.slug = new_slug; } if let Some(new_edit_code) = req.new_edit_code { entry.salt = salt(); entry.edit_code = hash(new_edit_code + &entry.salt); } // update entry.content = req.content; entry.metadata = req.metadata; entry.edited = unix_epoch_timestamp(); if let Err(e) = data .update(id, serde_json::to_string(&entry).unwrap()) .await { return Json(e.into()); } // return Json(ApiReturn { ok: true, message: "Success".to_string(), payload: Some(entry.slug), }) }