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 a7eb6bd..76c7ea5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -243,9 +243,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.9.1" +version = "2.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b8e56985ec62d17e9c1001dc89c88ecd7dc08e47eba5ec7c29c7b5eeecde967" +checksum = "6a65b545ab31d687cff52899d4890855fec459eb6afe0da6417b8a18da87aa29" [[package]] name = "block-buffer" @@ -542,9 +542,9 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "emojis" -version = "0.7.0" +version = "0.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0a08afd8e599463c275703532e707c767b8c068a826eea9ca8fceaf3435029df" +checksum = "f52f3d011046a013bdefbc63a5523b06ad0c0f1e227941baf98475496229d634" dependencies = [ "phf 0.12.1", ] @@ -571,7 +571,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "778e2ac28f6c47af28e4907f13ffd1e1ddbd400980a9abd7c8df189bf578a5ad" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -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", @@ -623,7 +624,7 @@ dependencies = [ "tetratto-core", "tetratto-shared", "tokio", - "toml 0.9.4", + "toml 0.9.5", "tower-http", "tracing", "tracing-subscriber", @@ -809,7 +810,7 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf760ebf69878d9fd8f110c89703d90ce35095324d1f1edcb595c63945ee757" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", "ignore", "walkdir", ] @@ -1181,7 +1182,7 @@ version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", "cfg-if", "libc", ] @@ -1494,7 +1495,7 @@ version = "0.10.73" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8505734d46c8ab1e19a1dce3aef597ad87dcb4c37e7188231769bd6bd51cebf8" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", "cfg-if", "foreign-types", "libc", @@ -1831,7 +1832,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", "getopts", "memchr", "pulldown-cmark-escape", @@ -1963,7 +1964,7 @@ version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d04b7d0ee6b4a0207a0a7adb104d23ecb0b47d6beae7152d0fa34b692b29fd6" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", ] [[package]] @@ -2012,9 +2013,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "reqwest" -version = "0.12.22" +version = "0.12.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" +checksum = "d429f34c8092b2d42c7c93cec323bb4adeb7c67698f70839adec842ec10c7ceb" dependencies = [ "base64", "bytes", @@ -2078,11 +2079,11 @@ version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "11181fbabf243db407ef8df94a6ce0b2f9a733bd8be4ad02b4eda9602296cac8" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2166,7 +2167,7 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", "core-foundation", "core-foundation-sys", "libc", @@ -2498,7 +2499,7 @@ version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", "core-foundation", "system-configuration-sys", ] @@ -2523,7 +2524,7 @@ dependencies = [ "getrandom 0.3.3", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -2561,14 +2562,14 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "12.0.2" +version = "15.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a367ac3ced8ff302080e1b4a82a67acd24fa606245c4381a6f77dbaaf6ef4b58" +checksum = "c7aeb9dcc5631ec6188bb9438dc97015c6662b6f59e650e5afa865775f170c9c" dependencies = [ "async-recursion", "base16ct", "base64", - "bitflags 2.9.1", + "bitflags 2.9.2", "emojis", "md-5", "oiseau", @@ -2581,7 +2582,7 @@ dependencies = [ "tetratto-l10n", "tetratto-shared", "tokio", - "toml 0.9.4", + "toml 0.9.5", "totp-rs", ] @@ -2593,7 +2594,7 @@ checksum = "d96f5e41633c757e3519efb47c9b85d00d14322c1961360e126d0ecc0ea79b86" dependencies = [ "pathbufd", "serde", - "toml 0.9.4", + "toml 0.9.5", ] [[package]] @@ -2834,9 +2835,9 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41ae868b5a0f67631c14589f7e250c1ea2c574ee5ba21c6c8dd4b1485705a5a1" +checksum = "75129e1dc5000bfbaa9fee9d1b21f974f9fbad9daec557a521ee6e080825f6e8" dependencies = [ "indexmap", "serde", @@ -2881,9 +2882,9 @@ dependencies = [ [[package]] name = "toml_parser" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30" +checksum = "b551886f449aa90d4fe2bdaa9f4a2577ad2dde302c61ecf262d80b116db95c10" dependencies = [ "winnow", ] @@ -2939,7 +2940,7 @@ version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "adc82fd73de2a9722ac5da747f12383d2bfdb93591ee6c58486e0097890f05f2" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", "bytes", "futures-core", "futures-util", @@ -3395,7 +3396,7 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf221c93e13a30d793f7645a0e7762c55d169dbb0a49671918a2319d289b10bb" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.59.0", ] [[package]] @@ -3637,7 +3638,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" dependencies = [ - "bitflags 2.9.1", + "bitflags 2.9.2", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index 47aa4fd..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" @@ -8,7 +8,7 @@ license = "AGPL-3.0-or-later" homepage = "https://fluffle.cc" [dependencies] -tetratto-core = "12.0.2" +tetratto-core = "15.0.1" tetratto-shared = "12.0.6" tokio = { version = "1.47.1", features = ["macros", "rt-multi-thread"] } pathbufd = "0.1.4" @@ -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/README.md b/README.md index 0e72c79..fd98dfd 100644 --- a/README.md +++ b/README.md @@ -2,27 +2,11 @@ Fluffle is a familiar Markdown pastebin-y site :) -Since Tetratto is used as a backend, you'll obviously need to create an app at . Once you've created the app, scroll down to "Secret key" and roll the key. Copy the key since you'll need it for later. - ## Usage Once you've cloned the repository, cd into the `app` directory and run `cargo run -r`. -Before you run the server, however, you should create a `.env` file to store your API key (that you rolled earlier). In the `.env` file, put the following: - -```ini -API_KEY= -``` - -So if your key was "ABCD123", you would have: - -```ini -API_KEY=ABCD123 -``` - -Once this file is in place, you can safely run the server. You can also optionally add a `PORT` variable in there to change the port number. If you don't change the port, you can find the server at `http://localhost:9119`. - -It's important to note that you're fairly limited on app storage without the Tetratto developer pass. You can manage your billing settings at . +After you start the server the first time, a `fluffle.toml` file will be created in the current directory. You'll need to edit that file to configure your PostgreSQL connection, instance name/theme color, etc. ## Customization @@ -38,7 +22,7 @@ Once you've built the binary, it'll be located at (from the root `fluffle/` dire All templates are compiled with [nanoneo](https://trisua.com/t/nanoneo), so it's recommended that you familiarize yourself with that syntax. -You can set a master password to be able to freely edit all entries by using the `MASTER_PASS` environment variable. +You can set a master password to be able to freely edit all entries by using the `master_pass` configuration key. A password is automatically generated on first start as well. Please note that this value is stored in plain text. ## Attribution diff --git a/app/public/style.css b/app/public/style.css index 52323f1..5f7e506 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 { @@ -809,7 +817,7 @@ dialog { border: 0; } -dialog.inner { +dialog .inner { display: flex; flex-direction: column; gap: var(--pad-2); @@ -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/claim.lisp b/app/templates_src/claim.lisp index bff9a83..b81816a 100644 --- a/app/templates_src/claim.lisp +++ b/app/templates_src/claim.lisp @@ -6,7 +6,7 @@ (div ("class" "card container") (h1 (text "{{ entry.slug }}")) - (p (text "Custom slug reclaims are handled through ") (b (text "{{ tetratto }}")) (text ". You'll need to have an account there to submit a claim request.")) + (p (text "Custom slug reclaims are handled through ") (b (text "{{ config.service_hosts.tetratto }}")) (text ". You'll need to have an account there to submit a claim request.")) (p (text "Please note that you are unlikely to receive a response unless your claim is accepted. Please do not submit additional requests for the same slug.")) (text "{% if metadata.tetratto_owner_username -%}") @@ -24,13 +24,13 @@ (text "{% if metadata.tetratto_owner_username -%}") ; contact owner button (a - ("href" "{{ tetratto }}/mail/compose?receivers={{ tetratto_owner_username }}&subject=Reclaim%20for%20%22{{ entry.slug }}%22") + ("href" "{{ config.service_hosts.tetratto }}/mail/compose?receivers={{ metadata.tetratto_owner_username }}&subject=Reclaim%20for%20%22{{ entry.slug }}%22") ("class" "button surface no_fill") (text "{{ icon \"external-link\" }} Contact owner")) (text "{%- endif %}") (a - ("href" "{{ tetratto }}/mail/compose?receivers={{ tetratto_handler_account_username }}&subject=Reclaim%20for%20%22{{ entry.slug }}%22") + ("href" "{{ config.service_hosts.tetratto }}/mail/compose?receivers={{ tetratto_handler_account_username }}&subject=Reclaim%20for%20%22{{ entry.slug }}%22") ("class" "button surface no_fill") (text "{{ icon \"external-link\" }} Submit request")) (text "{% else %}") 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..7e985b8 100644 --- a/app/templates_src/root.lisp +++ b/app/templates_src/root.lisp @@ -6,7 +6,7 @@ (meta ("name" "viewport") ("content" "width=device-width, initial-scale=1.0")) (meta ("http-equiv" "X-UA-Compatible") ("content" "ie=edge")) - (link ("rel" "stylesheet") ("href" "{{ tetratto }}/css/utility.css?v={{ build_code }}")) + (link ("rel" "stylesheet") ("href" "https://repodelivery.tetratto.com/tetratto/crates/app/src/public/css/utility.css")) (link ("rel" "stylesheet") ("href" "/public/style.css?v={{ build_code }}")) (style (text ":root { --color-primary: {{ theme_color }}; }")) @@ -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..6c4816c 100644 --- a/app/templates_src/view.lisp +++ b/app/templates_src/view.lisp @@ -46,16 +46,18 @@ (text "Owner:") (a ("class" "flex items_center gap_2") - ("href" "{{ tetratto }}/@{{ metadata.tetratto_owner_username }}") + ("href" "{{ config.service_hosts.tetratto }}/@{{ metadata.tetratto_owner_username }}") + (text "{% if metadata.tetratto_owner_id -%}") (img ("class" "avatar") - ("src" "{{ tetratto }}/api/v1/auth/user/{{ metadata.tetratto_owner_username }}/avatar?selector_type=username")) + ("src" "{{ config.service_hosts.buckets }}/avatars/{{ metadata.tetratto_owner_id }}")) + (text "{%- endif %}") (text "{{ metadata.tetratto_owner_username }}"))) (text "{%- endif %}") ; 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..8f57438 --- /dev/null +++ b/src/config.rs @@ -0,0 +1,115 @@ +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 ServiceHostsConfig { + pub tetratto: String, + pub buckets: String, +} + +#[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 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 host URL of required services. + #[serde(default = "default_service_hosts")] + pub service_hosts: ServiceHostsConfig, + /// 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_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() +} + +fn default_service_hosts() -> ServiceHostsConfig { + ServiceHostsConfig { + tetratto: "https://tetratto.com".to_string(), + buckets: "https://assetdelivery.tetratto.com".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(), + 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(), + service_hosts: default_service_hosts(), + 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..641eb40 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)] @@ -402,6 +444,9 @@ pub struct EntryMetadata { #[serde(default, alias = "TETRATTO_OWNER_USERNAME")] #[validate(max_length = 32)] pub tetratto_owner_username: String, + /// The ID of the owner of this entry on the Tetratto instance. + #[serde(default, alias = "TETRATTO_OWNER_ID")] + pub tetratto_owner_id: usize, } macro_rules! metadata_css { @@ -558,6 +603,40 @@ impl EntryMetadata { input.replace("}", "").replace(";", "").replace("/*", "") } + /// Split the given input string by the given character while skipping over + /// CSS colors. + pub fn css_color_split(c: char, input: &str) -> Vec { + let mut out = Vec::new(); + let mut buffer = String::new(); + let mut in_function = false; + + for x in input.chars() { + if x == c && !in_function { + out.push(buffer.clone()); + buffer.clear(); + continue; + } + + match x { + '(' => { + in_function = true; + buffer.push(x); + } + ')' => { + in_function = false; + buffer.push(x); + } + _ => buffer.push(x), + } + } + + if !buffer.is_empty() { + out.push(buffer); + } + + out + } + pub fn css(&self) -> String { let mut output = "