From 187508b8f34b0879132df670689846d696cd4ff4 Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 18 Aug 2025 21:32:43 -0400 Subject: [PATCH] add: change database to postgres --- .gitignore | 2 + Cargo.lock | 3 +- Cargo.toml | 3 +- app/public/style.css | 28 ++ app/templates_src/edit.lisp | 2 +- app/templates_src/root.lisp | 2 +- app/templates_src/view.lisp | 2 +- src/config.rs | 106 ++++++ src/database/entries.rs | 316 ++++++++++++++++ src/database/mod.rs | 30 ++ src/database/sql/create_entries.sql | 13 + src/database/sql/mod.rs | 1 + src/main.rs | 16 +- src/model.rs | 42 +++ src/routes.rs | 552 +++++----------------------- 15 files changed, 647 insertions(+), 471 deletions(-) create mode 100644 src/config.rs create mode 100644 src/database/entries.rs create mode 100644 src/database/mod.rs create mode 100644 src/database/sql/create_entries.sql create mode 100644 src/database/sql/mod.rs diff --git a/.gitignore b/.gitignore index ea8c4bf..fb3ddd3 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ /target +app/fluffle.toml +migration.js diff --git a/Cargo.lock b/Cargo.lock index b29b7f3..76c7ea5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -607,13 +607,14 @@ dependencies = [ [[package]] name = "fluffle" -version = "0.4.0" +version = "1.0.0" dependencies = [ "axum", "axum-extra", "dotenv", "glob", "nanoneo", + "oiseau", "pathbufd", "regex", "serde", diff --git a/Cargo.toml b/Cargo.toml index e7c1cfc..1d91342 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "fluffle" -version = "0.4.0" +version = "1.0.0" edition = "2024" authors = ["trisuaso"] repository = "https://trisua.com/t/fluffle" @@ -31,3 +31,4 @@ serde_json = "1.0.142" toml = "0.9.4" serde_valid = { version = "1.0.5", features = ["toml"] } regex = "1.11.1" +oiseau = { version = "0.1.2", default-features = false, features = ["postgres", "redis",] } diff --git a/app/public/style.css b/app/public/style.css index 52323f1..1cf9f64 100644 --- a/app/public/style.css +++ b/app/public/style.css @@ -283,6 +283,14 @@ video { position: absolute; z-index: 2; top: 100%; + right: 0; + width: max-content; + max-width: 15rem; +} + +.dropdown .inner.left { + right: unset; + left: 0; } .dropdown .inner.open { @@ -823,3 +831,23 @@ dialog::backdrop { dialog:is(.dark *)::backdrop { background: hsla(0, 0%, 100%, 15%); } + +/* menus */ +menu { + display: flex; +} + +menu .button { + justify-content: flex-start; + width: 100%; +} + +menu .button.active { + background: var(--color-super-raised); +} + +menu.col { + flex-direction: column; + width: 25rem; + max-width: 100%; +} diff --git a/app/templates_src/edit.lisp b/app/templates_src/edit.lisp index adde02e..0edfdfb 100644 --- a/app/templates_src/edit.lisp +++ b/app/templates_src/edit.lisp @@ -143,7 +143,7 @@ const { load, failed } = submitter_load(rm ? document.getElementById(\"fake_delete_button\") : e.submitter); load(); - fetch(\"/api/v1/entries/{{ entry.slug }}\", { + fetch(\"/api/v1/entries/{{ entry.id }}\", { method: \"POST\", headers: { \"Content-Type\": \"application/json\", diff --git a/app/templates_src/root.lisp b/app/templates_src/root.lisp index 12eb5b9..cee0fa2 100644 --- a/app/templates_src/root.lisp +++ b/app/templates_src/root.lisp @@ -33,7 +33,7 @@ ("class" "button camo fade") (text "{{ icon \"menu\" }}")) (div - ("class" "inner") + ("class" "inner left") (a ("class" "button") ("href" "/") diff --git a/app/templates_src/view.lisp b/app/templates_src/view.lisp index 5dff22c..d06db89 100644 --- a/app/templates_src/view.lisp +++ b/app/templates_src/view.lisp @@ -55,7 +55,7 @@ ; views (text "{% if not metadata.option_disable_views -%}") - (span (text "Views: {{ views }}")) + (span (text "Views: {{ entry.views }}")) (text "{%- endif %}") ; easy-to-read diff --git a/src/config.rs b/src/config.rs new file mode 100644 index 0000000..1b9e05c --- /dev/null +++ b/src/config.rs @@ -0,0 +1,106 @@ +use oiseau::config::{Configuration, DatabaseConfig}; +use pathbufd::PathBufD; +use serde::{Deserialize, Serialize}; +use tetratto_shared::hash::random_id; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Config { + /// The name of the site. Shown in the UI. + #[serde(default = "default_name")] + pub name: String, + /// The (CSS) theme color of the site. Shown in the UI. + #[serde(default = "default_theme_color")] + pub theme_color: String, + /// The URL of the Tetratto host associated with this instance. + #[serde(default = "default_tetratto")] + pub tetratto: String, + /// The slug of the instance's information page. + /// + /// Should be the pathname WITHOUT the leading slash. + #[serde(default = "default_what_page_slug")] + pub what_page_slug: String, + /// The username of the handler account in charge of this instance on the + /// linked Tetratto host. + #[serde(default = "default_tetratto_handler_account_username")] + pub tetratto_handler_account_username: String, + /// Database configuration. + #[serde(default = "default_database")] + pub database: DatabaseConfig, + /// Real IP header (for reverse proxy). + #[serde(default = "default_real_ip_header")] + pub real_ip_header: String, + /// The master password which is allowed to do anything without password checks. + pub master_pass: String, +} + +fn default_name() -> String { + "Fluffle".to_string() +} + +fn default_theme_color() -> String { + "#a3b3ff".to_string() +} + +fn default_tetratto() -> String { + "https://tetratto.com".to_string() +} + +fn default_what_page_slug() -> String { + "what".to_string() +} + +fn default_tetratto_handler_account_username() -> String { + "fluffle".to_string() +} + +fn default_database() -> DatabaseConfig { + DatabaseConfig::default() +} + +fn default_real_ip_header() -> String { + "CF-Connecting-IP".to_string() +} + +impl Configuration for Config { + fn db_config(&self) -> DatabaseConfig { + self.database.to_owned() + } +} + +impl Default for Config { + fn default() -> Self { + Self { + name: default_name(), + theme_color: default_theme_color(), + tetratto: default_tetratto(), + what_page_slug: default_what_page_slug(), + tetratto_handler_account_username: default_tetratto_handler_account_username(), + database: default_database(), + real_ip_header: default_real_ip_header(), + master_pass: random_id(), + } + } +} + +impl Config { + /// Read the configuration file. + pub fn read() -> Self { + toml::from_str( + &match std::fs::read_to_string(PathBufD::current().join("fluffle.toml")) { + Ok(x) => x, + Err(_) => { + let x = Config::default(); + + std::fs::write( + PathBufD::current().join("fluffle.toml"), + &toml::to_string_pretty(&x).expect("failed to serialize config"), + ) + .expect("failed to write config"); + + return x; + } + }, + ) + .expect("failed to deserialize config") + } +} diff --git a/src/database/entries.rs b/src/database/entries.rs new file mode 100644 index 0000000..8776a14 --- /dev/null +++ b/src/database/entries.rs @@ -0,0 +1,316 @@ +use super::{DataManager, NAME_REGEX}; +use crate::model::{Entry, EntryMetadata}; +use oiseau::{PostgresRow, cache::Cache, execute, get, params, query_row}; +use serde_valid::Validate; +use tetratto_core::{ + auto_method, + model::{Error, Result}, +}; +use tetratto_shared::{hash::hash, unix_epoch_timestamp}; + +impl DataManager { + /// Get an [`Entry`] from an SQL row. + pub(crate) fn get_entry_from_row(x: &PostgresRow) -> Entry { + Entry { + id: get!(x->0(i64)) as usize, + slug: get!(x->1(String)), + edit_code: get!(x->2(String)), + salt: get!(x->3(String)), + created: get!(x->4(i64)) as usize, + edited: get!(x->5(i64)) as usize, + content: get!(x->6(String)), + metadata: get!(x->7(String)), + last_edit_from: get!(x->8(String)), + modify_code: get!(x->9(String)), + views: get!(x->10(i64)) as usize, + } + } + + auto_method!(get_entry_by_id(usize as i64)@get_entry_from_row -> "SELECT * FROM entries WHERE id = $1" --name="entry" --returns=Entry --cache-key-tmpl="fluf.entry:{}"); + auto_method!(get_entry_by_slug(&str)@get_entry_from_row -> "SELECT * FROM entries WHERE slug = $1" --name="entry" --returns=Entry --cache-key-tmpl="fluf.entry:{}"); + + fn hash_passwords(metadata: &mut EntryMetadata) -> (bool, String) { + // hash passwords + let do_update_metadata = (!metadata.option_view_password.is_empty() + || !metadata.option_source_password.is_empty()) + && (!metadata.option_view_password.starts_with("h:") + || !metadata.option_source_password.starts_with("h:")); + + if !metadata.option_view_password.is_empty() + && !metadata.option_view_password.starts_with("h:") + { + metadata.option_view_password = + format!("h:{}", hash(metadata.option_view_password.clone())); + } + + if !metadata.option_source_password.is_empty() + && !metadata.option_source_password.starts_with("h:") + { + metadata.option_source_password = + format!("h:{}", hash(metadata.option_source_password.clone())); + } + + if do_update_metadata { + if let Ok(x) = toml::to_string_pretty(&metadata) { + return (true, x); + }; + } + + (false, String::new()) + } + + /// Create a new entry in the database. + /// + /// # Arguments + /// * `data` - a mock [`Entry`] object to insert + pub async fn create_entry(&self, mut data: Entry) -> Result { + // check values + if data.slug.trim().len() < 2 { + return Err(Error::DataTooShort("slug".to_string())); + } else if data.slug.len() > 128 { + return Err(Error::DataTooLong("slug".to_string())); + } + + if data.content.len() < 2 { + return Err(Error::DataTooShort("content".to_string())); + } + + if data.content.len() > 150_000 { + return Err(Error::DataTooLong("content".to_string())); + } + + // check characters + let regex = regex::RegexBuilder::new(NAME_REGEX) + .multi_line(true) + .build() + .unwrap(); + + if regex.captures(&data.slug).is_some() { + return Err(Error::MiscError( + "This slug contains invalid characters".to_string(), + )); + } + + // check for existing + if self.get_entry_by_slug(&data.slug).await.is_ok() { + return Err(Error::MiscError("Slug is already in use".to_string())); + } + + // check metadata + let mut metadata: EntryMetadata = + match toml::from_str(&EntryMetadata::ini_to_toml(&data.metadata)) { + Ok(x) => x, + Err(e) => return Err(Error::MiscError(e.to_string())), + }; + + if let Err(e) = metadata.validate() { + return Err(Error::MiscError(e.to_string())); + } + + let (do_update_metadata, updated) = Self::hash_passwords(&mut metadata); + if do_update_metadata { + data.metadata = updated; + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO entries VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", + params![ + &(data.id as i64), + &data.slug, + &data.edit_code, + &data.salt, + &(data.created as i64), + &(data.edited as i64), + &data.content, + &data.metadata, + &data.last_edit_from, + &data.modify_code, + &(data.views as i64) + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + /// Update an existing entry. + pub async fn update_entry( + &self, + id: usize, + edit_code: String, + mut new_slug: String, + new_content: String, + mut new_metadata: String, + mut new_edit_code: String, + mut new_modify_code: String, + by_ip: String, + ) -> Result { + // check values + if !new_slug.is_empty() { + if new_slug.trim().len() < 2 { + return Err(Error::DataTooShort("slug".to_string())); + } else if new_slug.len() > 128 { + return Err(Error::DataTooLong("slug".to_string())); + } + } + + if new_content.len() < 2 { + return Err(Error::DataTooShort("content".to_string())); + } + + if new_content.len() > 150_000 { + return Err(Error::DataTooLong("content".to_string())); + } + + // check characters + let regex = regex::RegexBuilder::new(NAME_REGEX) + .multi_line(true) + .build() + .unwrap(); + + if regex.captures(&new_slug).is_some() { + return Err(Error::MiscError( + "This slug contains invalid characters".to_string(), + )); + } + + // check metadata + let mut metadata: EntryMetadata = + match toml::from_str(&EntryMetadata::ini_to_toml(&new_metadata)) { + Ok(x) => x, + Err(e) => return Err(Error::MiscError(e.to_string())), + }; + + if let Err(e) = metadata.validate() { + return Err(Error::MiscError(e.to_string())); + } + + let (do_update_metadata, updated) = Self::hash_passwords(&mut metadata); + if do_update_metadata { + new_metadata = updated; + } + + // get stored version of entry + let entry = self.get_entry_by_id(id).await?; + + // check password + let using_modify = hash(edit_code.clone() + &entry.salt) == entry.modify_code; + + if !using_modify && edit_code != self.0.0.master_pass { + if !entry.check_password(edit_code) { + return Err(Error::NotAllowed); + } + } + + // remove cached + self.cache_clear_entry(&entry).await; + + // hash junk + if !using_modify { + if new_slug.is_empty() { + // use original; no change + new_slug = entry.slug; + } else { + // make sure slug is all lowercase + new_slug = new_slug.to_lowercase(); + } + + if !new_edit_code.is_empty() { + new_edit_code = hash(new_edit_code + &entry.salt); + } else { + // use original; no change + new_edit_code = entry.edit_code; + } + + if !new_modify_code.is_empty() { + new_modify_code = hash(new_modify_code + &entry.salt); + } else { + // use original; no change + new_modify_code = entry.modify_code; + } + } else { + // using modify code; no change + new_slug = entry.slug; + new_edit_code = entry.edit_code; + new_modify_code = entry.modify_code; + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "UPDATE entries SET slug = $1, edit_code = $2, modify_code = $3, content = $4, metadata = $5, edited = $6, last_edit_from = $7 WHERE id = $8", + params![ + &new_slug, + &new_edit_code, + &new_modify_code, + &new_content, + &new_metadata, + &(unix_epoch_timestamp() as i64), + &by_ip, + &(id as i64), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(new_slug) + } + + /// Delete an existing entry. + pub async fn delete_entry(&self, id: usize, edit_code: String) -> Result<()> { + // get entry + let entry = self.get_entry_by_id(id).await?; + + // check password + if edit_code != self.0.0.master_pass { + if !entry.check_password(edit_code) { + return Err(Error::NotAllowed); + } + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "DELETE FROM entries WHERE id = $1", + params![&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.cache_clear_entry(&entry).await; + Ok(()) + } + + /// Remove an [`Entry`] from the cache. + pub async fn cache_clear_entry(&self, entry: &Entry) -> bool { + self.0.1.remove(format!("fluf.entry:{}", entry.id)).await + && self.0.1.remove(format!("fluf.entry:{}", entry.slug)).await + } + + auto_method!(incr_entry_views()@get_entry_by_id -> "UPDATE entries SET views = views + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_entry --incr); + auto_method!(decr_entry_views()@get_entry_by_id -> "UPDATE entries SET views = views - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_entry --decr=views); +} diff --git a/src/database/mod.rs b/src/database/mod.rs new file mode 100644 index 0000000..78f93bb --- /dev/null +++ b/src/database/mod.rs @@ -0,0 +1,30 @@ +mod entries; +mod sql; + +use crate::config::Config; +use oiseau::{execute, postgres::DataManager as OiseauManager, postgres::Result as PgResult}; +use tetratto_core::model::{Error, Result}; + +pub const NAME_REGEX: &str = r"[^\w_\-\.,!]+"; + +#[derive(Clone)] +pub struct DataManager(pub OiseauManager); + +impl DataManager { + /// Create a new [`DataManager`]. + pub async fn new(config: Config) -> PgResult { + Ok(Self(OiseauManager::new(config).await?)) + } + + /// Initialize tables. + pub async fn init(&self) -> Result<()> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + execute!(&conn, sql::CREATE_TABLE_ENTRIES).unwrap(); + + Ok(()) + } +} diff --git a/src/database/sql/create_entries.sql b/src/database/sql/create_entries.sql new file mode 100644 index 0000000..fcf0db5 --- /dev/null +++ b/src/database/sql/create_entries.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS entries ( + id BIGINT NOT NULL PRIMARY KEY, + slug TEXT NOT NULL, + edit_code TEXT NOT NULL, + salt TEXT NOT NULL, + created BIGINT NOT NULL, + edited BIGINT NOT NULL, + content TEXT NOT NULL, + metadata TEXT NOT NULL, + last_edit_from TEXT NOT NULL, + modify_code TEXT NOT NULL, + views BIGINT NOT NULL +) diff --git a/src/database/sql/mod.rs b/src/database/sql/mod.rs new file mode 100644 index 0000000..3a33cce --- /dev/null +++ b/src/database/sql/mod.rs @@ -0,0 +1 @@ +pub const CREATE_TABLE_ENTRIES: &str = include_str!("./create_entries.sql"); diff --git a/src/main.rs b/src/main.rs index e93e886..f215ddc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,13 +1,17 @@ #![doc = include_str!("../README.md")] +mod config; +mod database; mod markdown; mod model; mod routes; +use crate::database::DataManager; use axum::{Extension, Router}; +use config::Config; use nanoneo::core::element::Render; use std::{collections::HashMap, env::var, net::SocketAddr, process::exit, sync::Arc}; use tera::{Tera, Value}; -use tetratto_core::{html, sdk::DataClient}; +use tetratto_core::html; use tetratto_shared::hash::salt; use tokio::sync::RwLock; use tower_http::{ @@ -16,7 +20,7 @@ use tower_http::{ }; use tracing::{Level, info}; -pub(crate) type InnerState = (DataClient, Tera, String); +pub(crate) type InnerState = (DataManager, Tera, String); pub(crate) type State = Arc>; fn render_markdown(value: &Value, _: &HashMap) -> tera::Result { @@ -57,10 +61,10 @@ async fn main() { }; // ... - let database = DataClient::new( - Some(var("TETRATTO").unwrap_or("https://tetratto.com".to_string())), - var("API_KEY").expect("API_KEY environment variable required"), - ); + let database = DataManager::new(Config::read()) + .await + .expect("failed to connect to database"); + database.init().await.expect("failed to init database"); // build lisp create_dir_if_not_exists!("./templates_build"); diff --git a/src/model.rs b/src/model.rs index 0351f2b..d7645ed 100644 --- a/src/model.rs +++ b/src/model.rs @@ -2,9 +2,15 @@ use crate::markdown::is_numeric; use serde::{Deserialize, Serialize}; use serde_valid::Validate; use std::fmt::Display; +use tetratto_shared::{ + hash::{hash, salt}, + snow::Snowflake, + unix_epoch_timestamp, +}; #[derive(Serialize, Deserialize)] pub struct Entry { + pub id: usize, pub slug: String, pub edit_code: String, pub salt: String, @@ -19,6 +25,42 @@ pub struct Entry { /// An edit code that can only be used to change the entry's content. #[serde(default)] pub modify_code: String, + #[serde(default)] + pub views: usize, +} + +impl Entry { + /// Create a new [`Entry`]. + pub fn new( + slug: String, + edit_code: String, + content: String, + metadata: String, + last_edit_from: String, + ) -> Self { + let salt = salt(); + let edit_code = hash(edit_code.clone() + &salt); + let created = unix_epoch_timestamp(); + + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + slug, + edit_code, + salt, + created, + edited: created, + content, + metadata, + last_edit_from, + modify_code: String::new(), + views: 0, + } + } + + /// Check the given password against the entry's stored password hash. + pub fn check_password(&self, supplied: String) -> bool { + hash(supplied + &self.salt) == self.edit_code + } } #[derive(Serialize, Deserialize, PartialEq, Eq)] diff --git a/src/routes.rs b/src/routes.rs index 76d86b6..352e769 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -1,7 +1,6 @@ -use std::env::var; - use crate::{ State, + config::Config, model::{Entry, EntryMetadata, QuickFlag, QuickFlags}, }; use axum::{ @@ -16,19 +15,8 @@ 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_\-\.,!]+"; +use tetratto_core::model::{ApiReturn, Error}; +use tetratto_shared::{hash::salt, unix_epoch_timestamp}; pub fn routes() -> Router { Router::new() @@ -44,28 +32,21 @@ pub fn routes() -> Router { .route("/{slug}/edit", get(editor_request)) .route("/{slug}/claim", get(reclaim_request)) // api - .route("/api/v1/util/ip", get(util_ip)) .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 { +fn default_context(config: &Config, build_code: &str) -> Context { let mut ctx = Context::new(); - ctx.insert("name", &var("NAME").unwrap_or("Fluffle".to_string())); - ctx.insert( - "theme_color", - &var("THEME_COLOR").unwrap_or("#a3b3ff".to_string()), - ); - ctx.insert("tetratto", &data.host); - ctx.insert( - "what_page_slug", - &var("WHAT_SLUG").unwrap_or("what".to_string()), - ); + ctx.insert("name", &config.name); + ctx.insert("theme_color", &config.theme_color); + ctx.insert("tetratto", &config.tetratto); + ctx.insert("what_page_slug", &config.what_page_slug); ctx.insert( "tetratto_handler_account_username", - &var("TETRATTO_HANDLER_ACCOUNT_USERNAME").unwrap_or("fluffle".to_string()), + &config.tetratto_handler_account_username, ); ctx.insert("build_code", &build_code); ctx @@ -75,7 +56,7 @@ fn default_context(data: &DataClient, build_code: &str) -> Context { 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); + let mut ctx = default_context(&data.0.0, &build_code); ctx.insert( "error", &Error::GeneralNotFound("page".to_string()).to_string(), @@ -86,7 +67,7 @@ async fn not_found_request(Extension(data): Extension) -> impl IntoRespon 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)) + tera.render("index.lisp", &default_context(&data.0.0, &build_code)) .unwrap(), ) } @@ -99,7 +80,7 @@ async fn view_doc_request( 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); + let mut ctx = default_context(&data.0.0, &build_code); ctx.insert( "error", &Error::GeneralNotFound("entry".to_string()).to_string(), @@ -110,13 +91,13 @@ async fn view_doc_request( let text = match std::fs::read_to_string(&path) { Ok(t) => t, Err(e) => { - let mut ctx = default_context(&data, &build_code); + let mut ctx = default_context(&data.0.0, &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); + let mut ctx = default_context(&data.0.0, &build_code); ctx.insert("text", &text); ctx.insert("file_name", &name); @@ -151,20 +132,10 @@ async fn view_request( format!("Atto-Viewed=true; Path=/{slug}; Max-Age=86400"), ); - let entry = match data - .query(&SimplifiedQuery { - query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug.to_lowercase())), - mode: AppDataSelectMode::One(0), - cache: false, - }) - .await - { - Ok(r) => match r { - AppDataQueryResult::One(r) => serde_json::from_str::(&r.value).unwrap(), - AppDataQueryResult::Many(_) => unreachable!(), - }, + let entry = match data.get_entry_by_slug(&slug).await { + Ok(x) => x, Err(_) => { - let mut ctx = default_context(&data, &build_code); + let mut ctx = default_context(&data.0.0, &build_code); ctx.insert( "error", &Error::GeneralNotFound("entry".to_string()).to_string(), @@ -185,7 +156,7 @@ async fn view_request( }; if let Err(e) = metadata.validate() { - let mut ctx = default_context(&data, &build_code); + let mut ctx = default_context(&data.0.0, &build_code); ctx.insert("error", &e.to_string()); return ( [viewed_header], @@ -197,7 +168,7 @@ async fn view_request( if !metadata.option_view_password.is_empty() && metadata.option_view_password != props.key.clone() { - let mut ctx = default_context(&data, &build_code); + let mut ctx = default_context(&data.0.0, &build_code); ctx.insert("entry", &entry); return ( [viewed_header], @@ -206,57 +177,24 @@ async fn view_request( } // pull views - let views = if !metadata.option_disable_views { - match data - .query(&SimplifiedQuery { - query: AppDataSelectQuery::KeyIs(format!("entries.views('{}')", slug)), - mode: AppDataSelectMode::One(0), - cache: false, - }) - .await - { - Ok(r) => match r { - AppDataQueryResult::One(r) => { - // count view - let views = r.value.parse::().unwrap(); + if jar.get("Atto-Viewed").is_none() { + // the Atto-Viewed cookie tells us if we've already viewed this + // entry recently (at all in the past week) + if let Err(e) = data.incr_entry_views(entry.id).await { + let mut ctx = default_context(&data.0.0, &build_code); + ctx.insert("error", &e.to_string()); - if jar.get("Atto-Viewed").is_none() { - // the Atto-Viewed cookie tells us if we've already viewed this - // entry recently (at all in the past week) - 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 ( - [viewed_header], - 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 ( - [viewed_header], - Html(tera.render("error.lisp", &ctx).unwrap()), - ); - } + return ( + [viewed_header], + Html(tera.render("error.lisp", &ctx).unwrap()), + ); } - } else { - 0 - }; + } // ... - let mut ctx = default_context(&data, &build_code); + let mut ctx = default_context(&data.0.0, &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()); @@ -286,20 +224,10 @@ async fn editor_request( let (ref data, ref tera, ref build_code) = *data.read().await; slug = slug.to_lowercase(); - let entry = match data - .query(&SimplifiedQuery { - query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug.to_lowercase())), - mode: AppDataSelectMode::One(0), - cache: false, - }) - .await - { - Ok(r) => match r { - AppDataQueryResult::One(r) => serde_json::from_str::(&r.value).unwrap(), - AppDataQueryResult::Many(_) => unreachable!(), - }, + let entry = match data.get_entry_by_slug(&slug).await { + Ok(x) => x, Err(_) => { - let mut ctx = default_context(&data, &build_code); + let mut ctx = default_context(&data.0.0, &build_code); ctx.insert( "error", &Error::GeneralNotFound("entry".to_string()).to_string(), @@ -323,13 +251,13 @@ async fn editor_request( } else { false } { - let mut ctx = default_context(&data, &build_code); + let mut ctx = default_context(&data.0.0, &build_code); ctx.insert("entry", &entry); return Html(tera.render("password.lisp", &ctx).unwrap()); } // ... - let mut ctx = default_context(&data, &build_code); + let mut ctx = default_context(&data.0.0, &build_code); ctx.insert("entry", &entry); ctx.insert("password", &props.key); @@ -346,20 +274,10 @@ async fn reclaim_request( let (ref data, ref tera, ref build_code) = *data.read().await; slug = slug.to_lowercase(); - let entry = match data - .query(&SimplifiedQuery { - query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug.to_lowercase())), - mode: AppDataSelectMode::One(0), - cache: false, - }) - .await - { - Ok(r) => match r { - AppDataQueryResult::One(r) => serde_json::from_str::(&r.value).unwrap(), - AppDataQueryResult::Many(_) => unreachable!(), - }, + let entry = match data.get_entry_by_slug(&slug).await { + Ok(x) => x, Err(_) => { - let mut ctx = default_context(&data, &build_code); + let mut ctx = default_context(&data.0.0, &build_code); ctx.insert( "error", &Error::GeneralNotFound("entry".to_string()).to_string(), @@ -377,13 +295,13 @@ async fn reclaim_request( }; if let Err(e) = metadata.validate() { - let mut ctx = default_context(&data, &build_code); + let mut ctx = default_context(&data.0.0, &build_code); ctx.insert("error", &e.to_string()); return Html(tera.render("error.lisp", &ctx).unwrap()); } // ... - let mut ctx = default_context(&data, &build_code); + let mut ctx = default_context(&data.0.0, &build_code); ctx.insert("entry", &entry); ctx.insert("metadata", &metadata); @@ -428,26 +346,10 @@ async fn exists_request( Json(ApiReturn { ok: true, message: "Success".to_string(), - payload: data - .query(&SimplifiedQuery { - query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug)), - mode: AppDataSelectMode::One(0), - cache: false, - }) - .await - .is_ok(), + payload: data.get_entry_by_slug(&slug).await.is_ok(), }) } -async fn util_ip(headers: HeaderMap) -> impl IntoResponse { - headers - .get(var("REAL_IP_HEADER").unwrap_or("CF-Connecting-IP".to_string())) - .unwrap_or(&HeaderValue::from_static("")) - .to_str() - .unwrap_or("") - .to_string() -} - #[derive(Deserialize)] struct CreateEntry { content: String, @@ -463,35 +365,6 @@ fn default_random() -> String { salt() } -fn hash_passwords(metadata: &mut EntryMetadata) -> (bool, String) { - // hash passwords - let do_update_metadata = (!metadata.option_view_password.is_empty() - || !metadata.option_source_password.is_empty()) - && (!metadata.option_view_password.starts_with("h:") - || !metadata.option_source_password.starts_with("h:")); - - if !metadata.option_view_password.is_empty() && !metadata.option_view_password.starts_with("h:") - { - metadata.option_view_password = - format!("h:{}", hash(metadata.option_view_password.clone())); - } - - if !metadata.option_source_password.is_empty() - && !metadata.option_source_password.starts_with("h:") - { - metadata.option_source_password = - format!("h:{}", hash(metadata.option_source_password.clone())); - } - - if do_update_metadata { - if let Ok(x) = toml::to_string_pretty(&metadata) { - return (true, x); - }; - } - - (false, String::new()) -} - /// The time that must be waited between each entry creation. const CREATE_WAIT_TIME: usize = 15000; @@ -506,18 +379,18 @@ async fn create_request( // get real ip let real_ip = headers - .get(var("REAL_IP_HEADER").unwrap_or("CF-Connecting-IP".to_string())) + .get(&data.0.0.real_ip_header) .unwrap_or(&HeaderValue::from_static("")) .to_str() .unwrap_or("") .to_string(); // check for ip ban - if !real_ip.is_empty() { - if data.check_ip(&real_ip.as_str()).await.unwrap_or(false) { - return Err(Json(Error::NotAllowed.into())); - } - } + // if !real_ip.is_empty() { + // if data.check_ip(&real_ip.as_str()).await.unwrap_or(false) { + // return Err(Json(Error::NotAllowed.into())); + // } + // } // check wait time if let Some(cookie) = jar.get("__Secure-Claim-Next") { @@ -534,93 +407,15 @@ async fn create_request( } } - // 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 mut 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())); - } - - let (do_update_metadata, updated) = hash_passwords(&mut metadata); - if do_update_metadata { - req.metadata = updated; - } - - // check for existing - if data - .query(&SimplifiedQuery { - query: AppDataSelectQuery::KeyIs(format!("entries('{}')", req.slug)), - mode: AppDataSelectMode::One(0), - cache: false, - }) - .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, - last_edit_from: real_ip, - modify_code: String::new(), - }) - .unwrap(), - ) - .await - { - return Err(Json(e.into())); - } - - if let Err(e) = data - .insert(format!("entries.views('{}')", req.slug), 0.to_string()) + .create_entry(Entry::new( + req.slug.clone(), + req.edit_code.clone(), + req.content, + req.metadata, + real_ip, + )) .await { return Err(Json(e.into())); @@ -662,220 +457,57 @@ struct EditEntry { async fn edit_request( headers: HeaderMap, Extension(data): Extension, - Path(mut slug): Path, - Json(mut req): Json, + Path(id): Path, + Json(req): Json, ) -> impl IntoResponse { let (ref data, _, _) = *data.read().await; - slug = slug.to_lowercase(); // get real ip let real_ip = headers - .get(var("REAL_IP_HEADER").unwrap_or("CF-Connecting-IP".to_string())) + .get(&data.0.0.real_ip_header) .unwrap_or(&HeaderValue::from_static("")) .to_str() .unwrap_or("") .to_string(); // check for ip ban - if !real_ip.is_empty() { - if data.check_ip(&real_ip.as_str()).await.unwrap_or(false) { - return Json(Error::NotAllowed.into()); - } - } + // if !real_ip.is_empty() { + // if data.check_ip(&real_ip.as_str()).await.unwrap_or(false) { + // return Json(Error::NotAllowed.into()); + // } + // } - // 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 mut 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()), + // handle delete + if req.delete { + return match data.delete_entry(id, req.edit_code).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: None, + }), + Err(e) => return Json(e.into()), }; - - if let Err(e) = metadata.validate() { - return Json(Error::MiscError(e.to_string()).into()); - } - - let (do_update_metadata, updated) = hash_passwords(&mut metadata); - if do_update_metadata { - req.metadata = updated; - } - - // ... - let (id, mut entry) = match data - .query(&SimplifiedQuery { - query: AppDataSelectQuery::KeyIs(format!("entries('{}')", slug)), - mode: AppDataSelectMode::One(0), - cache: false, - }) - .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()), - }; - - let edit_code = hash(req.edit_code.clone() + &entry.salt); - let using_modify_code = edit_code == entry.modify_code; - - // check edit code - let mut using_master = false; - if let Ok(master_pass) = var("MASTER_PASS") { - if req.edit_code == master_pass { - using_master = true; - } - } - - if !using_master - && edit_code - != *if using_modify_code { - &entry.modify_code - } else { - &entry.edit_code - } - { - return Json(Error::NotAllowed.into()); - } - - // ... - if !using_modify_code { - // handle delete - if req.delete { - let views_id = match data - .query(&SimplifiedQuery { - query: AppDataSelectQuery::KeyIs(format!("entries.views('{}')", slug)), - mode: AppDataSelectMode::One(0), - cache: false, - }) - .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(mut new_slug) = req.new_slug { - new_slug = new_slug.to_lowercase(); - - 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), - cache: false, - }) - .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), - cache: false, - }) - .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 modify code - if let Some(new_modify_code) = req.new_modify_code { - entry.modify_code = hash(new_modify_code + &entry.salt); - } - } - - // update - entry.content = req.content; - entry.edited = unix_epoch_timestamp(); - - if !using_modify_code { - entry.metadata = req.metadata; - entry.last_edit_from = real_ip; - } - - 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), - }) + match data + .update_entry( + id, + req.edit_code, + req.new_slug.unwrap_or_default(), + req.content, + req.metadata, + req.new_edit_code.unwrap_or_default(), + req.new_modify_code.unwrap_or_default(), + real_ip, + ) + .await + { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => Json(e.into()), + } }