2025-07-25 18:33:16 -04:00
|
|
|
use std::env::var;
|
|
|
|
|
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,
|
2025-07-25 18:33:16 -04:00
|
|
|
http::{HeaderMap, HeaderValue},
|
2025-07-20 02:49:42 -04:00
|
|
|
response::{Html, IntoResponse},
|
|
|
|
routing::{get, get_service, post},
|
|
|
|
};
|
2025-07-25 15:12:15 -04:00
|
|
|
use axum_extra::extract::CookieJar;
|
|
|
|
use pathbufd::PathBufD;
|
2025-07-20 02:49:42 -04:00
|
|
|
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,
|
|
|
|
};
|
|
|
|
|
2025-07-22 14:48:55 -04:00
|
|
|
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)
|
2025-07-25 15:12:15 -04:00
|
|
|
.route("/docs/{name}", get(view_doc_request))
|
2025-07-20 02:49:42 -04:00
|
|
|
// 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();
|
2025-07-25 18:33:16 -04:00
|
|
|
ctx.insert("name", &var("NAME").unwrap_or("Fluffle".to_string()));
|
2025-07-25 18:06:29 -04:00
|
|
|
ctx.insert(
|
|
|
|
"theme_color",
|
2025-07-25 18:33:16 -04:00
|
|
|
&var("THEME_COLOR").unwrap_or("#a3b3ff".to_string()),
|
2025-07-25 18:06:29 -04:00
|
|
|
);
|
2025-07-20 02:49:42 -04:00
|
|
|
ctx.insert("tetratto", &data.host);
|
2025-07-20 03:03:48 -04:00
|
|
|
ctx.insert(
|
|
|
|
"what_page_slug",
|
2025-07-25 18:33:16 -04:00
|
|
|
&var("WHAT_SLUG").unwrap_or("what".to_string()),
|
2025-07-20 03:03:48 -04:00
|
|
|
);
|
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
|
|
|
}
|
|
|
|
|
2025-07-25 15:12:15 -04:00
|
|
|
async fn view_doc_request(
|
|
|
|
Extension(data): Extension<State>,
|
|
|
|
Path(name): Path<String>,
|
|
|
|
) -> 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());
|
|
|
|
}
|
|
|
|
|
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
|
2025-07-21 22:28:43 -04:00
|
|
|
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());
|
|
|
|
}
|
|
|
|
|
2025-07-25 18:06:29 -04:00
|
|
|
Html(
|
|
|
|
crate::markdown::render_markdown(&req.content)
|
|
|
|
+ &format!("<div id=\"metadata_css\">{}</div>", 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()
|
|
|
|
}
|
|
|
|
|
2025-07-25 15:12:15 -04:00
|
|
|
/// The time that must be waited between each entry creation.
|
|
|
|
const CREATE_WAIT_TIME: usize = 5000;
|
|
|
|
|
2025-07-20 02:49:42 -04:00
|
|
|
async fn create_request(
|
2025-07-25 15:12:15 -04:00
|
|
|
jar: CookieJar,
|
2025-07-25 18:33:16 -04:00
|
|
|
headers: HeaderMap,
|
2025-07-20 02:49:42 -04:00
|
|
|
Extension(data): Extension<State>,
|
|
|
|
Json(req): Json<CreateEntry>,
|
2025-07-25 15:12:15 -04:00
|
|
|
) -> std::result::Result<impl IntoResponse, Json<ApiReturn<()>>> {
|
2025-07-20 03:24:55 -04:00
|
|
|
let (ref data, _, _) = *data.read().await;
|
2025-07-20 02:49:42 -04:00
|
|
|
|
2025-07-25 18:33:16 -04:00
|
|
|
// get real ip
|
|
|
|
let real_ip = headers
|
|
|
|
.get(var("REAL_IP_HEADER").unwrap_or("CF-Connecting-IP".to_string()))
|
|
|
|
.unwrap_or(&HeaderValue::from_static(""))
|
|
|
|
.to_str()
|
|
|
|
.unwrap_or("")
|
|
|
|
.to_string();
|
|
|
|
|
|
|
|
// check for ip ban
|
|
|
|
if !real_ip.is_empty() {
|
2025-07-25 19:37:00 -04:00
|
|
|
if data.check_ip(&real_ip.as_str()).await.unwrap_or(false) {
|
2025-07-25 18:33:16 -04:00
|
|
|
return Err(Json(Error::NotAllowed.into()));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-07-25 15:12:15 -04:00
|
|
|
// check wait time
|
|
|
|
if let Some(cookie) = jar.get("__Secure-Claim-Next") {
|
|
|
|
if unix_epoch_timestamp()
|
|
|
|
!= cookie
|
|
|
|
.to_string()
|
|
|
|
.replace("__Secure-Claim-Next=", "")
|
|
|
|
.parse::<usize>()
|
|
|
|
.unwrap_or(0)
|
|
|
|
{
|
|
|
|
return Err(Json(
|
|
|
|
Error::MiscError("You must wait a bit to create another entry".to_string()).into(),
|
|
|
|
));
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
2025-07-20 02:49:42 -04:00
|
|
|
// check lengths
|
|
|
|
if req.slug.len() < 2 {
|
2025-07-25 15:12:15 -04:00
|
|
|
return Err(Json(Error::DataTooShort("slug".to_string()).into()));
|
2025-07-20 02:49:42 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
if req.slug.len() > 32 {
|
2025-07-25 15:12:15 -04:00
|
|
|
return Err(Json(Error::DataTooLong("slug".to_string()).into()));
|
2025-07-20 02:49:42 -04:00
|
|
|
}
|
|
|
|
|
2025-07-20 20:43:01 -04:00
|
|
|
if req.content.len() < 2 {
|
2025-07-25 15:12:15 -04:00
|
|
|
return Err(Json(Error::DataTooShort("content".to_string()).into()));
|
2025-07-20 20:43:01 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
if req.content.len() > 150_000 {
|
2025-07-25 15:12:15 -04:00
|
|
|
return Err(Json(Error::DataTooLong("content".to_string()).into()));
|
2025-07-20 20:43:01 -04:00
|
|
|
}
|
|
|
|
|
2025-07-22 14:48:55 -04:00
|
|
|
// check slug
|
|
|
|
let regex = regex::RegexBuilder::new(NAME_REGEX)
|
|
|
|
.multi_line(true)
|
|
|
|
.build()
|
|
|
|
.unwrap();
|
|
|
|
|
|
|
|
if regex.captures(&req.slug).is_some() {
|
2025-07-25 15:12:15 -04:00
|
|
|
return Err(Json(
|
|
|
|
Error::MiscError("This slug contains invalid characters".to_string()).into(),
|
|
|
|
));
|
2025-07-22 14:48:55 -04:00
|
|
|
}
|
|
|
|
|
2025-07-21 02:11:23 -04:00
|
|
|
// check metadata
|
2025-07-21 22:28:43 -04:00
|
|
|
let metadata: EntryMetadata = match toml::from_str(&EntryMetadata::ini_to_toml(&req.metadata)) {
|
2025-07-21 02:11:23 -04:00
|
|
|
Ok(x) => x,
|
2025-07-25 15:12:15 -04:00
|
|
|
Err(e) => return Err(Json(Error::MiscError(e.to_string()).into())),
|
2025-07-21 02:11:23 -04:00
|
|
|
};
|
|
|
|
|
|
|
|
if let Err(e) = metadata.validate() {
|
2025-07-25 15:12:15 -04:00
|
|
|
return Err(Json(Error::MiscError(e.to_string()).into()));
|
2025-07-21 02:11:23 -04:00
|
|
|
}
|
|
|
|
|
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()
|
|
|
|
{
|
2025-07-25 15:12:15 -04:00
|
|
|
return Err(Json(
|
|
|
|
Error::MiscError("Slug already in use".to_string()).into(),
|
|
|
|
));
|
2025-07-20 02:49:42 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
// 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-25 18:33:16 -04:00
|
|
|
last_edit_from: real_ip,
|
2025-07-20 02:49:42 -04:00
|
|
|
})
|
|
|
|
.unwrap(),
|
|
|
|
)
|
|
|
|
.await
|
|
|
|
{
|
2025-07-25 15:12:15 -04:00
|
|
|
return Err(Json(e.into()));
|
2025-07-20 02:49:42 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
if let Err(e) = data
|
|
|
|
.insert(format!("entries.views('{}')", req.slug), 0.to_string())
|
|
|
|
.await
|
|
|
|
{
|
2025-07-25 15:12:15 -04:00
|
|
|
return Err(Json(e.into()));
|
2025-07-20 02:49:42 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
// return
|
2025-07-25 15:12:15 -04:00
|
|
|
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)),
|
|
|
|
}),
|
|
|
|
))
|
2025-07-20 02:49:42 -04:00
|
|
|
}
|
|
|
|
|
|
|
|
#[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(
|
2025-07-25 18:33:16 -04:00
|
|
|
headers: HeaderMap,
|
2025-07-20 02:49:42 -04:00
|
|
|
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-25 18:33:16 -04:00
|
|
|
// get real ip
|
|
|
|
let real_ip = headers
|
|
|
|
.get(var("REAL_IP_HEADER").unwrap_or("CF-Connecting-IP".to_string()))
|
|
|
|
.unwrap_or(&HeaderValue::from_static(""))
|
|
|
|
.to_str()
|
|
|
|
.unwrap_or("")
|
|
|
|
.to_string();
|
|
|
|
|
|
|
|
// check for ip ban
|
|
|
|
if !real_ip.is_empty() {
|
2025-07-25 19:37:00 -04:00
|
|
|
if data.check_ip(&real_ip.as_str()).await.unwrap_or(false) {
|
2025-07-25 18:33:16 -04:00
|
|
|
return Json(Error::NotAllowed.into());
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
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
|
2025-07-21 22:28:43 -04:00
|
|
|
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());
|
|
|
|
}
|
|
|
|
|
2025-07-22 14:48:55 -04:00
|
|
|
// 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-25 18:33:16 -04:00
|
|
|
entry.last_edit_from = real_ip;
|
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),
|
|
|
|
})
|
|
|
|
}
|