diff --git a/Cargo.lock b/Cargo.lock index b03e9ca..adc1cbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -955,6 +955,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cba6ae63eb948698e300f645f87c70f76630d505f23b8907cf1e193ee85048c1" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.1.16" @@ -1743,15 +1752,6 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" -[[package]] -name = "markdown" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5cab8f2cadc416a82d2e783a1946388b31654d391d1c7d92cc1f03e295b1deb" -dependencies = [ - "unicode-id", -] - [[package]] name = "markup5ever" version = "0.35.0" @@ -2355,6 +2355,25 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "pulldown-cmark" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e8bbe1a966bd2f362681a44f6edce3c2310ac21e4d5067a6e7ec396297a6ea0" +dependencies = [ + "bitflags 2.9.1", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "qoi" version = "0.4.1" @@ -3301,7 +3320,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "12.0.0" +version = "12.0.2" dependencies = [ "async-recursion", "base16ct", @@ -3334,13 +3353,14 @@ dependencies = [ [[package]] name = "tetratto-shared" -version = "12.0.0" +version = "12.0.6" dependencies = [ "ammonia", "chrono", "hex_fmt", - "markdown", + "pulldown-cmark", "rand 0.9.1", + "regex", "serde", "sha2", "snowflaked", @@ -3871,12 +3891,6 @@ version = "0.3.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5c1cb5db39152898a79168971543b1cb5020dff7fe43c8dc468b0885f5e29df5" -[[package]] -name = "unicode-id" -version = "0.3.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10103c57044730945224467c09f71a4db0071c123a0648cc3e818913bde6b561" - [[package]] name = "unicode-ident" version = "1.0.18" @@ -3898,6 +3912,12 @@ version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e70f2a8b45122e719eb623c01822704c4e0907e7e426a05927e1a1cfff5b75d0" +[[package]] +name = "unicode-width" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4a1a07cc7db3810833284e8d372ccdc6da29741639ecc70c9ec107df0fa6154c" + [[package]] name = "untrusted" version = "0.9.0" diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 1504ba5..82cf197 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -4,15 +4,11 @@ use nanoneo::{ }; use pathbufd::PathBufD; use regex::Regex; -use std::{ - collections::HashMap, - fs::{exists, read_to_string, write}, - sync::LazyLock, - time::SystemTime, -}; +use std::{collections::HashMap, fs::read_to_string, sync::LazyLock, time::SystemTime}; use tera::Context; use tetratto_core::{ config::Config, + html::{pull_icons, ICONS}, model::{ auth::{DefaultTimelineChoice, User}, permissions::{FinePermission, SecondaryPermission}, @@ -157,38 +153,6 @@ pub const TETRATTO_BUNNY: &[u8] = include_bytes!("./public/images/tetratto_bunny pub(crate) static HTML_FOOTER: LazyLock> = LazyLock::new(|| RwLock::new(String::new())); -/// A container for all loaded icons. -pub(crate) static ICONS: LazyLock>> = - LazyLock::new(|| RwLock::new(HashMap::new())); - -/// Pull an icon given its name and insert it into [`ICONS`]. -pub(crate) async fn pull_icon(icon: &str, icons_dir: &str) { - let writer = &mut ICONS.write().await; - - let icon_url = format!( - "https://raw.githubusercontent.com/lucide-icons/lucide/refs/heads/main/icons/{icon}.svg" - ); - - let file_path = PathBufD::current().extend(&[icons_dir, &format!("{icon}.svg")]); - - if exists(&file_path).unwrap() { - writer.insert(icon.to_string(), read_to_string(&file_path).unwrap()); - return; - } - - println!("download icon: {icon}"); - let svg = reqwest::get(icon_url) - .await - .unwrap() - .text() - .await - .unwrap() - .replace("\n", ""); - - write(&file_path, &svg).unwrap(); - writer.insert(icon.to_string(), svg); -} - macro_rules! vendor_icon { ($name:literal, $icon:ident, $icons_dir:expr) => {{ let writer = &mut ICONS.write().await; @@ -261,56 +225,8 @@ pub(crate) async fn replace_in_html( input = input.replace(cap.get(0).unwrap().as_str(), &replace_with); } - // icon (with class) - let icon_with_class = - Regex::new("(\\{\\{)\\s*(icon)\\s*(.*?)\\s*c\\((.*?)\\)\\s*(\\}\\})").unwrap(); - - for cap in icon_with_class.captures_iter(&input.clone()) { - let cap_str = &cap.get(3).unwrap().as_str().replace("\"", ""); - let icon = &(if cap_str.contains(" }}") { - cap_str.split(" }}").next().unwrap().to_string() - } else { - cap_str.to_string() - }); - - pull_icon(icon, &config.dirs.icons).await; - - let reader = ICONS.read().await; - let icon_text = reader.get(icon).unwrap().replace( - "", &format!("{reader}"), 1); diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 3246755..bd692f3 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -258,6 +258,7 @@ version = "1.0.0" "developer:label.change_homepage" = "Change homepage" "developer:label.change_redirect" = "Change redirect URL" "developer:label.change_quota_status" = "Change quota status" +"developer:label.change_storage_capacity" = "Change storage capacity" "developer:label.manage_scopes" = "Manage scopes" "developer:label.scopes" = "Scopes" "developer:label.guides_and_help" = "Guides & help" diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index bfac36f..b0098b7 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -36,12 +36,13 @@ pub(crate) type InnerState = (DataManager, Tera, Client, Option); pub(crate) type State = Arc>; fn render_markdown(value: &Value, _: &HashMap) -> tera::Result { - Ok( - tetratto_shared::markdown::render_markdown(&CustomEmoji::replace(value.as_str().unwrap())) - .replace("\\@", "@") - .replace("%5C@", "@") - .into(), + Ok(tetratto_shared::markdown::render_markdown( + &CustomEmoji::replace(value.as_str().unwrap()), + true, ) + .replace("\\@", "@") + .replace("%5C@", "@") + .into()) } fn render_emojis(value: &Value, _: &HashMap) -> tera::Result { diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index d3c1a7f..8475223 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -2415,7 +2415,7 @@ (ul ("style" "margin-bottom: var(--pad-4)") (li - (text "Increased app storage limit (500 KB->5 MB)")) + (text "Increased app storage limit (500 KB->25 MB)")) (li (text "Ability to create forges")) (li diff --git a/crates/app/src/public/html/developer/app.lisp b/crates/app/src/public/html/developer/app.lisp index b1661e8..d19fb10 100644 --- a/crates/app/src/public/html/developer/app.lisp +++ b/crates/app/src/public/html/developer/app.lisp @@ -44,6 +44,28 @@ ("value" "Unlimited") ("selected" "{% if app.quota_status == 'Unlimited' -%}true{% else %}false{%- endif %}") (text "Unlimited"))))) + (div + ("class" "card-nest") + (div + ("class" "card small flex items-center gap-2") + (icon (text "database-zap")) + (b (str (text "developer:label.change_storage_capacity")))) + (div + ("class" "card") + (select + ("onchange" "save_storage_capacity(event)") + (option + ("value" "Tier1") + ("selected" "{% if app.storage_capacity == 'Tier1' -%}true{% else %}false{%- endif %}") + (text "Tier 1 (25 MB)")) + (option + ("value" "Tier2") + ("selected" "{% if app.storage_capacity == 'Tier2' -%}true{% else %}false{%- endif %}") + (text "Tier 2 (50 MB)")) + (option + ("value" "Tier3") + ("selected" "{% if app.storage_capacity == 'Tier3' -%}true{% else %}false{%- endif %}") + (text "Tier 3 (100 MB)"))))) (text "{%- endif %}") (div ("class" "card-nest") @@ -232,6 +254,26 @@ }); }; + globalThis.save_storage_capacity = (event) => { + const selected = event.target.selectedOptions[0]; + fetch(\"/api/v1/apps/{{ app.id }}/storage_capacity\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + storage_capacity: selected.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + globalThis.change_title = async (e) => { e.preventDefault(); diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index 2b68c90..5a84aac 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -406,6 +406,7 @@ MANAGE_SERVICES: 1 << 3, MANAGE_PRODUCTS: 1 << 4, DEVELOPER_PASS: 1 << 5, + MANAGE_LETTERS: 1 << 6, }, \"secondary_role\", \"add_permission_to_secondary_role\", diff --git a/crates/app/src/public/html/profile/base.lisp b/crates/app/src/public/html/profile/base.lisp index 7e4d6fb..e10dec9 100644 --- a/crates/app/src/public/html/profile/base.lisp +++ b/crates/app/src/public/html/profile/base.lisp @@ -107,6 +107,7 @@ (p (text "{{ profile.settings.status }}")) (text "{%- endif %}") + (text "{% if not profile.settings.hide_social_follows or (user and user.id == profile.id) -%}") (div ("class" "w-full flex") (a @@ -123,6 +124,7 @@ (text "{{ profile.following_count }}")) (span (text "{{ text \"auth:label.following\" }}")))) + (text "{%- endif %}") (text "{% if is_following_you -%}") (b ("class" "notification chip w-content flex items-center gap-2") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 59b64d0..64d3a30 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -1889,6 +1889,14 @@ \"{{ profile.settings.hide_from_social_lists }}\", \"checkbox\", ], + [ + [ + \"hide_social_follows\", + \"Hide followers/following links on my profile\", + ], + \"{{ profile.settings.hide_social_follows }}\", + \"checkbox\", + ], [[], \"Questions\", \"title\"], [ [ diff --git a/crates/app/src/public/js/app_sdk.js b/crates/app/src/public/js/app_sdk.js index cd21e6a..7a6c834 100644 --- a/crates/app/src/public/js/app_sdk.js +++ b/crates/app/src/public/js/app_sdk.js @@ -72,6 +72,25 @@ export default function tetratto({ ); } + async function check_ip(ip) { + if (!api_key) { + throw Error("No API key provided."); + } + + return api_promise( + json_parse( + await ( + await fetch(`${host}/api/v1/bans/${ip}`, { + method: "GET", + headers: { + "Atto-Secret-Key": api_key, + }, + }) + ).text(), + ), + ); + } + async function query(body) { if (!api_key) { throw Error("No API key provided."); @@ -285,6 +304,7 @@ export default function tetratto({ api_key, // app data app, + check_ip, query, insert, update, diff --git a/crates/app/src/routes/api/v1/app_data.rs b/crates/app/src/routes/api/v1/app_data.rs index d5e8c3f..b5fa212 100644 --- a/crates/app/src/routes/api/v1/app_data.rs +++ b/crates/app/src/routes/api/v1/app_data.rs @@ -72,7 +72,7 @@ pub async fn create_request( // check size let new_size = app.data_used + req.value.len(); - if new_size > AppData::user_limit(&owner) { + if new_size > AppData::user_limit(&owner, &app) { return Json(Error::AppHitStorageLimit.into()); } @@ -155,12 +155,19 @@ pub async fn update_value_request( let size_without = app.data_used - app_data.value.len(); let new_size = size_without + req.value.len(); - if new_size > AppData::user_limit(&owner) { + if new_size > AppData::user_limit(&owner, &app) { return Json(Error::AppHitStorageLimit.into()); } // ... - if let Err(e) = data.add_app_data_used(app.id, req.value.len() as i32).await { + // we only need to add the delta size (the next size - the old size) + if let Err(e) = data + .add_app_data_used( + app.id, + (req.value.len() as i32) - (app_data.value.len() as i32), + ) + .await + { return Json(e.into()); } diff --git a/crates/app/src/routes/api/v1/apps.rs b/crates/app/src/routes/api/v1/apps.rs index 3b5cd60..eac16ba 100644 --- a/crates/app/src/routes/api/v1/apps.rs +++ b/crates/app/src/routes/api/v1/apps.rs @@ -15,7 +15,7 @@ use tetratto_core::model::{ ApiReturn, Error, }; use tetratto_shared::{hash::random_id, unix_epoch_timestamp}; -use super::CreateApp; +use super::{CreateApp, UpdateAppStorageCapacity}; pub async fn create_request( jar: CookieJar, @@ -138,6 +138,35 @@ pub async fn update_quota_status_request( } } +pub async fn update_storage_capacity_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if !user.permissions.check(FinePermission::MANAGE_APPS) { + return Json(Error::NotAllowed.into()); + } + + match data + .update_app_storage_capacity(id, req.storage_capacity) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "App updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + pub async fn update_scopes_request( jar: CookieJar, Extension(data): Extension, diff --git a/crates/app/src/routes/api/v1/auth/ipbans.rs b/crates/app/src/routes/api/v1/auth/ipbans.rs index 7163091..a8eb856 100644 --- a/crates/app/src/routes/api/v1/auth/ipbans.rs +++ b/crates/app/src/routes/api/v1/auth/ipbans.rs @@ -1,12 +1,34 @@ use crate::{ - State, get_user_from_token, + get_app_from_key, get_user_from_token, model::{ApiReturn, Error}, routes::api::v1::CreateIpBan, + State, }; -use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use axum::{extract::Path, http::HeaderMap, response::IntoResponse, Extension, Json}; use crate::cookie::CookieJar; use tetratto_core::model::{addr::RemoteAddr, auth::IpBan, permissions::FinePermission}; +/// Check if the given IP is banned. +pub async fn check_request( + headers: HeaderMap, + Path(ip): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + if get_app_from_key!(data, headers).is_none() { + return Json(Error::NotAllowed.into()); + } + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: data + .get_ipban_by_addr(&RemoteAddr::from(ip.as_str())) + .await + .is_ok(), + }) +} + /// Create a new IP ban. pub async fn create_request( jar: CookieJar, diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 6b56e9b..8a5d95a 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -20,9 +20,9 @@ use axum::{ routing::{any, delete, get, post, put}, Router, }; -use serde::{Deserialize}; +use serde::Deserialize; use tetratto_core::model::{ - apps::{AppDataSelectMode, AppDataSelectQuery, AppQuota}, + apps::{AppDataSelectMode, AppDataSelectQuery, AppQuota, DeveloperPassStorageQuota}, auth::AchievementName, communities::{ CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess, @@ -432,6 +432,10 @@ pub fn routes() -> Router { "/apps/{id}/quota_status", post(apps::update_quota_status_request), ) + .route( + "/apps/{id}/storage_capacity", + post(apps::update_storage_capacity_request), + ) .route("/apps/{id}/scopes", post(apps::update_scopes_request)) .route("/apps/{id}/grant", post(apps::grant_request)) .route("/apps/{id}/roll", post(apps::roll_api_key_request)) @@ -491,6 +495,7 @@ pub fn routes() -> Router { post(communities::communities::update_membership_role), ) // ipbans + .route("/bans/{ip}", get(auth::ipbans::check_request)) .route("/bans/{ip}", post(auth::ipbans::create_request)) .route("/bans/{ip}", delete(auth::ipbans::delete_request)) // reports @@ -1031,6 +1036,11 @@ pub struct UpdateAppQuotaStatus { pub quota_status: AppQuota, } +#[derive(Deserialize)] +pub struct UpdateAppStorageCapacity { + pub storage_capacity: DeveloperPassStorageQuota, +} + #[derive(Deserialize)] pub struct UpdateAppScopes { pub scopes: Vec, diff --git a/crates/app/src/routes/api/v1/notes.rs b/crates/app/src/routes/api/v1/notes.rs index bba335e..979dbf7 100644 --- a/crates/app/src/routes/api/v1/notes.rs +++ b/crates/app/src/routes/api/v1/notes.rs @@ -267,7 +267,7 @@ pub async fn delete_by_dir_request( } pub async fn render_markdown_request(Json(req): Json) -> impl IntoResponse { - tetratto_shared::markdown::render_markdown(&CustomEmoji::replace(&req.content)) + tetratto_shared::markdown::render_markdown(&CustomEmoji::replace(&req.content), true) .replace("\\@", "@") .replace("%5C@", "@") } diff --git a/crates/app/src/routes/pages/developer.rs b/crates/app/src/routes/pages/developer.rs index 0d421f7..a9e5f92 100644 --- a/crates/app/src/routes/pages/developer.rs +++ b/crates/app/src/routes/pages/developer.rs @@ -62,7 +62,7 @@ pub async fn app_request( )); } - let data_limit = AppData::user_limit(&user); + let data_limit = AppData::user_limit(&user, &app); let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index 15a3ee8..3f6274b 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -731,6 +731,21 @@ pub async fn following_request( check_user_blocked_or_private!(user, other_user, data, jar); + // check hide_social_follows + if other_user.settings.hide_social_follows { + if let Some(ref ua) = user { + if ua.id != other_user.id { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } else { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } + // fetch data let list = match data .0 @@ -826,6 +841,21 @@ pub async fn followers_request( check_user_blocked_or_private!(user, other_user, data, jar); + // check hide_social_follows + if other_user.settings.hide_social_follows { + if let Some(ref ua) = user { + if ua.id != other_user.id { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } else { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } + // fetch data let list = match data .0 diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 98a0947..bd7ac03 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "tetratto-core" description = "The core behind Tetratto" -version = "12.0.0" +version = "12.0.2" edition = "2024" authors.workspace = true repository.workspace = true @@ -18,7 +18,7 @@ default = ["database", "types", "sdk"] pathbufd = "0.1.4" serde = { version = "1.0.219", features = ["derive"] } toml = "0.9.2" -tetratto-shared = { version = "12.0.0", path = "../shared" } +tetratto-shared = { version = "12.0.6", path = "../shared" } tetratto-l10n = { version = "12.0.0", path = "../l10n" } serde_json = "1.0.141" totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"], optional = true } @@ -35,6 +35,4 @@ oiseau = { version = "0.1.2", default-features = false, features = [ "redis", ], optional = true } paste = { version = "1.0.15", optional = true } - -[dev-dependencies] tokio = { version = "1.46.1", features = ["macros", "rt-multi-thread"] } diff --git a/crates/core/src/database/app_data.rs b/crates/core/src/database/app_data.rs index d6225fc..9aeafc1 100644 --- a/crates/core/src/database/app_data.rs +++ b/crates/core/src/database/app_data.rs @@ -5,7 +5,7 @@ use crate::{auto_method, DataManager}; use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; pub const FREE_DATA_LIMIT: usize = 512_000; -pub const PASS_DATA_LIMIT: usize = 5_242_880; +pub const PASS_DATA_LIMIT: usize = 26_214_400; impl DataManager { /// Get a [`AppData`] from an SQL row. @@ -117,13 +117,13 @@ impl DataManager { let app = self.get_app_by_id(data.app).await?; // check values - if data.key.len() < 2 { + if data.key.len() < 1 { return Err(Error::DataTooShort("key".to_string())); - } else if data.key.len() > 32 { + } else if data.key.len() > 128 { return Err(Error::DataTooLong("key".to_string())); } - if data.value.len() < 2 { + if data.value.len() < 1 { return Err(Error::DataTooShort("value".to_string())); } else if data.value.len() > Self::MAXIMUM_DATA_SIZE { return Err(Error::DataTooLong("value".to_string())); diff --git a/crates/core/src/database/apps.rs b/crates/core/src/database/apps.rs index b605cb6..72334a8 100644 --- a/crates/core/src/database/apps.rs +++ b/crates/core/src/database/apps.rs @@ -1,6 +1,6 @@ use oiseau::cache::Cache; use crate::model::{ - apps::{AppQuota, ThirdPartyApp}, + apps::{AppQuota, ThirdPartyApp, DeveloperPassStorageQuota}, auth::User, oauth::AppScope, permissions::{FinePermission, SecondaryPermission}, @@ -25,6 +25,7 @@ impl DataManager { scopes: serde_json::from_str(&get!(x->9(String))).unwrap(), api_key: get!(x->10(String)), data_used: get!(x->11(i32)) as usize, + storage_capacity: serde_json::from_str(&get!(x->12(String))).unwrap(), } } @@ -95,7 +96,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO apps VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", + "INSERT INTO apps VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)", params![ &(data.id as i64), &(data.created as i64), @@ -108,7 +109,8 @@ impl DataManager { &(data.grants as i32), &serde_json::to_string(&data.scopes).unwrap(), &data.api_key, - &(data.data_used as i32) + &(data.data_used as i32), + &serde_json::to_string(&data.storage_capacity).unwrap(), ] ); @@ -167,6 +169,7 @@ impl DataManager { auto_method!(update_app_quota_status(AppQuota)@get_app_by_id -> "UPDATE apps SET quota_status = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_app); auto_method!(update_app_scopes(Vec)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET scopes = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_app); auto_method!(update_app_api_key(&str)@get_app_by_id -> "UPDATE apps SET api_key = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_app); + auto_method!(update_app_storage_capacity(DeveloperPassStorageQuota)@get_app_by_id -> "UPDATE apps SET storage_capacity = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_app); auto_method!(update_app_data_used(i32)@get_app_by_id -> "UPDATE apps SET data_used = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_app); auto_method!(add_app_data_used(i32)@get_app_by_id -> "UPDATE apps SET data_used = data_used + $1 WHERE id = $2" --cache-key-tmpl=cache_clear_app); diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 3bd8678..4ced643 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -100,14 +100,21 @@ impl DataManager { tokens: serde_json::from_str(&get!(x->6(String)).to_string()).unwrap(), permissions: FinePermission::from_bits(get!(x->7(i32)) as u32).unwrap(), is_verified: get!(x->8(i32)) as i8 == 1, - notification_count: get!(x->9(i32)) as usize, + notification_count: { + let x = get!(x->9(i32)) as usize; + // we're a little too close to the maximum count, clearly something's gone wrong + if x > usize::MAX - 1000 { 0 } else { x } + }, follower_count: get!(x->10(i32)) as usize, following_count: get!(x->11(i32)) as usize, last_seen: get!(x->12(i64)) as usize, totp: get!(x->13(String)), recovery_codes: serde_json::from_str(&get!(x->14(String)).to_string()).unwrap(), post_count: get!(x->15(i32)) as usize, - request_count: get!(x->16(i32)) as usize, + request_count: { + let x = get!(x->16(i32)) as usize; + if x > usize::MAX - 1000 { 0 } else { x } + }, connections: serde_json::from_str(&get!(x->17(String)).to_string()).unwrap(), stripe_id: get!(x->18(String)), grants: serde_json::from_str(&get!(x->19(String)).to_string()).unwrap(), diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index d37c330..5e10783 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -44,6 +44,7 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap(); execute!(&conn, common::CREATE_TABLE_PRODUCTS).unwrap(); execute!(&conn, common::CREATE_TABLE_APP_DATA).unwrap(); + execute!(&conn, common::CREATE_TABLE_LETTERS).unwrap(); for x in common::VERSION_MIGRATIONS.split(";") { execute!(&conn, x).unwrap(); diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index d2239a6..bccbfb9 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -32,3 +32,4 @@ pub const CREATE_TABLE_DOMAINS: &str = include_str!("./sql/create_domains.sql"); pub const CREATE_TABLE_SERVICES: &str = include_str!("./sql/create_services.sql"); pub const CREATE_TABLE_PRODUCTS: &str = include_str!("./sql/create_products.sql"); pub const CREATE_TABLE_APP_DATA: &str = include_str!("./sql/create_app_data.sql"); +pub const CREATE_TABLE_LETTERS: &str = include_str!("./sql/create_letters.sql"); diff --git a/crates/core/src/database/drivers/sql/create_apps.sql b/crates/core/src/database/drivers/sql/create_apps.sql index d01ed41..70f2b8d 100644 --- a/crates/core/src/database/drivers/sql/create_apps.sql +++ b/crates/core/src/database/drivers/sql/create_apps.sql @@ -9,5 +9,6 @@ CREATE TABLE IF NOT EXISTS apps ( banned INT NOT NULL, grants INT NOT NULL, scopes TEXT NOT NULL, - data_used INT NOT NULL CHECK (data_used >= 0) + data_used INT NOT NULL CHECK (data_used >= 0), + storage_capacity TEXT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/create_letters.sql b/crates/core/src/database/drivers/sql/create_letters.sql new file mode 100644 index 0000000..f3100eb --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_letters.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS letters ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + receivers TEXT NOT NULL, + subject TEXT NOT NULL, + content TEXT NOT NULL, + read_by TEXT NOT NULL +) diff --git a/crates/core/src/database/drivers/sql/version_migrations.sql b/crates/core/src/database/drivers/sql/version_migrations.sql index c0c863a..c101e7d 100644 --- a/crates/core/src/database/drivers/sql/version_migrations.sql +++ b/crates/core/src/database/drivers/sql/version_migrations.sql @@ -5,3 +5,11 @@ ADD COLUMN IF NOT EXISTS channel_mutes TEXT DEFAULT '[]'; -- users is_deactivated ALTER TABLE users ADD COLUMN IF NOT EXISTS is_deactivated INT DEFAULT 0; + +-- apps storage_capacity +ALTER TABLE apps +ADD COLUMN IF NOT EXISTS storage_capacity TEXT DEFAULT '"Tier1"'; + +-- letters replying_to +ALTER TABLE letters +ADD COLUMN IF NOT EXISTS replying_to TEXT DEFAULT 0; diff --git a/crates/core/src/database/letters.rs b/crates/core/src/database/letters.rs new file mode 100644 index 0000000..fe9bbac --- /dev/null +++ b/crates/core/src/database/letters.rs @@ -0,0 +1,170 @@ +use crate::model::{auth::User, mail::Letter, permissions::SecondaryPermission, Error, Result}; +use crate::{auto_method, DataManager}; +use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow}; + +impl DataManager { + /// Get a [`Letter`] from an SQL row. + pub(crate) fn get_letter_from_row(x: &PostgresRow) -> Letter { + Letter { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + receivers: serde_json::from_str(&get!(x->3(String))).unwrap(), + subject: get!(x->4(String)), + content: get!(x->5(String)), + read_by: serde_json::from_str(&get!(x->6(String))).unwrap(), + replying_to: get!(x->7(i32)) as usize, + } + } + + auto_method!(get_letter_by_id(usize as i64)@get_letter_from_row -> "SELECT * FROM letters WHERE id = $1" --name="letter" --returns=Letter --cache-key-tmpl="atto.letter:{}"); + + /// Get all letters by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch letters for + pub async fn get_letters_by_user(&self, id: usize) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM letters WHERE owner = $1 ORDER BY created DESC", + &[&(id as i64)], + |x| { Self::get_letter_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("letter".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get all letters by user (where user is a receiver). + /// + /// # Arguments + /// * `id` - the ID of the user to fetch letters for + pub async fn get_received_letters_by_user(&self, id: usize) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM letters WHERE receivers LIKE $1 ORDER BY created DESC", + &[&format!("%\"{id}\"%")], + |x| { Self::get_letter_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("letter".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get all letters which are replying to the given letter. + /// + /// # Arguments + /// * `id` - the ID of the letter to fetch letters for + pub async fn get_letters_by_replying_to(&self, id: usize) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM letters WHERE replying_to = $1 ORDER BY created DESC", + &[&(id as i64)], + |x| { Self::get_letter_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("letter".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new letter in the database. + /// + /// # Arguments + /// * `data` - a mock [`Letter`] object to insert + pub async fn create_letter(&self, data: Letter) -> Result { + // check values + if data.subject.len() < 2 { + return Err(Error::DataTooShort("subject".to_string())); + } else if data.subject.len() > 256 { + return Err(Error::DataTooLong("subject".to_string())); + } + + if data.content.len() < 2 { + return Err(Error::DataTooShort("content".to_string())); + } else if data.content.len() > 16384 { + return Err(Error::DataTooLong("content".to_string())); + } + + // ... + 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 letters VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &serde_json::to_string(&data.receivers).unwrap(), + &data.subject, + &data.content, + &serde_json::to_string(&data.read_by).unwrap(), + &(data.replying_to as i64) + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_letter(&self, id: usize, user: &User) -> Result<()> { + let letter = self.get_letter_by_id(id).await?; + + // check user permission + if user.id != letter.owner + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_LETTERS) + { + 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 letters WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... + self.0.1.remove(format!("atto.letter:{}", id)).await; + Ok(()) + } + + auto_method!(update_letter_read_by(Vec) -> "UPDATE letters SET read_by = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.letter:{}"); +} diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 80b77a1..218bcd6 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -14,6 +14,7 @@ mod invite_codes; mod ipbans; mod ipblocks; mod journals; +mod letters; mod memberships; mod message_reactions; mod messages; diff --git a/crates/core/src/database/questions.rs b/crates/core/src/database/questions.rs index 7722250..3703d4f 100644 --- a/crates/core/src/database/questions.rs +++ b/crates/core/src/database/questions.rs @@ -408,6 +408,10 @@ impl DataManager { // check muted phrases for phrase in receiver.settings.muted { + if phrase.is_empty() { + continue; + } + if data.content.contains(&phrase) { // act like the question was created so theyre less likely to try and send it again or bypass return Ok(0); diff --git a/crates/core/src/html.rs b/crates/core/src/html.rs new file mode 100644 index 0000000..73c42e1 --- /dev/null +++ b/crates/core/src/html.rs @@ -0,0 +1,97 @@ +use std::{ + collections::HashMap, + fs::{exists, read_to_string, write}, + sync::LazyLock, +}; +use tokio::sync::RwLock; + +use pathbufd::PathBufD; + +/// A container for all loaded icons. +pub static ICONS: LazyLock>> = + LazyLock::new(|| RwLock::new(HashMap::new())); + +/// Pull an icon given its name and insert it into [`ICONS`]. +pub async fn pull_icon(icon: &str, icons_dir: &str) { + let writer = &mut ICONS.write().await; + + let icon_url = format!( + "https://raw.githubusercontent.com/lucide-icons/lucide/refs/heads/main/icons/{icon}.svg" + ); + + let file_path = PathBufD::current().extend(&[icons_dir, &format!("{icon}.svg")]); + + if exists(&file_path).unwrap_or(false) { + writer.insert(icon.to_string(), read_to_string(&file_path).unwrap()); + return; + } + + println!("download icon: {icon}"); + let svg = reqwest::get(icon_url) + .await + .unwrap() + .text() + .await + .unwrap() + .replace("\n", ""); + + write(&file_path, &svg).unwrap(); + writer.insert(icon.to_string(), svg); +} + +/// Read a string and pull all icons found within it. +pub async fn pull_icons(mut input: String, icon_dir: &str) -> String { + // icon (with class) + let icon_with_class = + regex::Regex::new("(\\{\\{)\\s*(icon)\\s*(.*?)\\s*c\\((.*?)\\)\\s*(\\}\\})").unwrap(); + + for cap in icon_with_class.captures_iter(&input.clone()) { + let cap_str = &cap.get(3).unwrap().as_str().replace("\"", ""); + let icon = &(if cap_str.contains(" }}") { + cap_str.split(" }}").next().unwrap().to_string() + } else { + cap_str.to_string() + }); + + pull_icon(icon, icon_dir).await; + + let reader = ICONS.read().await; + let icon_text = reader.get(icon).unwrap().replace( + " Self { + Self::Tier1 + } +} + +impl DeveloperPassStorageQuota { + pub fn limit(&self) -> usize { + match self { + DeveloperPassStorageQuota::Tier1 => 26214400, + DeveloperPassStorageQuota::Tier2 => 52428800, + DeveloperPassStorageQuota::Tier3 => 104857600, + } + } +} + /// An app is required to request grants on user accounts. /// /// Users must approve grants through a web portal. @@ -90,6 +119,8 @@ pub struct ThirdPartyApp { pub api_key: String, /// The number of bytes the app's app_data rows are using. pub data_used: usize, + /// The app's storage capacity. + pub storage_capacity: DeveloperPassStorageQuota, } impl ThirdPartyApp { @@ -102,12 +133,13 @@ impl ThirdPartyApp { title, homepage, redirect, - quota_status: AppQuota::Limited, + quota_status: AppQuota::default(), banned: false, grants: 0, scopes: Vec::new(), api_key: String::new(), data_used: 0, + storage_capacity: DeveloperPassStorageQuota::default(), } } } @@ -132,12 +164,16 @@ impl AppData { } /// Get the data limit of a given user. - pub fn user_limit(user: &User) -> usize { + pub fn user_limit(user: &User, app: &ThirdPartyApp) -> usize { if user .secondary_permissions .check(SecondaryPermission::DEVELOPER_PASS) { - PASS_DATA_LIMIT + if app.storage_capacity != DeveloperPassStorageQuota::Tier1 { + app.storage_capacity.limit() + } else { + PASS_DATA_LIMIT + } } else { FREE_DATA_LIMIT } diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 37d2bf9..2e45b73 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -1,5 +1,4 @@ use std::collections::HashMap; - use super::{ oauth::AuthGrant, permissions::{FinePermission, SecondaryPermission}, @@ -338,6 +337,10 @@ pub struct UserSettings { /// Biography shown on `profile/private.lisp` page. #[serde(default)] pub private_biography: String, + /// If the followers/following links are hidden from the user's profile. + /// Will also revoke access to their respective pages. + #[serde(default)] + pub hide_social_follows: bool, } #[derive(Clone, Debug, Serialize, Deserialize, Default)] diff --git a/crates/core/src/model/mail.rs b/crates/core/src/model/mail.rs new file mode 100644 index 0000000..8336821 --- /dev/null +++ b/crates/core/src/model/mail.rs @@ -0,0 +1,44 @@ +use serde::{Serialize, Deserialize}; +use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; + +/// A letter is the most basic structure of the mail system. Letters are sent +/// and received by users. +#[derive(Serialize, Deserialize)] +pub struct Letter { + pub id: usize, + pub created: usize, + pub owner: usize, + pub receivers: Vec, + pub subject: String, + pub content: String, + /// The ID of every use who has read the letter. Can be checked in the UI + /// with `user.id in letter.read_by`. + /// + /// This field can be updated by anyone in the letter's `receivers` field. + /// Other fields in the letter can only be updated by the letter's `owner`. + pub read_by: Vec, + /// The ID of the letter this letter is replying to. + pub replying_to: usize, +} + +impl Letter { + /// Create a new [`Letter`]. + pub fn new( + owner: usize, + receivers: Vec, + subject: String, + content: String, + replying_to: usize, + ) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + owner, + receivers, + subject, + content, + read_by: Vec::new(), + replying_to, + } + } +} diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index 7d7f19e..06c4149 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -7,6 +7,7 @@ pub mod communities; pub mod communities_permissions; pub mod journals; pub mod littleweb; +pub mod mail; pub mod moderation; pub mod oauth; pub mod permissions; diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs index 796b9f1..61ebb61 100644 --- a/crates/core/src/model/permissions.rs +++ b/crates/core/src/model/permissions.rs @@ -178,6 +178,7 @@ bitflags! { const MANAGE_SERVICES = 1 << 3; const MANAGE_PRODUCTS = 1 << 4; const DEVELOPER_PASS = 1 << 5; + const MANAGE_LETTERS = 1 << 6; const _ = !0; } diff --git a/crates/core/src/sdk.rs b/crates/core/src/sdk.rs index 71ba502..0e5add6 100644 --- a/crates/core/src/sdk.rs +++ b/crates/core/src/sdk.rs @@ -75,6 +75,22 @@ impl DataClient { } } + /// Check if the given IP is IP banned from the Tetratto host. You will only know + /// if the IP is banned or not, meaning you will not be shown the reason if it + /// is banned. + pub async fn check_ip(&self, ip: &str) -> Result { + match self + .http + .get(format!("{}/api/v1/bans/{}", self.host, ip)) + .header("Atto-Secret-Key", &self.api_key) + .send() + .await + { + Ok(x) => api_return_ok!(bool, x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + /// Query the app's data. pub async fn query(&self, query: &SimplifiedQuery) -> Result { match self diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 02b14fc..a866bd1 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "tetratto-shared" description = "Shared stuff for Tetratto" -version = "12.0.0" +version = "12.0.6" edition = "2024" authors.workspace = true repository.workspace = true @@ -10,9 +10,10 @@ license.workspace = true [dependencies] ammonia = "4.1.1" chrono = "0.4.41" -markdown = "1.0.0" hex_fmt = "0.3.0" +pulldown-cmark = "0.13.0" rand = "0.9.1" +regex = "1.11.1" serde = { version = "1.0.219", features = ["derive"] } sha2 = "0.10.9" snowflaked = "1.0.3" diff --git a/crates/shared/src/markdown.rs b/crates/shared/src/markdown.rs index 82d6b79..022e23d 100644 --- a/crates/shared/src/markdown.rs +++ b/crates/shared/src/markdown.rs @@ -1,36 +1,42 @@ use ammonia::Builder; -use markdown::{to_html_with_options, Options, CompileOptions, ParseOptions, Constructs}; +use pulldown_cmark::{Parser, Options, html::push_html}; use std::collections::HashSet; -/// Render markdown input into HTML -pub fn render_markdown(input: &str) -> String { - let options = Options { - compile: CompileOptions { - allow_any_img_src: false, - allow_dangerous_html: true, - allow_dangerous_protocol: true, - gfm_task_list_item_checkable: false, - gfm_tagfilter: false, - ..Default::default() - }, - parse: ParseOptions { - constructs: Constructs { - math_flow: true, - math_text: true, - ..Constructs::gfm() - }, - gfm_strikethrough_single_tilde: false, - math_text_single_dollar: false, - mdx_expression_parse: None, - mdx_esm_parse: None, - ..Default::default() - }, - }; +pub fn render_markdown_dirty(input: &str) -> String { + let input = &autolinks(&parse_alignment(&parse_backslash_breaks(input))); - let html = match to_html_with_options(input, &options) { - Ok(h) => h, - Err(e) => e.to_string(), - }; + let mut options = Options::empty(); + options.insert(Options::ENABLE_STRIKETHROUGH); + options.insert(Options::ENABLE_GFM); + options.insert(Options::ENABLE_FOOTNOTES); + options.insert(Options::ENABLE_TABLES); + options.insert(Options::ENABLE_HEADING_ATTRIBUTES); + options.insert(Options::ENABLE_SUBSCRIPT); + + let parser = Parser::new_ext(input, options); + + let mut html = String::new(); + push_html(&mut html, parser); + + html +} + +pub fn clean_html(html: String, allowed_attributes: HashSet<&str>) -> String { + Builder::default() + .generic_attributes(allowed_attributes) + .add_tags(&[ + "video", "source", "img", "b", "span", "p", "i", "strong", "em", "a", "align", + ]) + .rm_tags(&["script", "style", "link", "canvas"]) + .add_tag_attributes("a", &["href", "target"]) + .add_url_schemes(&["atto"]) + .clean(&html.replace("