fluffle/src/routes.rs

530 lines
15 KiB
Rust
Raw Normal View History

2025-07-21 02:11:23 -04:00
use crate::{
State,
model::{Entry, EntryMetadata},
};
2025-07-20 02:49:42 -04:00
use axum::{
Extension, Json, Router,
extract::Path,
response::{Html, IntoResponse},
routing::{get, get_service, post},
};
use serde::Deserialize;
2025-07-21 02:11:23 -04:00
use serde_valid::Validate;
2025-07-20 02:49:42 -04:00
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_\-\.,!]+";
2025-07-20 02:49:42 -04:00
pub fn routes() -> Router {
Router::new()
.nest_service(
"/public",
get_service(tower_http::services::ServeDir::new("./public")),
)
.fallback(not_found_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))
2025-07-20 20:43:01 -04:00
.route("/api/v1/entries/{slug}", get(exists_request))
2025-07-20 02:49:42 -04:00
}
2025-07-20 03:24:55 -04:00
fn default_context(data: &DataClient, build_code: &str) -> Context {
2025-07-20 02:49:42 -04:00
let mut ctx = Context::new();
ctx.insert(
"name",
&std::env::var("NAME").unwrap_or("Attobin".to_string()),
);
ctx.insert("tetratto", &data.host);
2025-07-20 03:03:48 -04:00
ctx.insert(
"what_page_slug",
&std::env::var("WHAT_SLUG").unwrap_or("what".to_string()),
);
2025-07-20 03:24:55 -04:00
ctx.insert("build_code", &build_code);
2025-07-20 02:49:42 -04:00
ctx
}
// pages
async fn not_found_request(Extension(data): Extension<State>) -> impl IntoResponse {
2025-07-20 03:24:55 -04:00
let (ref data, ref tera, ref build_code) = *data.read().await;
2025-07-20 02:49:42 -04:00
2025-07-20 03:24:55 -04:00
let mut ctx = default_context(&data, &build_code);
2025-07-20 02:49:42 -04:00
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<State>) -> impl IntoResponse {
2025-07-20 03:24:55 -04:00
let (ref data, ref tera, ref build_code) = *data.read().await;
Html(
tera.render("index.lisp", &default_context(&data, &build_code))
.unwrap(),
)
2025-07-20 02:49:42 -04:00
}
async fn view_request(
Extension(data): Extension<State>,
Path(slug): Path<String>,
) -> impl IntoResponse {
2025-07-20 03:24:55 -04:00
let (ref data, ref tera, ref build_code) = *data.read().await;
2025-07-20 02:49:42 -04:00
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::<Entry>(&r.value).unwrap(),
AppDataQueryResult::Many(_) => unreachable!(),
},
Err(_) => {
2025-07-20 03:24:55 -04:00
let mut ctx = default_context(&data, &build_code);
2025-07-20 02:49:42 -04:00
ctx.insert(
"error",
&Error::GeneralNotFound("entry".to_string()).to_string(),
);
return Html(tera.render("error.lisp", &ctx).unwrap());
}
};
2025-07-21 02:11:23 -04:00
// check metadata
let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&entry.metadata))
{
2025-07-21 02:11:23 -04:00
Ok(x) => x,
2025-07-20 02:49:42 -04:00
Err(e) => {
2025-07-20 03:24:55 -04:00
let mut ctx = default_context(&data, &build_code);
2025-07-20 02:49:42 -04:00
ctx.insert("error", &e.to_string());
return Html(tera.render("error.lisp", &ctx).unwrap());
}
};
2025-07-21 02:11:23 -04:00
if let Err(e) = metadata.validate() {
2025-07-20 03:24:55 -04:00
let mut ctx = default_context(&data, &build_code);
2025-07-20 02:49:42 -04:00
ctx.insert("error", &e.to_string());
return Html(tera.render("error.lisp", &ctx).unwrap());
}
2025-07-21 02:11:23 -04:00
// 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::<usize>().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
};
2025-07-20 02:49:42 -04:00
// ...
2025-07-20 03:24:55 -04:00
let mut ctx = default_context(&data, &build_code);
2025-07-20 02:49:42 -04:00
ctx.insert("entry", &entry);
ctx.insert("views", &views);
2025-07-21 02:11:23 -04:00
ctx.insert("metadata", &metadata);
ctx.insert("metadata_head", &metadata.head_tags());
ctx.insert("metadata_css", &metadata.css());
2025-07-20 02:49:42 -04:00
Html(tera.render("view.lisp", &ctx).unwrap())
}
async fn editor_request(
Extension(data): Extension<State>,
Path(slug): Path<String>,
) -> impl IntoResponse {
2025-07-20 03:24:55 -04:00
let (ref data, ref tera, ref build_code) = *data.read().await;
2025-07-20 02:49:42 -04:00
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::<Entry>(&r.value).unwrap(),
AppDataQueryResult::Many(_) => unreachable!(),
},
Err(_) => {
2025-07-20 03:24:55 -04:00
let mut ctx = default_context(&data, &build_code);
2025-07-20 02:49:42 -04:00
ctx.insert(
"error",
&Error::GeneralNotFound("entry".to_string()).to_string(),
);
return Html(tera.render("error.lisp", &ctx).unwrap());
}
};
// ...
2025-07-20 03:24:55 -04:00
let mut ctx = default_context(&data, &build_code);
2025-07-20 02:49:42 -04:00
ctx.insert("entry", &entry);
Html(tera.render("edit.lisp", &ctx).unwrap())
}
// api
#[derive(Deserialize)]
struct RenderMarkdown {
content: String,
2025-07-24 01:06:03 -04:00
metadata: String,
2025-07-20 02:49:42 -04:00
}
async fn render_request(Json(req): Json<RenderMarkdown>) -> impl IntoResponse {
2025-07-24 01:06:03 -04:00
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())
2025-07-20 02:49:42 -04:00
}
2025-07-20 20:43:01 -04:00
async fn exists_request(
Extension(data): Extension<State>,
Path(slug): Path<String>,
) -> 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(),
})
}
2025-07-20 02:49:42 -04:00
#[derive(Deserialize)]
struct CreateEntry {
content: String,
2025-07-21 02:11:23 -04:00
#[serde(default)]
metadata: String,
2025-07-20 02:49:42 -04:00
#[serde(default = "default_random")]
slug: String,
#[serde(default = "default_random")]
edit_code: String,
}
fn default_random() -> String {
salt()
}
async fn create_request(
Extension(data): Extension<State>,
Json(req): Json<CreateEntry>,
) -> impl IntoResponse {
2025-07-20 03:24:55 -04:00
let (ref data, _, _) = *data.read().await;
2025-07-20 02:49:42 -04:00
// check lengths
if req.slug.len() < 2 {
return Json(Error::DataTooShort("slug".to_string()).into());
}
if req.slug.len() > 32 {
return Json(Error::DataTooLong("slug".to_string()).into());
}
2025-07-20 20:43:01 -04:00
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 slug
let regex = regex::RegexBuilder::new(NAME_REGEX)
.multi_line(true)
.build()
.unwrap();
if regex.captures(&req.slug).is_some() {
return Json(Error::MiscError("This slug contains invalid characters".to_string()).into());
}
2025-07-21 02:11:23 -04:00
// check metadata
let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&req.metadata)) {
2025-07-21 02:11:23 -04:00
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());
}
2025-07-20 02:49:42 -04:00
// check for existing
if data
.query(&SimplifiedQuery {
query: AppDataSelectQuery::KeyIs(format!("entries('{}')", req.slug)),
mode: AppDataSelectMode::One(0),
})
.await
.is_ok()
{
return 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,
2025-07-21 02:11:23 -04:00
metadata: req.metadata,
2025-07-20 02:49:42 -04:00
})
.unwrap(),
)
.await
{
return Json(e.into());
}
if let Err(e) = data
.insert(format!("entries.views('{}')", req.slug), 0.to_string())
.await
{
return Json(e.into());
}
// return
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<String>,
#[serde(default)]
new_edit_code: Option<String>,
#[serde(default)]
2025-07-21 02:11:23 -04:00
metadata: String,
#[serde(default)]
2025-07-20 02:49:42 -04:00
delete: bool,
}
async fn edit_request(
Extension(data): Extension<State>,
Path(slug): Path<String>,
Json(req): Json<EditEntry>,
) -> impl IntoResponse {
2025-07-20 03:24:55 -04:00
let (ref data, _, _) = *data.read().await;
2025-07-20 02:49:42 -04:00
2025-07-20 20:43:01 -04:00
// 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());
}
2025-07-21 02:11:23 -04:00
// check metadata
let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&req.metadata)) {
2025-07-21 02:11:23 -04:00
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());
}
2025-07-20 20:43:01 -04:00
// ...
2025-07-20 02:49:42 -04:00
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::<Entry>(&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(),
);
}
2025-07-20 02:49:42 -04:00
// 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;
2025-07-21 02:11:23 -04:00
entry.metadata = req.metadata;
2025-07-20 02:49:42 -04:00
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),
})
}