diff --git a/.gitignore b/.gitignore index f5f83f6..7bc86aa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target debug/ +.dev diff --git a/Cargo.lock b/Cargo.lock index c90535f..adc1cbb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,9 +34,9 @@ checksum = "4aa90d7ce82d4be67b64039a3d588d38dbcc6736577de4a847025ce5b0c468d1" [[package]] name = "ammonia" -version = "4.1.0" +version = "4.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ada2ee439075a3e70b6992fce18ac4e407cd05aea9ca3f75d2c0b0c20bbb364" +checksum = "d6b346764dd0814805de8abf899fe03065bcee69bb1a4771c785817e39f3978f" dependencies = [ "cssparser", "html5ever", @@ -337,12 +337,6 @@ dependencies = [ "tokio-postgres", ] -[[package]] -name = "bberry" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee0ee2ee1f1a6094d77ba1bf5402f8a8d66e77f6353aff728e37249b2e77458" - [[package]] name = "bit_field" version = "0.10.2" @@ -961,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" @@ -1124,12 +1127,11 @@ dependencies = [ [[package]] name = "html5ever" -version = "0.31.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953cbbe631aae7fc0a112702ad5d3aaf09da38beaf45ea84610d6e1c358f569c" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" dependencies = [ "log", - "mac", "markup5ever", "match_token", ] @@ -1576,6 +1578,17 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "io-uring" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1739,20 +1752,11 @@ 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.16.1" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a8096766c229e8c88a3900c9b44b7e06aa7f7343cc229158c3e58ef8f9973a" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" dependencies = [ "log", "tendril", @@ -1761,9 +1765,9 @@ dependencies = [ [[package]] name = "match_token" -version = "0.1.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" dependencies = [ "proc-macro2", "quote", @@ -1871,6 +1875,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "nanoneo" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1495d19c5bed5372c613d7b4a38e8093b357f4405ce38ba1de2d6586e5c892" + [[package]] name = "native-tls" version = "0.2.14" @@ -2345,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" @@ -2639,9 +2668,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "reqwest" -version = "0.12.20" +version = "0.12.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813" +checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" dependencies = [ "base64 0.22.1", "bytes", @@ -2659,6 +2688,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", @@ -2874,9 +2904,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.141" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" dependencies = [ "itoa", "memchr", @@ -2925,6 +2955,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3191,7 +3230,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml", + "toml 0.8.23", "version-compare", ] @@ -3249,19 +3288,20 @@ dependencies = [ [[package]] name = "tetratto" -version = "11.0.0" +version = "12.0.0" dependencies = [ "ammonia", "async-stripe", "axum", "axum-extra", - "bberry", "cf-turnstile", "contrasted", + "cookie", "emojis", "futures-util", "image", "mime_guess", + "nanoneo", "pathbufd", "regex", "reqwest", @@ -3280,7 +3320,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "11.0.0" +version = "12.0.2" dependencies = [ "async-recursion", "base16ct", @@ -3297,28 +3337,30 @@ dependencies = [ "serde_json", "tetratto-l10n", "tetratto-shared", - "toml", + "tokio", + "toml 0.9.2", "totp-rs", ] [[package]] name = "tetratto-l10n" -version = "11.0.0" +version = "12.0.0" dependencies = [ "pathbufd", "serde", - "toml", + "toml 0.9.2", ] [[package]] name = "tetratto-shared" -version = "11.0.0" +version = "12.0.6" dependencies = [ "ammonia", "chrono", "hex_fmt", - "markdown", + "pulldown-cmark", "rand 0.9.1", + "regex", "serde", "sha2", "snowflaked", @@ -3444,16 +3486,18 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.45.1" +version = "1.46.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17" dependencies = [ "backtrace", "bytes", + "io-uring", "libc", "mio", "parking_lot", "pin-project-lite", + "slab", "socket2", "tokio-macros", "windows-sys 0.52.0", @@ -3548,11 +3592,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_edit", ] +[[package]] +name = "toml" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 1.0.0", + "toml_datetime 0.7.0", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -3562,6 +3621,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +dependencies = [ + "serde", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -3570,17 +3638,25 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", - "serde_spanned", - "toml_datetime", - "toml_write", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "winnow", ] [[package]] -name = "toml_write" -version = "0.1.2" +name = "toml_parser" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" [[package]] name = "totp-rs" @@ -3815,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" @@ -3842,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/Cargo.toml b/Cargo.toml index e8d6326..b5beca0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = ["crates/app", "crates/shared", "crates/core", "crates/l10n"] package.authors = ["trisuaso"] package.repository = "https://trisua.com/t/tetratto" package.license = "AGPL-3.0-or-later" +package.homepage = "https://tetratto.com" [profile.dev] incremental = true diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 8a7eb6e..e5ca15f 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,7 +1,11 @@ [package] name = "tetratto" -version = "11.0.0" +version = "12.0.0" edition = "2024" +authors.workspace = true +repository.workspace = true +license.workspace = true +homepage.workspace = true [dependencies] pathbufd = "0.1.4" @@ -16,17 +20,16 @@ tower-http = { version = "0.6.6", features = [ "set-header", ] } axum = { version = "0.8.4", features = ["macros", "ws"] } -tokio = { version = "1.45.1", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1.46.1", features = ["macros", "rt-multi-thread"] } axum-extra = { version = "0.10.1", features = ["cookie", "multipart"] } -ammonia = "4.1.0" +ammonia = "4.1.1" tetratto-shared = { path = "../shared" } tetratto-core = { path = "../core" } tetratto-l10n = { path = "../l10n" } - image = "0.25.6" -reqwest = { version = "0.12.20", features = ["json", "stream"] } +reqwest = { version = "0.12.22", features = ["json", "stream"] } regex = "1.11.1" -serde_json = "1.0.140" +serde_json = "1.0.141" mime_guess = "2.0.5" cf-turnstile = "0.2.0" contrasted = "0.1.3" @@ -37,7 +40,9 @@ async-stripe = { version = "0.41.0", features = [ "webhook-events", "billing", "runtime-tokio-hyper", + "connect", ] } emojis = "0.7.0" webp = "0.3.0" -bberry = "0.2.0" +nanoneo = "0.2.0" +cookie = "0.18.1" diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 81671fe..82cf197 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -1,21 +1,17 @@ -use bberry::{ +use nanoneo::{ core::element::{Element, Render}, text, read_param, }; 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, + permissions::{FinePermission, SecondaryPermission}, }, }; use tetratto_l10n::LangFile; @@ -40,8 +36,8 @@ pub const ATTO_JS: &str = include_str!("./public/js/atto.js"); pub const ME_JS: &str = include_str!("./public/js/me.js"); pub const STREAMS_JS: &str = include_str!("./public/js/streams.js"); pub const CARP_JS: &str = include_str!("./public/js/carp.js"); -pub const LAYOUT_EDITOR_JS: &str = include_str!("./public/js/layout_editor.js"); pub const PROTO_LINKS_JS: &str = include_str!("./public/js/proto_links.js"); +pub const APP_SDK_JS: &str = include_str!("./public/js/app_sdk.js"); // html pub const BODY: &str = include_str!("./public/html/body.lisp"); @@ -59,6 +55,7 @@ pub const AUTH_BASE: &str = include_str!("./public/html/auth/base.lisp"); pub const AUTH_LOGIN: &str = include_str!("./public/html/auth/login.lisp"); pub const AUTH_REGISTER: &str = include_str!("./public/html/auth/register.lisp"); pub const AUTH_CONNECTION: &str = include_str!("./public/html/auth/connection.lisp"); +pub const AUTH_SELLER_CONNECTION: &str = include_str!("./public/html/auth/seller_connection.lisp"); pub const PROFILE_BASE: &str = include_str!("./public/html/profile/base.lisp"); pub const PROFILE_POSTS: &str = include_str!("./public/html/profile/posts.lisp"); @@ -140,6 +137,8 @@ pub const LITTLEWEB_SERVICE: &str = include_str!("./public/html/littleweb/servic pub const LITTLEWEB_DOMAIN: &str = include_str!("./public/html/littleweb/domain.lisp"); pub const LITTLEWEB_BROWSER: &str = include_str!("./public/html/littleweb/browser.lisp"); +pub const MARKETPLACE_SELLER: &str = include_str!("./public/html/marketplace/seller.lisp"); + // langs pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); @@ -147,44 +146,13 @@ pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); pub const VENDOR_SPOTIFY_ICON: &str = include_str!("./public/images/vendor/spotify.svg"); pub const VENDOR_LAST_FM_ICON: &str = include_str!("./public/images/vendor/last-fm.svg"); +pub const VENDOR_STRIPE_ICON: &str = include_str!("./public/images/vendor/stripe.svg"); pub const TETRATTO_BUNNY: &[u8] = include_bytes!("./public/images/tetratto_bunny.webp"); 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; @@ -237,7 +205,7 @@ pub(crate) async fn replace_in_html( input.to_string() } else { let start = SystemTime::now(); - let parsed = bberry::parse(input); + let parsed = nanoneo::parse(input); println!("parsed lisp in {}μs", start.elapsed().unwrap().as_micros()); if let Some(plugins) = plugins { @@ -257,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); @@ -344,6 +264,7 @@ pub(crate) fn lisp_plugins() -> HashMap Elemen pub(crate) async fn write_assets(config: &Config) -> PathBufD { vendor_icon!("spotify", VENDOR_SPOTIFY_ICON, config.dirs.icons); vendor_icon!("last_fm", VENDOR_LAST_FM_ICON, config.dirs.icons); + vendor_icon!("stripe", VENDOR_STRIPE_ICON, config.dirs.icons); bin_icon!("tetratto_bunny.webp", TETRATTO_BUNNY, config.dirs.assets); // ... @@ -365,6 +286,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"auth/login.html"(crate::assets::AUTH_LOGIN) --config=config --lisp plugins); write_template!(html_path->"auth/register.html"(crate::assets::AUTH_REGISTER) --config=config --lisp plugins); write_template!(html_path->"auth/connection.html"(crate::assets::AUTH_CONNECTION) --config=config --lisp plugins); + write_template!(html_path->"auth/seller_connection.html"(crate::assets::AUTH_SELLER_CONNECTION) --config=config --lisp plugins); write_template!(html_path->"profile/base.html"(crate::assets::PROFILE_BASE) -d "profile" --config=config --lisp plugins); write_template!(html_path->"profile/posts.html"(crate::assets::PROFILE_POSTS) --config=config --lisp plugins); @@ -441,6 +363,8 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"littleweb/domain.html"(crate::assets::LITTLEWEB_DOMAIN) --config=config --lisp plugins); write_template!(html_path->"littleweb/browser.html"(crate::assets::LITTLEWEB_BROWSER) --config=config --lisp plugins); + write_template!(html_path->"marketplace/seller.html"(crate::assets::MARKETPLACE_SELLER) -d "marketplace" --config=config --lisp plugins); + html_path } @@ -508,6 +432,11 @@ pub(crate) async fn initial_context( "is_supporter", &ua.permissions.check(FinePermission::SUPPORTER), ); + ctx.insert( + "has_developer_pass", + &ua.secondary_permissions + .check(SecondaryPermission::DEVELOPER_PASS), + ); ctx.insert("home", &ua.settings.default_timeline.relative_url()); } else { ctx.insert("is_helper", &false); diff --git a/crates/app/src/cookie.rs b/crates/app/src/cookie.rs new file mode 100644 index 0000000..45fd9a4 --- /dev/null +++ b/crates/app/src/cookie.rs @@ -0,0 +1,68 @@ +use std::convert::Infallible; +use axum::{ + extract::FromRequestParts, + http::{request::Parts, HeaderMap}, +}; +use cookie::{Cookie, CookieJar as CookieCookieJar}; + +/// This is required because "Cookie" his a forbidden header for some fucking reason. +/// Stupidest thing I've ever encountered in JavaScript, absolute fucking insanity. +/// +/// Anyway, most of this shit is just from the original source for axum_extra::extract::CookieJar, +/// just edited to use X-Cookie instead. +/// +/// Stuff from axum_extra will have links to the original provided. +pub struct CookieJar { + jar: CookieCookieJar, +} + +/// +impl FromRequestParts for CookieJar +where + S: Send + Sync, +{ + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + Ok(Self::from_headers(&parts.headers)) + } +} + +fn cookies_from_request( + header: String, + headers: &HeaderMap, +) -> impl Iterator> + '_ { + headers + .get_all(header) + .into_iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(';')) + .filter_map(|cookie| Cookie::parse_encoded(cookie.to_owned()).ok()) +} + +impl CookieJar { + /// + /// + /// Modified only to prefer "X-Cookie" header. + pub fn from_headers(headers: &HeaderMap) -> Self { + let mut jar = CookieCookieJar::new(); + + for cookie in cookies_from_request( + if headers.contains_key("X-Cookie") { + "X-Cookie".to_string() + } else { + "Cookie".to_string() + }, + headers, + ) { + jar.add_original(cookie.clone()); + } + + Self { jar } + } + + /// + pub fn get(&self, name: &str) -> Option<&Cookie<'static>> { + self.jar.get(name) + } +} diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index a852d5a..bd692f3 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -32,6 +32,7 @@ version = "1.0.0" "general:action.copy_link" = "Copy link" "general:action.copy_id" = "Copy ID" "general:action.post" = "Post" +"general:action.apply" = "Apply" "general:label.account" = "Account" "general:label.safety" = "Safety" "general:label.share" = "Share" @@ -130,6 +131,7 @@ version = "1.0.0" "communities:label.edit_content" = "Edit content" "communities:label.repost" = "Repost" "communities:label.quote_post" = "Quote post" +"communities:label.ask_about_this" = "Ask about this" "communities:label.search_results" = "Search results" "communities:label.query" = "Query" "communities:label.join_new" = "Join new" @@ -161,6 +163,7 @@ version = "1.0.0" "settings:tab.sessions" = "Sessions" "settings:tab.connections" = "Connections" "settings:tab.images" = "Images" +"settings:tab.presets" = "Presets" "settings:label.change_password" = "Change password" "settings:label.current_password" = "Current password" "settings:label.delete_account" = "Delete account" @@ -180,6 +183,11 @@ version = "1.0.0" "settings:label.ips" = "IPs" "settings:label.generate_invites" = "Generate invites" "settings:label.add_to_stack" = "Add to stack" +"settings:label.alt_text" = "Alt text" +"settings:label.deactivate_account" = "Deactivate account" +"settings:label.activate_account" = "Activate account" +"settings:label.deactivate" = "Deactivate" +"settings:label.account_deactivated" = "Account deactivated" "settings:tab.security" = "Security" "settings:tab.blocks" = "Blocks" "settings:tab.billing" = "Billing" @@ -194,6 +202,7 @@ version = "1.0.0" "mod_panel:label.associations" = "Associations" "mod_panel:label.invited_by" = "Invited by" "mod_panel:label.send_debug_payload" = "Send debug payload" +"mod_panel:label.ban_reason" = "Ban reason" "mod_panel:action.send" = "Send" "requests:label.requests" = "Requests" @@ -217,6 +226,8 @@ version = "1.0.0" "chats:action.add_someone" = "Add someone" "chats:action.kick_member" = "Kick member" "chats:action.mention_user" = "Mention user" +"chats:action.mute" = "Mute" +"chats:action.unmute" = "Unmute" "stacks:link.stacks" = "Stacks" "stacks:label.my_stacks" = "My stacks" @@ -229,6 +240,7 @@ version = "1.0.0" "stacks:label.block_all" = "Block all" "stacks:label.unblock_all" = "Unblock all" +"forge:label.forges" = "Forges" "forge:label.my_forges" = "My forges" "forge:label.create_new" = "Create new forge" "forge:tab.info" = "Info" @@ -237,6 +249,7 @@ version = "1.0.0" "forge:action.close" = "Close" "developer:label.for_developers" = "for Developers" +"developer:label.apps" = "Apps" "developer:label.my_apps" = "My apps" "developer:label.create_new" = "Create new app" "developer:label.homepage" = "Homepage" @@ -245,9 +258,13 @@ 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" +"developer:label.secret_key" = "Secret key" +"developer:label.roll_key" = "Roll key" +"developer:label.data_usage" = "Data usage" "developer:action.delete" = "Delete app" "developer:action.authorize" = "Authorize" @@ -285,3 +302,9 @@ version = "1.0.0" "littleweb:action.edit_site_name" = "Edit site name" "littleweb:action.rename" = "Rename" "littleweb:action.add" = "Add" + +"marketplace:label.products" = "Products" +"marketplace:label.status" = "Status" +"marketplace:action.get_started" = "Get started" +"marketplace:action.finsh_setting_up_account" = "Finish setting up my account" +"marketplace:action.open_seller_dashboard" = "Open seller dashboard" diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 2c3c03c..69730e0 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -87,7 +87,10 @@ macro_rules! get_user_from_token { { Ok(ua) => { if ua.permissions.check_banned() { - Some(tetratto_core::model::auth::User::banned()) + let mut banned_user = tetratto_core::model::auth::User::banned(); + banned_user.ban_reason = ua.ban_reason; + + Some(banned_user) } else { Some(ua) } @@ -109,7 +112,7 @@ macro_rules! get_user_from_token { Ok((grant, ua)) => { if grant.scopes.contains(&$grant_scope) { if ua.permissions.check_banned() { - Some(tetratto_core::model::auth::User::banned()) + None } else { Some(ua) } @@ -140,6 +143,20 @@ macro_rules! get_user_from_token { None } }}; + + (--browser_session=$browser_session:expr, $db:expr) => {{ + // browser session id + match $db.get_user_by_browser_session(&$browser_session).await { + Ok(ua) => { + if ua.permissions.check_banned() { + None + } else { + Some(ua) + } + } + Err(_) => None, + } + }}; } #[macro_export] @@ -175,6 +192,27 @@ macro_rules! user_banned { #[macro_export] macro_rules! check_user_blocked_or_private { ($user:expr, $other_user:ident, $data:ident, $jar:ident) => { + // check is_deactivated + if ($user.is_none() && $other_user.is_deactivated) + | ($user.is_some() + && !$user + .as_ref() + .unwrap() + .permissions + .check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS) + && $other_user.is_deactivated) + { + return Err(Html( + render_error( + Error::GeneralNotFound("user".to_string()), + &$jar, + &$data, + &$user, + ) + .await, + )); + } + // check require_account if $user.is_none() && $other_user.settings.require_account { return Err(Html( @@ -402,3 +440,17 @@ macro_rules! ignore_users_gen { .concat() }; } + +#[macro_export] +macro_rules! get_app_from_key { + ($db:ident, $headers:ident) => { + if let Some(token) = $headers.get("Atto-Secret-Key") { + match $db.get_app_by_api_key(token.to_str().unwrap()).await { + Ok(x) => Some(x), + Err(_) => None, + } + } else { + None + } + }; +} diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 00ad85f..b0098b7 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -2,13 +2,18 @@ #![doc(html_favicon_url = "/public/favicon.svg")] #![doc(html_logo_url = "/public/tetratto_bunny.webp")] mod assets; +mod cookie; mod image; mod macros; mod routes; mod sanitize; use assets::{init_dirs, write_assets}; -use tetratto_core::model::{permissions::FinePermission, uploads::CustomEmoji}; +use stripe::Client as StripeClient; +use tetratto_core::model::{ + permissions::{FinePermission, SecondaryPermission}, + uploads::CustomEmoji, +}; pub use tetratto_core::*; use axum::{ @@ -27,15 +32,17 @@ use tracing::{Level, info}; use std::{collections::HashMap, env::var, net::SocketAddr, process::exit, sync::Arc}; use tokio::sync::RwLock; -pub(crate) type State = Arc>; +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 { @@ -53,6 +60,15 @@ fn check_supporter(value: &Value, _: &HashMap) -> tera::Result) -> tera::Result { + Ok( + SecondaryPermission::from_bits(value.as_u64().unwrap() as u32) + .unwrap() + .check(SecondaryPermission::DEVELOPER_PASS) + .into(), + ) +} + fn check_staff_badge(value: &Value, _: &HashMap) -> tera::Result { Ok(FinePermission::from_bits(value.as_u64().unwrap() as u32) .unwrap() @@ -107,6 +123,7 @@ async fn main() { tera.register_filter("markdown", render_markdown); tera.register_filter("color", color_escape); tera.register_filter("has_supporter", check_supporter); + tera.register_filter("has_dev_pass", check_dev_pass); tera.register_filter("has_staff_badge", check_staff_badge); tera.register_filter("has_banned", check_banned); tera.register_filter("remove_script_tags", remove_script_tags); @@ -115,6 +132,13 @@ async fn main() { let client = Client::new(); let mut app = Router::new(); + // create stripe client + let stripe_client = if let Some(ref stripe) = config.stripe { + Some(StripeClient::new(stripe.secret.clone())) + } else { + None + }; + // add correct routes if var("LITTLEWEB").is_ok() { app = app.merge(routes::lw_routes()); @@ -123,13 +147,18 @@ async fn main() { .merge(routes::routes(&config)) .layer(SetResponseHeaderLayer::if_not_present( HeaderName::from_static("content-security-policy"), - HeaderValue::from_static("default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; worker-src * blob:; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *; frame-ancestors 'self'"), + HeaderValue::from_static("default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; worker-src * blob:; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' blob: *; frame-ancestors 'self'"), )); } // add junk app = app - .layer(Extension(Arc::new(RwLock::new((database, tera, client))))) + .layer(Extension(Arc::new(RwLock::new(( + database, + tera, + client, + stripe_client, + ))))) .layer(axum::extract::DefaultBodyLimit::max( var("BODY_LIMIT") .unwrap_or("8388608".to_string()) diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 24c41bd..ab6a09d 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -404,7 +404,7 @@ select:focus { .poll_bar { background-color: var(--color-primary); border-radius: var(--radius); - height: 25px; + height: 24px; } .poll_option { @@ -413,6 +413,22 @@ select:focus { overflow-wrap: anywhere; } +.progress_bar { + background: var(--color-super-lowered); + border-radius: var(--circle); + position: relative; + overflow: hidden; + height: 14px; +} + +.progress_bar .poll_bar { + border-radius: var(--circle); + height: 14px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + position: absolute; +} + input[type="checkbox"] { --color: #c9b1bc; appearance: none; @@ -583,6 +599,9 @@ input[type="checkbox"]:checked { border-radius: 6px; height: max-content; font-weight: 600; + display: flex; + justify-content: center; + align-items: center; } .notification.tr { @@ -597,6 +616,11 @@ input[type="checkbox"]:checked { padding: 0; } +.notification:not(.chip) .icon { + width: 100%; + height: 100%; +} + /* chip */ .chip { background: var(--color-primary); @@ -931,7 +955,7 @@ dialog::backdrop { transition: transform 0.15s; } -.dropdown:has(.inner.open) .dropdown-arrow { +.dropdown:has(.inner.open) .dropdown_arrow { transform: rotateZ(180deg); } @@ -1111,7 +1135,7 @@ details[open] > summary { margin-bottom: var(--pad-1); } -details[open] > summary::after { +details[open]:not(.accordion) > summary::after { top: 0; left: 0; width: 5px; @@ -1134,8 +1158,7 @@ details.accordion { } details.accordion summary { - background: var(--background); - border: solid 1px var(--color-super-lowered); + background: var(--color-lowered); border-radius: var(--radius); padding: var(--pad-3) var(--pad-4); margin: 0; @@ -1143,11 +1166,15 @@ details.accordion summary { user-select: none; } -details.accordion summary .icon { +details.accordion summary:hover { + background: var(--color-super-lowered); +} + +details.accordion summary .icon.dropdown_arrow { transition: transform 0.15s; } -details.accordion[open] summary .icon { +details.accordion[open] summary .icon.dropdown_arrow { transform: rotateZ(180deg); } @@ -1157,13 +1184,11 @@ details.accordion[open] summary { } details.accordion .inner { - background: var(--background); + background: var(--color-raised); padding: var(--pad-3) var(--pad-4); border-radius: var(--radius); border-top-left-radius: 0; border-top-right-radius: 0; - border: solid 1px var(--color-super-lowered); - border-top: none; } /* codemirror */ diff --git a/crates/app/src/public/html/auth/register.lisp b/crates/app/src/public/html/auth/register.lisp index aa94c3d..05b3d71 100644 --- a/crates/app/src/public/html/auth/register.lisp +++ b/crates/app/src/public/html/auth/register.lisp @@ -118,7 +118,7 @@ ("class" "hidden lowered card w-full no_p_margin") ("ui_ident" "purchase_help") (b (text "What does \"Purchase account\" mean?")) - (p (text "Your account will be created, but you cannot use it until you activate it for {{ config.stripe.supporter_price_text }}.")) + (p (text "Your account will be created, but you cannot use it until you activate it for {{ config.stripe.price_texts.supporter }}.")) (p (text "Alternatively, you can provide an invite code to create your account for free."))) (text "{%- endif %}") (button diff --git a/crates/app/src/public/html/auth/seller_connection.lisp b/crates/app/src/public/html/auth/seller_connection.lisp new file mode 100644 index 0000000..43381da --- /dev/null +++ b/crates/app/src/public/html/auth/seller_connection.lisp @@ -0,0 +1,25 @@ +(text "{% extends \"auth/base.html\" %} {% block head %}") +(title + (text "Connection")) + +(text "{% endblock %} {% block title %}Connection{% endblock %} {% block content %}") +(div + ("class" "w-full flex-col gap-2") + ("id" "status") + (b + (text "Working..."))) + +(text "{% if connection_type == \"refresh\" %}") +(script + ("defer" "true") + (text "setTimeout(async () => { + trigger(\"seller::onboarding\"); + }, 1000);")) +(text "{% elif connection_type == \"return\" %}") +(script + ("defer" "true") + (text "setTimeout(async () => { + document.getElementById(\"status\").innerHTML = + `Account updated. You can now close this tab.`; + }, 1000);")) +(text "{%- endif %} {% endblock %}") diff --git a/crates/app/src/public/html/chats/app.lisp b/crates/app/src/public/html/chats/app.lisp index 0dc16c3..a5bf139 100644 --- a/crates/app/src/public/html/chats/app.lisp +++ b/crates/app/src/public/html/chats/app.lisp @@ -210,6 +210,30 @@ }); }; + globalThis.mute_channel = async (id, mute = true) => { + await trigger(\"atto::debounce\", [\"channels::mute\"]); + fetch(`/api/v1/channels/${id}/mute`, { + method: mute ? \"POST\" : \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + if (mute) { + document.querySelector(`[ui_ident=channel\\\\.mute\\\\:${id}]`).classList.add(\"hidden\"); + document.querySelector(`[ui_ident=channel\\\\.unmute\\\\:${id}]`).classList.remove(\"hidden\"); + } else { + document.querySelector(`[ui_ident=channel\\\\.mute\\\\:${id}]`).classList.remove(\"hidden\"); + document.querySelector(`[ui_ident=channel\\\\.unmute\\\\:${id}]`).classList.add(\"hidden\"); + } + } + }); + }; + globalThis.update_channel_title = async (id) => { await trigger(\"atto::debounce\", [\"channels::update_title\"]); const title = await trigger(\"atto::prompt\", [\"New channel title:\"]); diff --git a/crates/app/src/public/html/chats/channels.lisp b/crates/app/src/public/html/chats/channels.lisp index a87dbeb..f789d32 100644 --- a/crates/app/src/public/html/chats/channels.lisp +++ b/crates/app/src/public/html/chats/channels.lisp @@ -31,6 +31,22 @@ (text "{{ icon \"user-plus\" }}") (span (text "{{ text \"chats:action.add_someone\" }}"))) + ; mute/unmute + (button + ("class" "lowered small {% if channel.id in user.channel_mutes -%} hidden {%- endif %}") + ("ui_ident" "channel.mute:{{ channel.id }}") + ("onclick" "mute_channel('{{ channel.id }}')") + (icon (text "bell-off")) + (span + (str (text "chats:action.mute")))) + (button + ("class" "lowered small {% if not channel.id in user.channel_mutes -%} hidden {%- endif %}") + ("ui_ident" "channel.unmute:{{ channel.id }}") + ("onclick" "mute_channel('{{ channel.id }}', false)") + (icon (text "bell-ring")) + (span + (str (text "chats:action.unmute")))) + ; ... (text "{%- endif %}") (button ("class" "lowered small") diff --git a/crates/app/src/public/html/communities/list.lisp b/crates/app/src/public/html/communities/list.lisp index 186d4f9..cf1cb48 100644 --- a/crates/app/src/public/html/communities/list.lisp +++ b/crates/app/src/public/html/communities/list.lisp @@ -29,7 +29,6 @@ ("minlength" "2") ("maxlength" "32"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")))) (text "{% if list|length >= 4 -%} {{ components::supporter_ad(body=\"Become a supporter to create up to 10 communities!\") }} {%- endif %} {%- endif %}") (div diff --git a/crates/app/src/public/html/communities/question.lisp b/crates/app/src/public/html/communities/question.lisp index 975e055..4468d25 100644 --- a/crates/app/src/public/html/communities/question.lisp +++ b/crates/app/src/public/html/communities/question.lisp @@ -39,7 +39,6 @@ ("class" "flex gap-2") (text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {% endif %}") (button - ("class" "primary") (text "{{ text \"requests:label.answer\" }}"))))) (text "{%- endif %}") (div diff --git a/crates/app/src/public/html/communities/search.lisp b/crates/app/src/public/html/communities/search.lisp index 642d214..a985e91 100644 --- a/crates/app/src/public/html/communities/search.lisp +++ b/crates/app/src/public/html/communities/search.lisp @@ -28,7 +28,6 @@ ("maxlength" "32") ("value" "{{ text }}"))) (button - ("class" "primary") (text "{{ text \"dialog:action.continue\" }}")))) (div ("class" "card-nest") diff --git a/crates/app/src/public/html/communities/settings.lisp b/crates/app/src/public/html/communities/settings.lisp index 4213cb9..fa5ddcf 100644 --- a/crates/app/src/public/html/communities/settings.lisp +++ b/crates/app/src/public/html/communities/settings.lisp @@ -135,7 +135,6 @@ ("required" "") ("minlength" "2"))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}")))))) @@ -190,7 +189,6 @@ ("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif") ("class" "w-content")) (button - ("class" "primary") (text "{{ icon \"check\" }}")))) (div ("class" "card-nest") @@ -213,7 +211,6 @@ ("accept" "image/png,image/jpeg,image/avif,image/webp") ("class" "w-content")) (button - ("class" "primary") (text "{{ icon \"check\" }}"))) (span ("class" "fade") @@ -245,7 +242,6 @@ ("required" "") ("minlength" "18"))) (button - ("class" "primary") (text "{{ text \"communities:action.select\" }}"))))) (div ("class" "card flex flex-col gap-2 w-full") @@ -296,7 +292,6 @@ ("minlength" "2") ("maxlength" "32"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")))) (text "{% for channel in channels %}") (div diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 156875e..8475223 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -102,13 +102,23 @@ ("class" "flush") ("style" "font-weight: 600") ("target" "_top") - (text "{{ self::username(user=user) }}")) + (text "{% if user.permissions|has_banned -%}") + (del ("class" "fade") (text "{{ self::username(user=user) }}")) + (text "{% else %}") + (text "{{ self::username(user=user) }}") + (text "{%- endif %}")) (text "{{ self::online_indicator(user=user) }} {% if user.is_verified -%}") (span ("title" "Verified") ("style" "color: var(--color-primary)") ("class" "flex items-center") (text "{{ icon \"badge-check\" }}")) + (text "{%- endif %} {% if user.permissions|has_staff_badge -%}") + (span + ("title" "Staff") + ("style" "color: var(--color-primary);") + ("class" "flex items-center") + (text "{{ icon \"shield-user\" }}")) (text "{%- endif %}")) (text "{%- endif %} {%- endmacro %} {% macro repost(repost, post, owner, secondary=false, community=false, show_community=true, can_manage_post=false) -%}") (div @@ -118,7 +128,7 @@ (div ("class" "card-nest post_outer:{{ post.id }} post_outer") ("is_repost" "{{ is_repost }}") - (text "{% if question -%} {{ self::question(question=question[0], owner=question[1], profile=owner) }} {% else %}") + (text "{% if question -%} {{ self::question(question=question[0], owner=question[1], asking_about=question[2], profile=owner) }} {% else %}") (div ("class" "card small") (a @@ -173,6 +183,12 @@ ("class" "flex items-center") ("style" "color: var(--color-primary)") (text "{{ icon \"square-asterisk\" }}")) + (text "{%- endif %} {% if post.context.full_unlist -%}") + (span + ("title" "Unlisted") + ("class" "flex items-center") + ("style" "color: var(--color-primary)") + (icon (text "eye-off"))) (text "{%- endif %} {% if post.stack -%}") (a ("title" "Posted to a stack you're in") @@ -221,7 +237,7 @@ ("hook" "long") (text "{{ post.title }}")) - (button ("class" "small lowered") (icon (text "ellipsis")))) + (button ("title" "View post content") ("class" "small lowered") (icon (text "ellipsis")))) (text "{% else %}") (text "{% if not post.context.content_warning -%}") (span @@ -315,13 +331,13 @@ ("class" "button camo small") ("target" "_blank") (text "{{ icon \"external-link\" }}")) - (text "{% if user -%}") (div ("class" "dropdown") (button ("class" "camo small") ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -329,6 +345,7 @@ (b ("class" "title") (text "{{ text \"general:label.share\" }}")) + (text "{% if user -%}") (button ("onclick" "trigger('me::repost', ['{{ post.id }}', '', '{{ config.town_square }}', true])") (text "{{ icon \"repeat-2\" }}") @@ -351,7 +368,16 @@ (span (text "BlueSky"))) (text "{%- endif %}") - (text "{% if user.id != post.owner -%}") + (text "{% if owner.settings.enable_questions -%}") + (a + ("class" "button") + ("href" "/@{{ owner.username }}?asking_about={{ post.id }}") + (icon (text "reply")) + (span + (str (text "communities:label.ask_about_this")))) + (text "{%- endif %}") + (text "{%- endif %}") + (text "{% if user and user.id != post.owner -%}") (b ("class" "title") (text "{{ text \"general:label.safety\" }}")) @@ -361,12 +387,12 @@ (text "{{ icon \"flag\" }}") (span (text "{{ text \"general:action.report\" }}"))) - (text "{%- endif %} {% if (user.id == post.owner) or is_helper or can_manage_post %}") + (text "{%- endif %} {% if user and (user.id == post.owner) or is_helper or can_manage_post %}") (b ("class" "title") (text "{{ text \"general:action.manage\" }}")) ; forge stuff - (text "{% if community and community.is_forge -%} {% if post.is_open -%}") + (text "{% if user and community and community.is_forge -%} {% if post.is_open -%}") (button ("class" "green") ("onclick" "trigger('me::update_open', ['{{ post.id }}', false])") @@ -382,7 +408,7 @@ (text "{{ text \"forge:action.reopen\" }}"))) (text "{%- endif %} {%- endif %}") ; owner stuff - (text "{% if user.id == post.owner -%}") + (text "{% if user and user.id == post.owner -%}") (a ("href" "/post/{{ post.id }}#/edit") (text "{{ icon \"pen\" }}") @@ -414,8 +440,7 @@ (text "{{ icon \"undo\" }}") (span (text "{{ text \"general:action.restore\" }}"))) - (text "{%- endif %} {%- endif %}"))) - (text "{%- endif %}")))) + (text "{%- endif %} {%- endif %}")))))) (text "{% if community and show_community and community.id != config.town_square or question %}")) (text "{%- endif %} {%- endmacro %} {% macro post_media(upload_ids) -%} {% if upload_ids|length > 0 -%}") @@ -427,7 +452,6 @@ ("alt" "Image upload") ("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload }}'])")) (text "{% endfor %}")) - (text "{%- endif %} {%- endmacro %} {% macro notification(notification) -%}") (div ("class" "w-full card-nest") @@ -624,7 +648,7 @@ --{{ css }}: {{ color|color }} !important; }")) -(text "{%- endif %} {%- endmacro %} {% macro question(question, owner, show_community=true, secondary=false, profile=false) -%}") +(text "{%- endif %} {%- endmacro %} {% macro question(question, owner, asking_about=false, show_community=true, secondary=false, profile=false) -%}") (div ("class" "card question {% if secondary -%}secondary{%- endif %} flex gap-2") (text "{% if owner.id == 0 or question.context.mask_owner -%}") @@ -694,6 +718,10 @@ (text "{{ question.content|markdown|safe }}")) ; question drawings (text "{{ self::post_media(upload_ids=question.drawings) }}") + ; asking about + (text "{% if asking_about -%}") + (text "{{ self::post(post=asking_about[1], owner=asking_about[0], secondary=not secondary, show_community=false) }}") + (text "{%- endif %}") ; anonymous user ip thing ; this is only shown if the post author is anonymous AND we are a helper (text "{% if is_helper and (owner.id == 0 or question.context.mask_owner) -%}") @@ -730,6 +758,7 @@ ("class" "no_p_margin") (text "{% if header -%} {{ header|markdown|safe }} {% else %} {{ text \"requests:label.ask_question\" }} {%- endif %}"))) (form + ("id" "create_question_form") ("class" "card flex flex-col gap-2") ("onsubmit" "create_question_from_form(event)") (div @@ -756,7 +785,6 @@ (div ("class" "flex gap-2") (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")) (text "{% if drawing_enabled -%}") @@ -800,7 +828,7 @@ }")) (text "{%- endif %}")) - (text "{% if not is_global and allow_anonymous and not user -%}") + (text "{% if not is_global and allow_anonymous and user -%}") (div ("class" "flex gap-2 items-center") (input @@ -816,6 +844,15 @@ (script (text "globalThis.gerald = null; + // asking about + globalThis.asking_about = new URLSearchParams(window.location.search).get(\"asking_about\") || \"\"; + + if (asking_about) { + document.getElementById(\"create_question_form\").innerHTML += + `
Asking about: ${asking_about} (cancel)`; + } + + // ... async function create_question_from_form(e) { e.preventDefault(); await trigger(\"atto::debounce\", [\"questions::create\"]); @@ -837,7 +874,8 @@ receiver: \"{{ receiver }}\", community: \"{{ community }}\", is_global: \"{{ is_global }}\" == \"true\", - mask_owner: (e.target.mask_owner || { checked:false }).checked + mask_owner: (e.target.mask_owner || { checked:false }).checked, + asking_about, }), ); @@ -866,7 +904,7 @@ (text "{%- endmacro %} {% macro global_question(question, can_manage_questions=false, secondary=false, show_community=true) -%}") (div ("class" "card-nest") - (text "{{ self::question(question=question[0], owner=question[1], show_community=show_community) }}") + (text "{{ self::question(question=question[0], owner=question[1], asking_about=false, show_community=show_community) }}") (div ("class" "small card flex justify-between flex-wrap gap-2{% if secondary -%} secondary{%- endif %}") (div @@ -893,6 +931,7 @@ ("class" "camo small") ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -1006,6 +1045,7 @@ ("class" "camo small") ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -1108,16 +1148,6 @@ (text "{{ icon \"circle-user-round\" }}") (span (text "{{ text \"auth:link.my_profile\" }}"))) - (a - ("href" "/journals/0/0") - (icon (text "notebook")) - (str (text "general:link.journals"))) - (text "{% if config.lw_host -%}") - (button - ("onclick" "document.getElementById('littleweb').showModal()") - (icon (text "globe")) - (str (text "general:link.little_web"))) - (text "{%- endif %}") (text "{% if not user.settings.disable_achievements -%}") (a ("href" "/achievements") @@ -1244,6 +1274,7 @@ ("class" "camo small square") ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -1427,7 +1458,9 @@ }); })();")) -(text "{%- endmacro %} {% macro supporter_ad(body=\"\") -%} {% if config.stripe and not is_supporter %}") +(text "{%- endmacro %}") + +(text "{% macro supporter_ad(body=\"\") -%} {% if config.stripe and not is_supporter %}") (div ("class" "card w-full supporter_ad") ("ui_ident" "supporter_ad") @@ -1447,8 +1480,9 @@ (text "{{ icon \"heart\" }}") (span (text "{{ text \"general:action.become_supporter\" }}"))))) +(text "{%- endif %} {%- endmacro %}") -(text "{%- endif %} {%- endmacro %} {% macro create_post_options() -%}") +(text "{% macro create_post_options() -%}") (div ("class" "flex gap-2 flex-wrap") (text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if not quoting -%} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {%- endif %} {%- endif %}") @@ -1465,6 +1499,7 @@ ("title" "More options") ("onclick" "document.getElementById('post_options_dialog').showModal()") ("type" "button") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (label @@ -1507,6 +1542,7 @@ is_nsfw: false, content_warning: \"\", tags: [], + full_unlist: false, }; window.BLANK_INITIAL_SETTINGS = JSON.stringify( @@ -1543,6 +1579,11 @@ // window.POST_INITIAL_SETTINGS.is_nsfw.toString(), // \"checkbox\", // ], + [ + [\"full_unlist\", \"Unlist from timelines\"], + window.POST_INITIAL_SETTINGS.full_unlist.toString(), + \"checkbox\", + ], [ [\"content_warning\", \"Content warning\"], window.POST_INITIAL_SETTINGS.content_warning, @@ -1759,8 +1800,8 @@ (span ("class" "notification chip") (text "{{ total }} votes")) (text "{% if not poll[2] -%}") (span - ("class" "notification chip") - (text "Expires in ") + ("class" "notification chip flex items-center gap-1") + (text "Expires in") (span ("class" "poll_date") ("data-created" "{{ poll[0].created }}") @@ -1836,7 +1877,6 @@ ("id" "join_or_leave") (text "{% if not is_owner -%} {% if not is_joined -%} {% if not is_pending %}") (button - ("class" "primary") ("onclick" "join_community()") (text "{{ icon \"circle-plus\" }}") (span @@ -2050,6 +2090,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("style" "width: 32px") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -2076,6 +2117,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("style" "width: 32px") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -2167,6 +2209,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("style" "width: 32px") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -2246,6 +2289,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("style" "width: 32px") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -2323,10 +2367,6 @@ (text "Save infinite post drafts")) (li (text "Ability to search through all posts")) - (li - (text "Ability to create forges")) - (li - (text "Create more than 1 app")) (li (text "Create up to 10 stack blocks")) (li @@ -2350,18 +2390,16 @@ (sup (a ("href" "#footnote-1") (text "1")))) (text "{%- endif %}")) (a - ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}") + ("href" "{{ config.stripe.payment_links.supporter }}?client_reference_id={{ user.id }}") ("class" "button") ("target" "_blank") - (text "Become a supporter ({{ config.stripe.supporter_price_text }})")) + (text "Become a supporter ({{ config.stripe.price_texts.supporter }})")) (span ("class" "fade") (text "Please use your") (b (text " real email ")) - (text "when - completing payment. It is required to manage - your billing settings.")) + (text "when completing payment. It is required to manage your billing settings.")) (text "{% if config.security.enable_invite_codes -%}") (span @@ -2370,3 +2408,46 @@ (b (text "1: ")) (text "After your account is at least 1 month old")) (text "{%- endif %}") (text "{%- endmacro %}") + +(text "{% macro get_developer_pass_button() -%}") +(p + (text "You currently do not hold a developer pass. With a developer pass, you'll get:")) +(ul + ("style" "margin-bottom: var(--pad-4)") + (li + (text "Increased app storage limit (500 KB->25 MB)")) + (li + (text "Ability to create forges")) + (li + (text "Ability to create more than 1 app")) + (li + (text "Developer pass profile badge"))) +(a + ("href" "{{ config.stripe.payment_links.dev_pass }}?client_reference_id={{ user.id }}") + ("class" "button") + ("target" "_blank") + (text "Continue ({{ config.stripe.price_texts.dev_pass }})")) +(span + ("class" "fade") + (text "Please use your") + (b + (text " real email ")) + (text "when completing payment. It is required to manage your billing settings. If you're already a supporter, please use the same email you used there.")) +(text "{%- endmacro %}") + +(text "{% macro developer_pass_ad(body) -%} {% if config.stripe and not has_developer_pass %}") +(div + ("class" "card w-full supporter_ad") + ("ui_ident" "supporter_ad") + ("onclick" "window.location.href = '/settings#/account/billing'") + (div + ("class" "card w-full flex flex-wrap items-center gap-2 justify-between") + (b + (text "{{ body }}")) + (a + ("href" "/settings#/account/billing") + ("class" "button small") + (icon (text "arrow-right")) + (span + (str (text "dialog:action.continue")))))) +(text "{%- endif %} {%- endmacro %}") diff --git a/crates/app/src/public/html/developer/app.lisp b/crates/app/src/public/html/developer/app.lisp index 2850ef5..d19fb10 100644 --- a/crates/app/src/public/html/developer/app.lisp +++ b/crates/app/src/public/html/developer/app.lisp @@ -10,11 +10,27 @@ (div ("id" "manage_fields") ("class" "card lowered flex flex-col gap-2") + (div + ("class" "card-nest") + (div + ("class" "card small flex items-center gap-2") + (icon (text "database")) + (b (str (text "developer:label.data_usage")))) + (div + ("class" "card flex flex-col gap-2") + (p ("class" "fade") (text "App data keys are not included in this metric, only stored values count towards your limit.")) + (text "{% set percentage = (app.data_used / data_limit) * 100 %}") + (div ("class" "progress_bar") (div ("class" "poll_bar") ("style" "width: {{ percentage }}%"))) + (div + ("class" "w-full flex justify-between items-center") + (span (text "{{ app.data_used|filesizeformat }}")) + (span (text "{{ data_limit|filesizeformat }}"))))) (text "{% if is_helper -%}") (div ("class" "card-nest") (div - ("class" "card small") + ("class" "card small flex items-center gap-2") + (icon (text "infinity")) (b (str (text "developer:label.change_quota_status")))) (div ("class" "card") @@ -28,11 +44,34 @@ ("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") (div - ("class" "card small") + ("class" "card small flex items-center gap-2") + (icon (text "pencil")) (b (str (text "developer:label.change_title")))) (form ("class" "card flex flex-col gap-2") @@ -50,14 +89,14 @@ ("required" "") ("minlength" "2"))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}"))))) (div ("class" "card-nest") (div - ("class" "card small") + ("class" "card small flex items-center gap-2") + (icon (text "house")) (b (str (text "developer:label.change_homepage")))) (form ("class" "card flex flex-col gap-2") @@ -75,14 +114,14 @@ ("required" "") ("minlength" "2"))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}"))))) (div ("class" "card-nest") (div - ("class" "card small") + ("class" "card small flex items-center gap-2") + (icon (text "goal")) (b (str (text "developer:label.change_redirect")))) (form ("class" "card flex flex-col gap-2") @@ -100,14 +139,14 @@ ("required" "") ("minlength" "2"))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}"))))) (div ("class" "card-nest") (div - ("class" "card small") + ("class" "card small flex items-center gap-2") + (icon (text "telescope")) (b (str (text "developer:label.manage_scopes")))) (form ("class" "card flex flex-col gap-2") @@ -140,10 +179,22 @@ (icon (text "external-link")) (text "Docs")))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span - (text "{{ text \"general:action.save\" }}")))))) + (text "{{ text \"general:action.save\" }}"))))) + (div + ("class" "card-nest") + (div + ("class" "card small flex items-center gap-2") + (icon (text "rotate-ccw-key")) + (b (str (text "developer:label.secret_key")))) + (div + ("class" "card flex flex-col gap-2") + (p ("class" "fade") (text "Your app's API key can only be seen once, so don't lose it. Rolling the key will invalidate the old one.")) + (pre (code ("id" "new_key"))) + (button + ("onclick" "roll_key()") + (str (text "developer:label.roll_key")))))) (div ("class" "card flex flex-col gap-2") (ul @@ -151,7 +202,8 @@ (li (b (text "Redirect URL: ")) (text "{{ app.redirect }}")) (li (b (text "Quota status: ")) (text "{{ app.quota_status }}")) (li (b (text "User grants: ")) (text "{{ app.grants }}")) - (li (b (text "Grant URL: ")) (text "{{ config.host }}/auth/connections_link/app/{{ app.id }}"))) + (li (b (text "Grant URL: ")) (text "{{ config.host }}/auth/connections_link/app/{{ app.id }}")) + (li (b (text "App ID (for SDK): ")) (text "{{ app.id }}"))) (a ("class" "button") @@ -202,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(); @@ -323,6 +395,31 @@ }); }; + globalThis.roll_key = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/apps/{{ app.id }}/roll\", { + method: \"POST\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + document.getElementById(\"new_key\").innerText = res.payload; + } + }); + }; + globalThis.delete_app = async () => { if ( !(await trigger(\"atto::confirm\", [ diff --git a/crates/app/src/public/html/developer/home.lisp b/crates/app/src/public/html/developer/home.lisp index aefd55d..160181b 100644 --- a/crates/app/src/public/html/developer/home.lisp +++ b/crates/app/src/public/html/developer/home.lisp @@ -41,23 +41,19 @@ ("id" "homepage") ("placeholder" "homepage") ("required" "") - ("minlength" "2") - ("maxlength" "32"))) + ("minlength" "2"))) (div ("class" "flex flex-col gap-1") (label ("for" "title") - (text "{{ text \"developer:label.redirect\" }}")) + (text "{{ text \"developer:label.redirect\" }} (optional)")) (input ("type" "url") ("name" "redirect") ("id" "redirect") ("placeholder" "redirect URL") - ("required" "") - ("minlength" "2") - ("maxlength" "32"))) + ("minlength" "2"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")))) ; app listing @@ -126,7 +122,7 @@ body: JSON.stringify({ title: e.target.title.value, homepage: e.target.homepage.value, - redirect: e.target.redirect.value, + redirect: e.target.redirect.value || \"\", }), }) .then((res) => res.json()) diff --git a/crates/app/src/public/html/developer/link.lisp b/crates/app/src/public/html/developer/link.lisp index 5d46c87..f50ad18 100644 --- a/crates/app/src/public/html/developer/link.lisp +++ b/crates/app/src/public/html/developer/link.lisp @@ -39,6 +39,13 @@ (str (text "dialog:action.cancel"))))))) (script (text "setTimeout(() => { + // {% if app.redirect|length == 0 %} + alert(\"App has an invalid redirect. Please contact the owner for help.\"); + window.close(); + return; + // {% endif %} + + // ... globalThis.authorize = async (event) => { if ( !(await trigger(\"atto::confirm\", [ @@ -76,6 +83,7 @@ const search = new URLSearchParams(window.location.search); search.append(\"verifier\", verifier); search.append(\"token\", res.payload); + search.append(\"uid\", \"{{ user.id }}\"); window.location.href = `{{ app.redirect|remove_script_tags|safe }}?${search.toString()}`; } diff --git a/crates/app/src/public/html/forge/home.lisp b/crates/app/src/public/html/forge/home.lisp index a83c545..3208a63 100644 --- a/crates/app/src/public/html/forge/home.lisp +++ b/crates/app/src/public/html/forge/home.lisp @@ -6,7 +6,7 @@ (main ("class" "flex flex-col gap-2") ; create new - (text "{% if user.permissions|has_supporter -%}") + (text "{% if user.secondary_permissions|has_dev_pass -%}") (div ("class" "card-nest") (div @@ -30,10 +30,9 @@ ("minlength" "2") ("maxlength" "32"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")))) (text "{% else %}") - (text "{{ components::supporter_ad(body=\"Become a supporter to create forges!\") }}") + (text "{{ components::developer_pass_ad(body=\"Get a developer pass to create forges!\") }}") (text "{%- endif %}") ; forge listing diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp index 255b2ec..71fbd4d 100644 --- a/crates/app/src/public/html/journals/app.lisp +++ b/crates/app/src/public/html/journals/app.lisp @@ -253,7 +253,6 @@ ("required" "") ("minlength" "2"))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}"))))))) @@ -379,7 +378,6 @@ ("name" "tags") ("id" "tags") ("placeholder" "tags") - ("required" "") ("minlength" "2") ("maxlength" "128") (text "{% for tag in note.tags -%} {{ tag }}, {% endfor %}")) diff --git a/crates/app/src/public/html/littleweb/browser.lisp b/crates/app/src/public/html/littleweb/browser.lisp index 76ac82b..95f35d8 100644 --- a/crates/app/src/public/html/littleweb/browser.lisp +++ b/crates/app/src/public/html/littleweb/browser.lisp @@ -32,7 +32,7 @@ (input ("type" "uri") ("class" "w-full") - ("true_value" "{{ path }}") + ("true_value" "") ("name" "uri") ("id" "uri")) @@ -47,7 +47,7 @@ ("exclude" "dropdown") ("style" "gap: var(--pad-1) !important") (text "{{ components::avatar(username=user.username, size=\"24px\") }}") - (icon_class (text "chevron-down") (text "dropdown-arrow"))) + (icon_class (text "chevron-down") (text "dropdown_arrow"))) (text "{{ components::user_menu() }}")) (text "{%- endif %}")) @@ -55,7 +55,7 @@ (iframe ("id" "browser_iframe") ("frameborder" "0") - ("src" "{% if path -%} {{ config.lw_host }}/api/v1/net/{{ path }} {%- endif %}")) + ("src" "{% if path -%} {{ config.lw_host }}/api/v1/net/{{ path }}?s={{ session }} {%- endif %}")) (style ("data-turbo-temporary" "true") @@ -91,6 +91,7 @@ position: fixed; width: calc(100dvw - (62px + var(--pad-2) * 2)) !important; left: var(--pad-2); + z-index: 2; } } @@ -101,7 +102,7 @@ height: var(--h); min-height: var(--h); max-height: var(--h); - font-size: 14px; + font-size: 16px; } #panel button:not(.inner *), @@ -115,18 +116,23 @@ }")) (script - (text "function littleweb_navigate(uri) { + (text "globalThis.SECRET_SESSION = \"{{ session }}\"; + function littleweb_navigate(uri) { if (!uri.includes(\".html\")) { uri = `${uri}/index.html`; } - if (!uri.startsWith(\"atto://\")) { - uri = `atto://${uri}`; - } - // ... console.log(\"navigate\", uri); - document.getElementById(\"browser_iframe\").src = `{{ config.lw_host|safe }}/api/v1/net/${uri}`; + document.getElementById(\"browser_iframe\").src = `{{ config.lw_host|safe }}/api/v1/net/${uri}?s={{ session }}`; + + if (!uri.includes(\"atto://\")) { + document.getElementById(\"uri\").setAttribute(\"true_value\", `atto://${uri}`); + } else { + document.getElementById(\"uri\").setAttribute(\"true_value\", uri); + } + + document.getElementById(\"uri\").value = uri.replace(\"atto://\", \"\").split(\"/\")[0]; } document.getElementById(\"browser_iframe\").addEventListener(\"load\", (e) => { @@ -151,7 +157,14 @@ if (data.event === \"change_url\") { const uri = new URL(data.target).pathname.slice(\"/api/v1/net/\".length); window.history.pushState(null, null, `/net/${uri.replace(\"atto://\", \"\")}`); - document.getElementById(\"uri\").setAttribute(\"true_value\", uri); + + if (!uri.includes(\"atto://\")) { + document.getElementById(\"uri\").setAttribute(\"true_value\", `atto://${uri}`); + } else { + document.getElementById(\"uri\").setAttribute(\"true_value\", uri); + } + + document.getElementById(\"uri\").value = uri.replace(\"atto://\", \"\").split(\"/\")[0]; } }); @@ -206,6 +219,9 @@ is_focused = false; }); - document.getElementById(\"uri\").value = document.getElementById(\"uri\").getAttribute(\"true_value\").replace(\"atto://\", \"\").split(\"/\")[0]")) + // navigate + if ({{ path|length }} > 0) { + littleweb_navigate(\"{{ path|safe }}\"); + }")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/littleweb/domain.lisp b/crates/app/src/public/html/littleweb/domain.lisp index d8b01f1..d4bc359 100644 --- a/crates/app/src/public/html/littleweb/domain.lisp +++ b/crates/app/src/public/html/littleweb/domain.lisp @@ -72,7 +72,7 @@ ("class" "card hidden w-full lowered flex flex-col gap-2") ("onsubmit" "add_data_from_form(event)") (div - ("class" "flex gap-2") + ("class" "flex gap-2 flex-collapse") (div ("class" "flex w-full flex-col gap-1") (label @@ -119,44 +119,48 @@ (icon (text "check")) (str (text "general:action.save"))))) ; data - (table - (thead - (tr - (th (text "Name")) - (th (text "Type")) - (th (text "Value")) - (th (text "Actions")))) + (div + ("class" "w-full") + ("style" "max-width: 100%; overflow: auto; min-height: 512px") + (table + ("class" "w-full") + (thead + (tr + (th (text "Name")) + (th (text "Type")) + (th (text "Value")) + (th (text "Actions")))) - (tbody - (text "{% for item in domain.data -%}") - (tr - (td (text "{{ item[0] }}")) - (text "{% for k,v in item[1] -%}") - (td (text "{{ k }}")) - (td (text "{{ v }}")) - (text "{%- endfor %}") - (td - ("style" "overflow: auto") - (div - ("class" "dropdown") - (button - ("class" "camo small") - ("onclick" "trigger('atto::hooks::dropdown', [event])") - ("exclude" "dropdown") - (icon (text "ellipsis"))) + (tbody + (text "{% for item in domain.data -%}") + (tr + (td (text "{{ item[0] }}")) + (text "{% for k,v in item[1] -%}") + (td (text "{{ k }}")) + (td (text "{{ v }}")) + (text "{%- endfor %}") + (td + ("style" "overflow: auto") (div - ("class" "inner") + ("class" "dropdown") (button - ("onclick" "rename_data('{{ item[0] }}')") - (icon (text "pencil")) - (str (text "littleweb:action.rename"))) + ("class" "camo small") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + (icon (text "ellipsis"))) + (div + ("class" "inner") + (button + ("onclick" "rename_data('{{ item[0] }}')") + (icon (text "pencil")) + (str (text "littleweb:action.rename"))) - (button - ("class" "red") - ("onclick" "remove_data('{{ item[0] }}')") - (icon (text "trash")) - (str (text "general:action.delete"))))))) - (text "{%- endfor %}")))))) + (button + ("class" "red") + ("onclick" "remove_data('{{ item[0] }}')") + (icon (text "trash")) + (str (text "general:action.delete"))))))) + (text "{%- endfor %}"))))))) (script ("id" "domain_data") ("type" "application/json") (text "{{ domain.data|json_encode()|safe }}")) (script diff --git a/crates/app/src/public/html/littleweb/domains.lisp b/crates/app/src/public/html/littleweb/domains.lisp index 1a9b649..c79ab3e 100644 --- a/crates/app/src/public/html/littleweb/domains.lisp +++ b/crates/app/src/public/html/littleweb/domains.lisp @@ -5,6 +5,17 @@ (text "{% endblock %} {% block body %} {{ macros::nav() }}") (main ("class" "flex flex-col gap-2") + + ; viewing other user's domains warning + (text "{% if profile.id != user.id -%}") + (div + ("class" "card w-full red flex gap-2 items-center") + (text "{{ icon \"skull\" }}") + (b + (text "Viewing other user's domains! Please be careful."))) + (text "{%- endif %}") + + ; ... (text "{% if user -%}") (div ("class" "pillmenu") @@ -48,7 +59,6 @@ (option ("value" "{{ tld }}") (text ".{{ tld|lower }}")) (text "{%- endfor %}"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")) (details diff --git a/crates/app/src/public/html/littleweb/service.lisp b/crates/app/src/public/html/littleweb/service.lisp index b0b7ac9..7cd9597 100644 --- a/crates/app/src/public/html/littleweb/service.lisp +++ b/crates/app/src/public/html/littleweb/service.lisp @@ -14,9 +14,24 @@ (div ("class" "card-nest") (div - ("class" "card small") - (b - (text "{{ service.name }}"))) + ("class" "card small flex flex-col gap-2") + (div + ("class" "flex w-full gap-2 justify-between") + (b + (text "{{ service.name }}")) + + (button + ("class" "small lowered") + ("title" "Help") + ("onclick" "document.getElementById('site_help').classList.toggle('hidden')") + (icon (text "circle-question-mark")))) + + (div + ("class" "card w-full lowered flex flex-col gap-2 hidden no_p_margin") + ("id" "site_help") + (p (text "Your site should include an \"index.html\" file in order to show content on its homepage.")) + (p (text "In the HTML editor, you can type `!` and use the provided suggestion to get an HTML boilerplate.")) + (p (text "After you've created a page, you can click \"Copy ID\" and go to manage a domain you own. On the domain management page, click \"Add\" and paste the ID you copied into the value field.")))) (div ("class" "flex gap-2 flex-wrap card") @@ -72,53 +87,57 @@ ("class" "card flex flex-col gap-2") (text "{% if not file or file.children|length > 0 -%}") ; directory browser - (table - (thead - (tr - (th (text "Name")) - (th (text "Type")) - (th (text "Children")) - (th (text "Actions")))) + (div + ("class" "w-full") + ("style" "max-width: 100%; overflow: auto; min-height: 512px") + (table + ("class" "w-full") + (thead + (tr + (th (text "Name")) + (th (text "Type")) + (th (text "Children")) + (th (text "Actions")))) - (tbody - (text "{% for item in files %}") - (tr - (td - ("class" "flex gap-2 items-center") - (text "{% if item.children|length > 0 -%}") - (icon (text "folder")) - (text "{% else %}") - (icon (text "file")) - (text "{%- endif %}") + (tbody + (text "{% for item in files %}") + (tr + (td + ("class" "flex gap-2 items-center") + (text "{% if item.children|length > 0 -%}") + (icon (text "folder")) + (text "{% else %}") + (icon (text "file")) + (text "{%- endif %}") - (a - ("href" "?path={{ path }}/{{ item.name }}") - ("data-turbo" "false") - (text "{{ item.name }}"))) - (td (text "{{ item.mime }}")) - (td (text "{{ item.children|length }}")) - (td - ("style" "overflow: auto") - (div - ("class" "dropdown") - (button - ("class" "camo small") - ("onclick" "trigger('atto::hooks::dropdown', [event])") - ("exclude" "dropdown") - (icon (text "ellipsis"))) + (a + ("href" "?path={{ path }}/{{ item.name }}") + ("data-turbo" "false") + (text "{{ item.name }}"))) + (td (text "{{ item.mime }}")) + (td (text "{{ item.children|length }}")) + (td + ("style" "overflow: auto") (div - ("class" "inner") + ("class" "dropdown") (button - ("onclick" "rename_file('{{ item.id }}')") - (icon (text "pencil")) - (str (text "littleweb:action.rename"))) + ("class" "camo small") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + (icon (text "ellipsis"))) + (div + ("class" "inner") + (button + ("onclick" "rename_file('{{ item.id }}')") + (icon (text "pencil")) + (str (text "littleweb:action.rename"))) - (button - ("class" "red") - ("onclick" "remove_file('{{ item.id }}')") - (icon (text "trash")) - (str (text "general:action.delete"))))))) - (text "{% endfor %}"))) + (button + ("class" "red") + ("onclick" "remove_file('{{ item.id }}')") + (icon (text "trash")) + (str (text "general:action.delete"))))))) + (text "{% endfor %}")))) (text "{% else %}") ; file editor (div ("id" "editor_container") ("class" "w-full") ("style" "height: 600px")) @@ -319,6 +338,7 @@ (text "{% if file and file.mime != 'Plain' -%}") (script ("src" "https://unpkg.com/monaco-editor@0.52.2/min/vs/loader.js")) +(script ("src" "https://unpkg.com/emmet-monaco-es/dist/emmet-monaco.min.js")) (script ("id" "file_content") ("type" "text/plain") (text "{{ file.content|remove_script_tags|safe }}")) (script (text "require.config({ paths: { vs: \"https://unpkg.com/monaco-editor@0.52.2/min/vs\" } }); @@ -337,10 +357,16 @@ style.innerText = '@import \"https://unpkg.com/monaco-editor@0.52.2/min/vs/editor/editor.main.css\";'; shadow.appendChild(style); + emmetMonaco.emmetHTML(); + emmetMonaco.emmetCSS(); + globalThis.editor = monaco.editor.create(inner, { value: document.getElementById(\"file_content\").innerText.replaceAll(\"</script>\", \"\"), language: MIME_MODES[\"{{ file.mime }}\"], theme: \"vs-dark\", + suggest: { + snippetsPreventQuickSuggestions: false, + }, }); });")) (text "{%- endif %}") diff --git a/crates/app/src/public/html/littleweb/services.lisp b/crates/app/src/public/html/littleweb/services.lisp index cca5af7..261b006 100644 --- a/crates/app/src/public/html/littleweb/services.lisp +++ b/crates/app/src/public/html/littleweb/services.lisp @@ -5,6 +5,17 @@ (text "{% endblock %} {% block body %} {{ macros::nav() }}") (main ("class" "flex flex-col gap-2") + + ; viewing other user's services warning + (text "{% if profile.id != user.id -%}") + (div + ("class" "card w-full red flex gap-2 items-center") + (text "{{ icon \"skull\" }}") + (b + (text "Viewing other user's sites! Please be careful."))) + (text "{%- endif %}") + + ; ... (text "{% if user -%}") (div ("class" "pillmenu") @@ -34,7 +45,6 @@ ("minlength" "2") ("maxlength" "32"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")))) (text "{%- endif %}") (div @@ -62,7 +72,10 @@ (span ("class" "date") (text "{{ item.created }}")) - (text "; {{ item.files|length }} files"))) + (text "; Updated ") + (span + ("class" "date") + (text "{{ item.revision }}")))) (text "{% endfor %}")))) (script diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index f9d8a1f..fff3188 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -39,12 +39,6 @@ ("title" "Create post") (icon (text "square-pen"))) - (a - ("href" "/chats/0/0") - ("class" "button {% if selected == 'chats' -%}active{%- endif %}") - ("title" "Chats") - (icon (text "message-circle"))) - (a ("href" "/requests") ("class" "button {% if selected == 'requests' -%}active{%- endif %}") @@ -65,6 +59,43 @@ ("id" "notifications_span") (text "{{ user.notification_count }}"))) + (text "{% if user -%}") + (div + ("class" "dropdown") + (button + ("class" "flex-row {% if selected == 'chats' or selected == 'journals' -%}active{%- endif %}") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("title" "More services") + (icon (text "grip"))) + + (div + ("class" "inner") + (a + ("href" "/chats/0/0") + ("title" "Chats") + (icon (text "message-circle")) + (str (text "communities:label.chats"))) + (a + ("href" "/journals/0/0") + (icon (text "notebook")) + (str (text "general:link.journals"))) + (a + ("href" "/forges") + (icon (text "anvil")) + (str (text "forge:label.forges"))) + (a + ("href" "/developer") + (icon (text "code")) + (str (text "developer:label.apps"))) + (text "{% if config.lw_host -%}") + (button + ("onclick" "document.getElementById('littleweb').showModal()") + (icon (text "globe")) + (str (text "general:link.little_web"))) + (text "{%- endif %}"))) + (text "{%- endif %}") + (text "{% if not hide_user_menu -%}") (div ("class" "dropdown") @@ -73,8 +104,9 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("style" "gap: var(--pad-1) !important") + ("title" "Account options") (text "{{ components::avatar(username=user.username, size=\"24px\") }}") - (icon_class (text "chevron-down") (text "dropdown-arrow"))) + (icon_class (text "chevron-down") (text "dropdown_arrow"))) (text "{{ components::user_menu() }}")) (text "{%- endif %} {% else %}") @@ -84,7 +116,7 @@ ("class" "title") ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") - (icon_class (text "chevron-down") (text "dropdown-arrow"))) + (icon_class (text "chevron-down") (text "dropdown_arrow"))) (div ("class" "inner") @@ -331,3 +363,17 @@ (span (text "{{ text \"settings:tab.connections\" }}"))) (text "{%- endmacro %}") + +(text "{% macro seller_settings_nav_options() -%}") +(a + ("data-tab-button" "account") + ("class" "active") + ("href" "#/account") + (icon (text "smile")) + (span (str (text "settings:tab.account")))) +(a + ("data-tab-button" "products") + ("href" "#/products") + (icon (text "package")) + (span (str (text "marketplace:label.products")))) +(text "{%- endmacro %}") diff --git a/crates/app/src/public/html/marketplace/seller.lisp b/crates/app/src/public/html/marketplace/seller.lisp new file mode 100644 index 0000000..0efa4f0 --- /dev/null +++ b/crates/app/src/public/html/marketplace/seller.lisp @@ -0,0 +1,79 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "Seller settings - {{ config.name }}")) +(text "{% endblock %} {% block body %} {{ macros::nav() }}") +(main + ("class" "flex flex-col gap-2") + + ; nav + (div + ("class" "mobile_nav mobile") + ; primary nav + (div + ("class" "dropdown") + ("style" "width: max-content") + (button + ("class" "raised small") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + (icon (text "sliders-horizontal")) + (span ("class" "current_tab_text") (text "account"))) + (div + ("class" "inner left") + (text "{{ macros::seller_settings_nav_options() }}")))) + + ; nav desktop + (div + ("class" "desktop pillmenu") + (text "{{ macros::seller_settings_nav_options() }}")) + + ; ... + (div + ("class" "card w-full lowered flex flex-col gap-2") + ("data-tab" "account") + (div + ("class" "card-nest w-full") + (div + ("class" "card small flex items-center gap-2") + (div + ("class" "notification") + ("style" "width: 46px") + (icon (text "stripe"))) + + (b (str (text "marketplace:label.status")))) + + (div + ("class" "card") + (text "{% if user.seller_data.account_id -%}") + (text "{% if user.seller_data.completed_onboarding -%}") + ; completed onboarding + has stripe account linked + (button + ("onclick" "trigger('seller::login')") + (icon (text "arrow-right")) + (str (text "marketplace:action.open_seller_dashboard"))) + (text "{% else %}") + ; not completed onboarding + (p (text "You've not finished setting up your Stripe account.")) + (p (text "Please complete onboarding to accept payments.")) + + (button + ("onclick" "trigger('seller::onboarding')") + (icon (text "arrow-right")) + (str (text "marketplace:action.finsh_setting_up_account"))) + (text "{%- endif %}") + (text "{% else %}") + ; doesn't have a stripe account linked + (button + ("onclick" "trigger('seller::register')") + (icon (text "arrow-right")) + (str (text "marketplace:action.get_started"))) + (text "{%- endif %}")))) + + (div + ("class" "card w-full lowered hidden flex flex-col gap-2") + ("data-tab" "products") + (div + ("class" "card w-full flex flex-wrap gap-2") + ))) + +(text "{% endblock %}") diff --git a/crates/app/src/public/html/misc/achievements.lisp b/crates/app/src/public/html/misc/achievements.lisp index 429c924..93f895e 100644 --- a/crates/app/src/public/html/misc/achievements.lisp +++ b/crates/app/src/public/html/misc/achievements.lisp @@ -17,7 +17,7 @@ (p (text "You'll find out what each achievement is when you get it, so look around!")) (hr) (span (b (text "Your progress: ")) (text "{{ percentage|round(method=\"floor\", precision=2) }}%")) - (div ("class" "poll_bar") ("style" "width: {{ percentage }}%")))) + (div ("class" "progress_bar") (div ("class" "poll_bar") ("style" "width: {{ percentage }}%"))))) (div ("class" "card-nest") diff --git a/crates/app/src/public/html/misc/requests.lisp b/crates/app/src/public/html/misc/requests.lisp index 9ba68d2..f49b6f4 100644 --- a/crates/app/src/public/html/misc/requests.lisp +++ b/crates/app/src/public/html/misc/requests.lisp @@ -62,12 +62,15 @@ ("class" "card-nest") (div ("class" "card small flex items-center gap-2") - (text "{{ icon \"user-plus\" }}") + (a + ("href" "/api/v1/auth/user/find/{{ request.id }}") + (text "{{ components::avatar(username=request.id, selector_type=\"id\") }}")) (span (text "{{ text \"requests:label.user_follow_request\" }}"))) (div ("class" "card flex flex-col gap-2") (span + ("class" "flex items-center gap-2") (text "{{ text \"requests:label.user_follow_request_message\" }}")) (div ("class" "card flex flex-wrap w-full secondary gap-2") @@ -92,7 +95,7 @@ (text "{%- endif %} {% endfor %} {% for question in questions %}") (div ("class" "card-nest") - (text "{{ components::question(question=question[0], owner=question[1], profile=user) }}") + (text "{{ components::question(question=question[0], owner=question[1], asking_about=question[2], profile=user) }}") (form ("class" "card flex flex-col gap-2") ("onsubmit" "answer_question_from_form(event, '{{ question[0].id }}')") @@ -129,7 +132,6 @@ (text "{{ text \"auth:action.ip_block\" }}"))) (button - ("class" "primary") (text "{{ text \"requests:label.answer\" }}"))))) (text "{% endfor %}"))) diff --git a/crates/app/src/public/html/mod/file_report.lisp b/crates/app/src/public/html/mod/file_report.lisp index 39891a7..39f7669 100644 --- a/crates/app/src/public/html/mod/file_report.lisp +++ b/crates/app/src/public/html/mod/file_report.lisp @@ -28,7 +28,6 @@ ("required" "") ("minlength" "16"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}"))))) (script diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index 9fb5ebf..5a84aac 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -50,6 +50,18 @@ (span ("class" "notification") (text "{{ profile.request_count }}"))) + (a + ("href" "/services?id={{ profile.id }}") + ("class" "button lowered") + (icon (text "globe")) + (span + (text "Sites"))) + (a + ("href" "/domains?id={{ profile.id }}") + ("class" "button lowered") + (icon (text "globe")) + (span + (text "Domains"))) (button ("class" "red lowered") ("onclick" "delete_account(event)") @@ -72,7 +84,7 @@ const ui = await ns(\"ui\"); const element = document.getElementById(\"mod_options\"); - async function profile_request(do_confirm, path, body) { + globalThis.profile_request = async (do_confirm, path, body) => { if (do_confirm) { if ( !(await trigger(\"atto::confirm\", [ @@ -155,6 +167,33 @@ }); }; + globalThis.update_user_secondary_role = async (new_role) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(`/api/v1/auth/user/{{ profile.id }}/role/2`, { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + role: Number.parseInt(new_role), + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + ui.refresh_container(element, [\"actions\"]); setTimeout(() => { @@ -173,11 +212,21 @@ \"{{ profile.awaiting_purchase }}\", \"checkbox\", ], + [ + [\"is_deactivated\", \"Is deactivated\"], + \"{{ profile.is_deactivated }}\", + \"checkbox\", + ], [ [\"role\", \"Permission level\"], \"{{ profile.permissions }}\", \"input\", ], + [ + [\"secondary_role\", \"Secondary permission level\"], + \"{{ profile.secondary_permissions }}\", + \"input\", + ], ], null, { @@ -191,9 +240,17 @@ awaiting_purchase: value, }); }, + is_deactivated: (value) => { + profile_request(false, \"deactivated\", { + is_deactivated: value, + }); + }, role: (new_role) => { return update_user_role(new_role); }, + secondary_role: (new_role) => { + return update_user_secondary_role(new_role); + }, }, ); }, 100); @@ -226,6 +283,32 @@ ("class" "card lowered flex flex-wrap gap-2") (text "{{ components::user_plate(user=invite[0], show_menu=false) }}"))) (text "{%- endif %}") + (div + ("class" "card-nest w-full") + (div + ("class" "card small flex items-center justify-between gap-2") + (div + ("class" "flex items-center gap-2") + (icon (text "scale")) + (span + (str (text "mod_panel:label.ban_reason"))))) + (form + ("class" "card flex flex-col gap-2") + ("onsubmit" "event.preventDefault(); profile_request(false, 'ban_reason', { reason: event.target.reason.value || '' })") + (div + ("class" "flex flex-col gap-1") + (label + ("for" "title") + (str (text "mod_panel:label.ban_reason"))) + (textarea + ("type" "text") + ("name" "reason") + ("id" "reason") + ("placeholder" "ban reason") + ("minlength" "2") + (text "{{ profile.ban_reason|remove_script_tags|safe }}"))) + (button + (str (text "general:action.save"))))) (div ("class" "card-nest w-full") (div @@ -244,6 +327,24 @@ (div ("class" "card lowered flex flex-col gap-2") ("id" "permission_builder"))) + (div + ("class" "card-nest w-full") + (div + ("class" "card small flex items-center justify-between gap-2") + (div + ("class" "flex items-center gap-2") + (text "{{ icon \"blocks\" }}") + (span + (text "{{ text \"mod_panel:label.permissions_level_builder\" }}"))) + (button + ("class" "small lowered") + ("onclick" "update_user_secondary_role(Number.parseInt(document.getElementById('secondary_role').value))") + (text "{{ icon \"check\" }}") + (span + (text "{{ text \"general:action.save\" }}")))) + (div + ("class" "card lowered flex flex-col gap-2") + ("id" "secondary_permission_builder"))) (script (text "setTimeout(async () => { const get_permissions_html = await trigger( @@ -291,6 +392,33 @@ Number.parseInt(\"{{ profile.permissions }}\"), \"permission_builder\", ); + }, 250); + + setTimeout(async () => { + const get_permissions_html = await trigger( + \"ui::generate_permissions_ui\", + [ + { + // https://trisuaso.github.io/tetratto/tetratto/model/permissions/struct.SecondaryPermission.html + DEFAULT: 1 << 0, + ADMINISTRATOR: 1 << 1, + MANAGE_DOMAINS: 1 << 2, + MANAGE_SERVICES: 1 << 3, + MANAGE_PRODUCTS: 1 << 4, + DEVELOPER_PASS: 1 << 5, + MANAGE_LETTERS: 1 << 6, + }, + \"secondary_role\", + \"add_permission_to_secondary_role\", + \"remove_permission_to_secondary_role\", + ], + ); + + document.getElementById(\"secondary_permission_builder\").innerHTML = + get_permissions_html( + Number.parseInt(\"{{ profile.secondary_permissions }}\"), + \"secondary_permission_builder\", + ); }, 250);"))) (text "{% endblock %}") diff --git a/crates/app/src/public/html/mod/warnings.lisp b/crates/app/src/public/html/mod/warnings.lisp index 35c384e..203fa3e 100644 --- a/crates/app/src/public/html/mod/warnings.lisp +++ b/crates/app/src/public/html/mod/warnings.lisp @@ -37,7 +37,6 @@ ("minlength" "2") ("maxlength" "4096"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")))) (div ("class" "card-nest") diff --git a/crates/app/src/public/html/post/post.lisp b/crates/app/src/public/html/post/post.lisp index 11a5156..8013461 100644 --- a/crates/app/src/public/html/post/post.lisp +++ b/crates/app/src/public/html/post/post.lisp @@ -71,7 +71,6 @@ ("name" "content") ("id" "content") ("placeholder" "content") - ("required" "") ("minlength" "2") ("maxlength" "4096"))) (div @@ -81,7 +80,6 @@ ("class" "flex gap-2") (text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {% endif %}") (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}"))))) (text "{%- endif %}") (div @@ -125,7 +123,6 @@ (text "{{ icon \"settings\" }}") (span (text "{{ text \"communities:action.configure\" }}")))) - (text "{%- endif %}") (div ("class" "flex flex-col gap-2 hidden") ("data-tab" "configure") @@ -201,7 +198,7 @@ \"checkbox\", ], [ - [\"is_nsfw\", \"Hide from public timelines\"], + [\"is_nsfw\", \"Mark as NSFW\"], \"{{ community.context.is_nsfw }}\", \"checkbox\", ], @@ -210,6 +207,11 @@ settings.content_warning, \"textarea\", ], + [ + [\"full_unlist\", \"Unlist from timelines\"], + \"{{ user.settings.auto_full_unlist }}\", + \"checkbox\", + ], [ [\"tags\", \"Tags\"], settings.tags.join(\", \"), @@ -245,6 +247,7 @@ }, }); }, 250);"))) + (text "{%- endif %}") (text "{% if user and user.id == post.owner -%}") (div ("class" "card-nest w-full hidden") @@ -275,7 +278,6 @@ ("class" "flex gap-2") (text "{{ components::emoji_picker(element_id=\"new_content\", render_dialog=false) }}") (button - ("class" "primary") (text "{{ text \"general:action.save\" }}"))))) (script (text "async function edit_post_from_form(e) { diff --git a/crates/app/src/public/html/profile/base.lisp b/crates/app/src/public/html/profile/base.lisp index 7962728..e10dec9 100644 --- a/crates/app/src/public/html/profile/base.lisp +++ b/crates/app/src/public/html/profile/base.lisp @@ -72,19 +72,25 @@ ("style" "color: var(--color-primary);") ("class" "flex items-center") (text "{{ icon \"badge-check\" }}")) - (text "{%- endif %} {% if profile.permissions|has_supporter -%}") + (text "{%- endif %} {% if profile.permissions|has_supporter -%}") (span ("title" "Supporter") ("style" "color: var(--color-primary);") ("class" "flex items-center") (text "{{ icon \"star\" }}")) - (text "{%- endif %} {% if profile.permissions|has_staff_badge -%}") + (text "{%- endif %} {% if profile.secondary_permissions|has_dev_pass -%}") + (span + ("title" "Developer pass") + ("style" "color: var(--color-primary);") + ("class" "flex items-center") + (text "{{ icon \"id-card-lanyard\" }}")) + (text "{%- endif %} {% if profile.permissions|has_staff_badge -%}") (span ("title" "Staff") ("style" "color: var(--color-primary);") ("class" "flex items-center") (text "{{ icon \"shield-user\" }}")) - (text "{%- endif %} {% if profile.permissions|has_banned -%}") + (text "{%- endif %} {% if profile.permissions|has_banned -%}") (span ("title" "Banned") ("style" "color: var(--color-primary);") @@ -101,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 @@ -117,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") @@ -225,7 +233,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("class" "lowered red") - (icon_class (text "chevron-down") (text "dropdown-arrow")) + (icon_class (text "chevron-down") (text "dropdown_arrow")) (str (text "auth:action.block"))) (div ("class" "inner left") @@ -290,7 +298,7 @@ ]); fetch( - \"/api/v1/auth/user/{{ profile.id }}/follow\", + \"/api/v1/auth/user/{{ profile.id }}/follow/toggle\", { method: \"POST\", }, diff --git a/crates/app/src/public/html/profile/blocked.lisp b/crates/app/src/public/html/profile/blocked.lisp index 1a128fa..660be0d 100644 --- a/crates/app/src/public/html/profile/blocked.lisp +++ b/crates/app/src/public/html/profile/blocked.lisp @@ -30,7 +30,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("class" "lowered red") - (icon_class (text "chevron-down") (text "dropdown-arrow")) + (icon_class (text "chevron-down") (text "dropdown_arrow")) (str (text "auth:action.block"))) (div ("class" "inner left") diff --git a/crates/app/src/public/html/profile/private.lisp b/crates/app/src/public/html/profile/private.lisp index c5acd7d..4654298 100644 --- a/crates/app/src/public/html/profile/private.lisp +++ b/crates/app/src/public/html/profile/private.lisp @@ -20,7 +20,11 @@ (div ("class" "card flex flex-col gap-2") (span + ("class" "fade") (text "{{ text \"auth:label.private_profile_message\" }}")) + (span + ("class" "no_p_margin") + (text "{{ profile.settings.private_biography|markdown|safe }}")) (div ("class" "card w-full secondary flex gap-2") (text "{% if user -%} {% if not is_following -%}") @@ -31,6 +35,7 @@ (text "{{ icon \"user-plus\" }}") (span (text "{{ text \"auth:action.request_to_follow\" }}"))) + (text "{% if follow_requested -%}") (button ("onclick" "cancel_follow_user(event)") ("class" "lowered red{% if not follow_requested -%} hidden{%- endif %}") @@ -38,7 +43,7 @@ (text "{{ icon \"user-minus\" }}") (span (text "{{ text \"auth:action.cancel_follow_request\" }}"))) - (text "{% else %}") + (text "{%- endif %} {% else %}") (button ("onclick" "toggle_follow_user(event)") ("class" "lowered red") @@ -53,7 +58,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("class" "lowered red") - (icon_class (text "chevron-down") (text "dropdown-arrow")) + (icon_class (text "chevron-down") (text "dropdown_arrow")) (str (text "auth:action.block"))) (div ("class" "inner left") @@ -76,7 +81,7 @@ (script (text "globalThis.toggle_follow_user = async (e) => { await trigger(\"atto::debounce\", [\"users::follow\"]); - fetch(\"/api/v1/auth/user/{{ profile.id }}/follow\", { + fetch(\"/api/v1/auth/user/{{ profile.id }}/follow/toggle\", { method: \"POST\", }) .then((res) => res.json()) diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index b7f0947..64d3a30 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -35,6 +35,87 @@ (text "{{ macros::profile_settings_nav_options() }}")) ; ... + (div + ("class" "w-full flex flex-col gap-2 hidden") + ("data-tab" "presets") + (div + ("class" "card lowered flex flex-col gap-2") + (a + ("href" "#/account") + ("class" "button secondary") + (icon (text "arrow-left")) + (span + (str (text "general:action.back")))) + (div + ("class" "card-nest") + (div + ("class" "card flex items-center gap-2 small") + (icon (text "cooking-pot")) + (span + (str (text "settings:tab.presets")))) + (div + ("class" "card flex flex-col gap-2 secondary") + (p (text "Not sure where to start? Try some settings presets!")) + (details + ("class" "w-full accordion") + (summary + (icon (text "rss")) + (text "Microblogging")) + + (div + ("class" "inner flex flex-col gap-2") + (p ("class" "fade") (text "Focus on yourself and your communities.")) + (ul ("id" "preset_microblogging_ul")) + (button + ("onclick" "apply_preset(PRESET_MICROBLOGGING)") + (icon (text "settings")) + (str (text "general:action.apply"))))) + + (details + ("class" "w-full accordion") + (summary + (icon (text "message-circle-heart")) + (text "Q&A")) + + (div + ("class" "inner flex flex-col gap-2") + (p ("class" "fade") (text "Just like Neospring!")) + (ul ("id" "preset_questions_ul")) + (button + ("onclick" "apply_preset(PRESET_QUESTIONS)") + (icon (text "settings")) + (str (text "general:action.apply"))))) + + (details + ("class" "w-full accordion") + (summary + (icon (text "key")) + (text "Private")) + + (div + ("class" "inner flex flex-col gap-2") + (p ("class" "fade") (text "This preset allows you to keep your profile and posts hidden to people you aren't following.")) + (ul ("id" "preset_private_ul")) + (button + ("onclick" "apply_preset(PRESET_PRIVATE)") + (icon (text "settings")) + (str (text "general:action.apply"))))) + + (details + ("class" "w-full accordion") + (summary + (icon (text "eye-closed")) + (text "NSFW")) + + (div + ("class" "inner flex flex-col gap-2") + (p ("class" "fade") (text "NSFW content is allowed if it is hidden from main timelines. This preset will help you do that quickly.")) + (ul ("id" "preset_nsfw_ul")) + (button + ("onclick" "apply_preset(PRESET_NSFW)") + (icon (text "settings")) + (str (text "general:action.apply"))))))))) + (div ("class" "w-full flex flex-col gap-2") ("data-tab" "account") @@ -56,6 +137,12 @@ (text "{{ icon \"rss\" }}") (span (text "{{ text \"auth:label.following\" }}"))) + (a + ("data-tab-button" "account/followers") + ("href" "#/account/followers") + (text "{{ icon \"rss\" }}") + (span + (text "{{ text \"auth:label.followers\" }}"))) (a ("data-tab-button" "account/blocks") ("href" "#/account/blocks") @@ -134,15 +221,16 @@ ("selected" "{% if home == '/all/questions' -%}true{% else %}false{%- endif %}") (text "All (questions)")) (text "{% for stack in stacks %}") - (option - ("value" "{\\\"Stack\\\":\\\"{{ stack.id }}\\\"}") - ("selected" "{% if home is ending_with(stack.id|as_str) -%}true{% else %}false{%- endif %}") - (text "{{ stack.name }} (stack)")) + (text "") (text "{% endfor %}")) (span ("class" "fade") - (text "This represents the timeline the home button takes you - to.")))) + (text "This represents the timeline the home button takes you to.")))) (div ("class" "card-nest desktop") ("ui_ident" "notifications") @@ -188,7 +276,6 @@ ("required" "") ("minlength" "2"))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}")))))) @@ -197,30 +284,50 @@ ("ui_ident" "delete_account") (div ("class" "card small flex items-center gap-2 red") - (text "{{ icon \"skull\" }}") - (b - (text "{{ text \"settings:label.delete_account\" }}"))) - (form - ("class" "card flex flex-col gap-2") - ("onsubmit" "delete_account(event)") - (div - ("class" "flex flex-col gap-1") - (label - ("for" "current_password") - (text "{{ text \"settings:label.current_password\" }}")) - (input - ("type" "password") - ("name" "current_password") - ("id" "current_password") - ("placeholder" "current_password") - ("required" "") - ("minlength" "6") - ("autocomplete" "off"))) - (button - ("class" "primary") - (text "{{ icon \"trash\" }}") - (span - (text "{{ text \"general:action.delete\" }}"))))) + (icon (text "skull")) + (b (str (text "communities:label.danger_zone")))) + (div + ("class" "card lowered flex flex-col gap-2") + (details + ("class" "accordion") + (summary + ("class" "flex items-center gap-2") + (icon_class (text "chevron-down") (text "dropdown_arrow")) + (str (text "settings:label.deactivate_account"))) + (div + ("class" "inner flex flex-col gap-2") + (p (text "Deactivating your account will treat it as deleted, but all your data will be recoverable if you change your mind. This option is recommended over a full deletion.")) + (button + ("onclick" "deactivate_account()") + (icon (text "lock")) + (span + (str (text "settings:label.deactivate")))))) + (details + ("class" "accordion") + (summary + ("class" "flex items-center gap-2") + (icon_class (text "chevron-down") (text "dropdown_arrow")) + (str (text "settings:label.delete_account"))) + (form + ("class" "inner flex flex-col gap-2") + ("onsubmit" "delete_account(event)") + (div + ("class" "flex flex-col gap-1") + (label + ("for" "current_password") + (text "{{ text \"settings:label.current_password\" }}")) + (input + ("type" "password") + ("name" "current_password") + ("id" "current_password") + ("placeholder" "current_password") + ("required" "") + ("minlength" "6") + ("autocomplete" "off"))) + (button + (text "{{ icon \"trash\" }}") + (span + (text "{{ text \"general:action.delete\" }}"))))))) (button ("onclick" "save_settings()") ("id" "save_button") @@ -331,7 +438,6 @@ ("minlength" "6") ("autocomplete" "off"))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}"))))))))) @@ -375,7 +481,7 @@ (text "{{ icon \"external-link\" }}") (span (text "{{ text \"requests:action.view_profile\" }}"))))) - (text "{% endfor %}")))) + (text "{% endfor %} {{ components::pagination(page=page, items=following|length, key=\"#/account/following\") }}")))) (script (text "globalThis.toggle_follow_user = async (uid) => { await trigger(\"atto::debounce\", [\"users::follow\"]); @@ -391,6 +497,62 @@ ]); }); };"))) + (div + ("class" "w-full flex flex-col gap-2 hidden") + ("data-tab" "account/followers") + (div + ("class" "card lowered flex flex-col gap-2") + (a + ("href" "#/account") + ("class" "button secondary") + (text "{{ icon \"arrow-left\" }}") + (span + (text "{{ text \"general:action.back\" }}"))) + (div + ("class" "card-nest") + (div + ("class" "card flex items-center gap-2 small") + (text "{{ icon \"rss\" }}") + (span + (text "{{ text \"auth:label.followers\" }}"))) + (div + ("class" "card flex flex-col gap-2") + (text "{% for userfollow in followers %} {% set user = userfollow[1] %}") + (div + ("class" "card secondary flex flex-wrap gap-2 items-center justify-between") + (div + ("class" "flex gap-2") + (text "{{ components::avatar(username=user.username) }} {{ components::full_username(user=user) }}")) + (div + ("class" "flex gap-2") + (button + ("class" "lowered red small") + ("onclick" "force_unfollow_me('{{ user.id }}')") + (text "{{ icon \"user-minus\" }}") + (span + (str (text "stacks:label.remove")))) + (a + ("href" "/@{{ user.username }}") + ("class" "button lowered small") + (text "{{ icon \"external-link\" }}") + (span + (text "{{ text \"requests:action.view_profile\" }}"))))) + (text "{% endfor %} {{ components::pagination(page=page, items=following|length, key=\"#/account/followers\") }}")))) + (script + (text "globalThis.force_unfollow_me = async (uid) => { + await trigger(\"atto::debounce\", [\"users::follow\"]); + + fetch(`/api/v1/auth/user/${uid}/force_unfollow_me`, { + method: \"POST\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + };"))) (div ("class" "w-full flex flex-col gap-2 hidden") ("data-tab" "account/blocks") @@ -492,32 +654,51 @@ (div ("class" "card flex flex-col gap-2 secondary") (text "{{ components::supporter_ad(body=\"Become a supporter to upload images directly to posts!\") }} {% for upload in uploads %}") - (div - ("class" "card flex flex-wrap gap-2 items-center justify-between") + (details + ("class" "accordion w-full") + (summary + ("class" "card flex flex-wrap gap-2 items-center justify-between") + (div + ("class" "flex gap-2 items-center") + (icon_class (text "chevron-down") (text "dropdown_arrow")) + (b + (span + ("class" "date") + (text "{{ upload.created }}")) + (text " ({{ upload.what }})"))) + (div + ("class" "flex gap-2") + (button + ("class" "raised small") + ("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])") + (text "{{ icon \"view\" }}") + (span + (text "{{ text \"general:action.view\" }}"))) + (button + ("class" "raised small red") + ("onclick" "remove_upload('{{ upload.id }}')") + (text "{{ icon \"x\" }}") + (span + (text "{{ text \"stacks:label.remove\" }}"))))) + (div - ("class" "flex gap-2 items-center") - ("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])") - ("style" "cursor: pointer") - (text "{{ icon \"file-image\" }}") - (b - (span - ("class" "date") - (text "{{ upload.created }}")) - (text "({{ upload.what }})"))) - (div - ("class" "flex gap-2") - (button - ("class" "lowered small") - ("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])") - (text "{{ icon \"view\" }}") - (span - (text "{{ text \"general:action.view\" }}"))) - (button - ("class" "lowered small red") - ("onclick" "remove_upload('{{ upload.id }}')") - (text "{{ icon \"x\" }}") - (span - (text "{{ text \"stacks:label.remove\" }}"))))) + ("class" "inner flex flex-col gap-2") + (form + ("class" "card lowered flex flex-col gap-2") + ("onsubmit" "update_upload_alt(event, '{{ upload.id }}')") + (div + ("class" "flex flex-col gap-1") + (label ("for" "alt_{{ upload.id }}") (b (str (text "settings:label.alt_text")))) + (textarea + ("id" "alt_{{ upload.id }}") + ("name" "alt") + ("class" "w-full") + ("placeholder" "Alternative text") + (text "{{ upload.alt|safe }}"))) + + (button + (icon (text "check")) + (str (text "general:action.save")))))) (text "{% endfor %} {{ components::pagination(page=page, items=uploads|length, key=\"#/account/uploads\") }}") (script (text "globalThis.remove_upload = async (id) => { @@ -539,6 +720,26 @@ res.message, ]); }); + }; + + globalThis.update_upload_alt = async (e, id) => { + e.preventDefault(); + fetch(`/api/v1/uploads/${id}/alt`, { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + alt: e.target.alt.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); };")))))) (text "{% if config.security.enable_invite_codes -%}") @@ -643,6 +844,29 @@ (div ("class" "card flex flex-col gap-2 secondary") (text "{% if config.stripe -%}") + (text "{% if has_developer_pass or is_supporter -%}") + (div + ("class" "card-nest") + ("ui_ident" "supporter_card") + (div + ("class" "card small flex items-center gap-2") + (icon (text "credit-card")) + (b + (text "Manage billing"))) + (div + ("class" "card flex flex-col gap-2") + (p + (text "You currently have a subscription! You can manage your billing information below. ") + (b + (text "Please use your email address you supplied when paying to log into the billing portal.")) + (text " You can manage all of your active subscriptions through this page.")) + (a + ("href" "{{ config.stripe.billing_portal_url }}") + ("class" "button lowered") + ("target" "_blank") + (text "Manage billing")))) + (text "{%- endif %}") + (div ("class" "card-nest") ("ui_ident" "supporter_card") @@ -652,28 +876,33 @@ (b (text "Supporter status"))) (div - ("class" "card flex flex-col gap-2") + ("class" "card flex flex-col gap-2 no_p_margin") (text "{% if is_supporter -%}") (p (text "You ") - (b - (text "are ")) - (text "a supporter! Thank you for all - that you do. You can manage your billing - information below.") - (b - (text "Please use your email address you supplied - when paying to login to the billing - portal."))) - (a - ("href" "{{ config.stripe.billing_portal_url }}") - ("class" "button lowered") - ("target" "_blank") - (text "Manage billing")) + (b (text "are ")) + (text "a supporter! Thank you for all that you do.")) (text "{% else %}") (text "{{ components::become_supporter_button() }}") (text "{%- endif %}"))) + (div + ("class" "card-nest") + ("ui_ident" "supporter_card") + (div + ("class" "card small flex items-center gap-2") + (icon (text "id-card-lanyard")) + (b + (text "Developer pass status"))) + (div + ("class" "card flex flex-col gap-2 no_p_margin") + (text "{% if has_developer_pass -%}") + (p + (text "You currently have a developer pass!")) + (text "{% else %}") + (text "{{ components::get_developer_pass_button() }}") + (text "{%- endif %}"))) + (text "{% if user.was_purchased and user.invite_code == 0 -%}") (form ("class" "card w-full lowered flex flex-col gap-2") @@ -725,7 +954,6 @@ ("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif") ("class" "w-content")) (button - ("class" "primary") (text "{{ icon \"check\" }}"))) (span ("class" "fade") @@ -753,7 +981,6 @@ ("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif") ("class" "w-content")) (button - ("class" "primary") (text "{{ icon \"check\" }}"))) (span ("class" "fade") @@ -779,7 +1006,23 @@ (text "Responses"))) (span ("class" "fade") - (text "This represents the timeline that is shown on your profile by default."))))) + (text "This represents the timeline that is shown on your profile by default.")))) + (div + ("class" "flex flex-col gap-2") + ("ui_ident" "show_presets") + (hr ("class" "margin")) + (div + ("class" "card-nest") + (div + ("class" "card small") + (b + (text "Not sure what to do?"))) + (div + ("class" "card no_p_margin") + (p + (text "Quickly set up your account with ") + (a ("href" "/settings#/presets") (text "settings presets")) + (text "!")))))) (button ("onclick" "save_settings()") ("id" "save_button") @@ -855,7 +1098,6 @@ ("class" "card w-full flex flex-wrap gap-2") ("ui_ident" "import_export") (button - ("class" "primary") ("onclick" "import_theme_settings()") (text "{{ icon \"upload\" }}") (span @@ -1391,6 +1633,87 @@ }); } + globalThis.deactivate_account = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you want to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/auth/user/{{ profile.id }}/deactivate\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ is_deactivated: true }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + + // presets + globalThis.apply_preset = async (preset) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this? This will change all listed settings to their listed values.\", + ])) + ) { + return; + } + + for (const x of preset) { + window.SETTING_SET_FUNCTIONS[0](x[0], x[1]) + } + + save_settings(); + } + + globalThis.render_preset_lis = (preset, id) => { + for (const x of preset) { + document.getElementById(id).innerHTML += `
  • ${x[0]}: ${x[1]}
  • `; + } + } + + globalThis.PRESET_MICROBLOGGING = [ + [\"default_timeline\", \"All\"], + [\"all_timeline_hide_answers\", true], + ]; + + globalThis.PRESET_QUESTIONS = [ + [\"default_timeline\", \"Following\"], + [\"auto_full_unlist\", true], + [\"enable_questions\", true], + [\"allow_anonymous_questions\", true], + [\"enable_drawings\", true], + [\"hide_extra_post_tabs\", true], + ]; + + globalThis.PRESET_PRIVATE = [ + [\"private_profile\", true], + [\"private_last_seen\", true], + [\"private_communities\", true], + [\"private_chats\", true], + [\"require_account\", true], + ]; + + globalThis.PRESET_NSFW = [ + [\"auto_unlist\", true], + [\"show_nsfw\", true], + ]; + + render_preset_lis(PRESET_MICROBLOGGING, \"preset_microblogging_ul\"); + render_preset_lis(PRESET_QUESTIONS, \"preset_questions_ul\"); + render_preset_lis(PRESET_PRIVATE, \"preset_private_ul\"); + render_preset_lis(PRESET_NSFW, \"preset_nsfw_ul\"); + + // ... const account_settings = document.getElementById(\"account_settings\"); const profile_settings = @@ -1410,6 +1733,7 @@ \"change_avatar\", \"change_banner\", \"default_profile_page\", + \"show_presets\", ]); ui.refresh_container(theme_settings, [ \"supporter_ad\", @@ -1432,6 +1756,15 @@ settings.biography, \"textarea\", ], + [ + [\"private_biography\", \"Private biography\"], + settings.private_biography, + \"textarea\", + { + embed_html: + 'This biography is only shown to users you are not following while your account is private.', + }, + ], [[\"status\", \"Status\"], settings.status, \"textarea\"], [ [\"warning\", \"Profile warning\"], @@ -1525,6 +1858,16 @@ \"{{ profile.settings.auto_unlist }}\", \"checkbox\", ], + [ + [\"auto_full_unlist\", \"Only publish my posts to my profile\"], + \"{{ profile.settings.auto_full_unlist }}\", + \"checkbox\", + ], + [ + [], + \"Your posts will still be visible in the \\\"Following\\\" timeline for users following you.\", + \"text\", + ], [ [\"all_timeline_hide_answers\", 'Hide posts answering questions from the \"All\" timeline'], \"{{ profile.settings.all_timeline_hide_answers }}\", @@ -1538,6 +1881,22 @@ \"{{ profile.settings.hide_associated_blocked_users }}\", \"checkbox\", ], + [ + [ + \"hide_from_social_lists\", + \"Hide my profile from social lists (followers/following)\", + ], + \"{{ 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/html/root.lisp b/crates/app/src/public/html/root.lisp index 6730dd8..5cf7da9 100644 --- a/crates/app/src/public/html/root.lisp +++ b/crates/app/src/public/html/root.lisp @@ -70,9 +70,13 @@ (str (text "general:label.account_banned"))) (div - ("class" "card") - (str (text "general:label.account_banned_body")))))) - + ("class" "card flex flex-col gap-2 no_p_margin") + (str (text "general:label.account_banned_body")) + (hr) + (span ("class" "fade") (text "The following reason was provided by a moderator:")) + (div + ("class" "card lowered w-full") + (text "{{ user.ban_reason|markdown|safe }}")))))) ; if we aren't banned, just show the page body (text "{% elif user and user.awaiting_purchase %}") ; account waiting for payment message @@ -137,6 +141,55 @@ } }); }")))))) + (text "{% elif user.is_deactivated -%}") + ; account deactivated message + (article + (main + (div + ("class" "card-nest") + (div + ("class" "card small flex items-center gap-2 red") + (icon (text "frown")) + (str (text "settings:label.account_deactivated"))) + + (div + ("class" "card flex flex-col gap-2 no_p_margin") + (p (text "You have deactivated your account. You can undo this with the button below if you'd like.")) + (hr) + (button + ("onclick" "activate_account()") + (icon (text "lock-open")) + (str (text "settings:label.activate_account"))))))) + + (script + (text "globalThis.activate_account = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you want to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/auth/user/{{ user.id }}/deactivate\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ is_deactivated: false }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.reload(); + } + }); + };")) (text "{% else %}") ; page body (text "{% block body %}{% endblock %}") diff --git a/crates/app/src/public/html/stacks/list.lisp b/crates/app/src/public/html/stacks/list.lisp index 50246ef..6381881 100644 --- a/crates/app/src/public/html/stacks/list.lisp +++ b/crates/app/src/public/html/stacks/list.lisp @@ -29,7 +29,6 @@ ("minlength" "2") ("maxlength" "32"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")))) (text "{%- endif %}") (div diff --git a/crates/app/src/public/html/stacks/manage.lisp b/crates/app/src/public/html/stacks/manage.lisp index 450c027..ecd892c 100644 --- a/crates/app/src/public/html/stacks/manage.lisp +++ b/crates/app/src/public/html/stacks/manage.lisp @@ -114,7 +114,6 @@ ("required" "") ("minlength" "2"))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}")))))) diff --git a/crates/app/src/public/html/timelines/home.lisp b/crates/app/src/public/html/timelines/home.lisp index 5a5658b..65b3a60 100644 --- a/crates/app/src/public/html/timelines/home.lisp +++ b/crates/app/src/public/html/timelines/home.lisp @@ -24,6 +24,18 @@ (a ("href" "/communities/search") (text "searching for a community to join!"))))) + (div + ("class" "card-nest") + (div + ("class" "card small") + (b + (text "Need help getting started?"))) + (div + ("class" "card no_p_margin") + (p + (text "Quickly set up your account with ") + (a ("href" "/settings#/presets") (text "settings presets")) + (text "!")))) (text "{% else %}") (div ("class" "card w-full flex flex-col gap-2") diff --git a/crates/app/src/public/images/default-avatar.svg b/crates/app/src/public/images/default-avatar.svg index 00fa7ab..2f92a92 100644 --- a/crates/app/src/public/images/default-avatar.svg +++ b/crates/app/src/public/images/default-avatar.svg @@ -6,4 +6,11 @@ xmlns="http://www.w3.org/2000/svg" > + + + diff --git a/crates/app/src/public/images/vendor/stripe.svg b/crates/app/src/public/images/vendor/stripe.svg new file mode 100644 index 0000000..415271d --- /dev/null +++ b/crates/app/src/public/images/vendor/stripe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/app/src/public/js/app_sdk.js b/crates/app/src/public/js/app_sdk.js new file mode 100644 index 0000000..7a6c834 --- /dev/null +++ b/crates/app/src/public/js/app_sdk.js @@ -0,0 +1,338 @@ +import { + JSONParse as json_parse, + JSONStringify as json_stringify, +} from "https://unpkg.com/json-with-bigint@3.4.4/json-with-bigint.js"; + +/// PKCE key generation. +export const PKCE = { + /// Create a verifier for [`PKCE::challenge`]. + verifier: async (length) => { + let text = ""; + const possible = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + for (let i = 0; i < length; i++) { + text += possible.charAt( + Math.floor(Math.random() * possible.length), + ); + } + + return text; + }, + /// Create the challenge needed to request a user token. + challenge: async (verifier) => { + const data = new TextEncoder().encode(verifier); + const digest = await window.crypto.subtle.digest("SHA-256", data); + return btoa( + String.fromCharCode.apply(null, [...new Uint8Array(digest)]), + ) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + }, +}; + +export default function tetratto({ + host = "https://tetratto.com", + api_key = null, + app_id = 0n, + user_token = null, + user_verifier = null, + user_id = 0n, +}) { + const GRANT_URL = `${host}/auth/connections_link/app/${app_id}`; + + function api_promise(res) { + return new Promise((resolve, reject) => { + if (res.ok) { + resolve(res.payload); + } else { + reject(res.message); + } + }); + } + + // app data + async function app() { + if (!api_key) { + throw Error("No API key provided."); + } + + return api_promise( + json_parse( + await ( + await fetch(`${host}/api/v1/app_data/app`, { + method: "GET", + headers: { + "Atto-Secret-Key": api_key, + }, + }) + ).text(), + ), + ); + } + + 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."); + } + + return api_promise( + json_parse( + await ( + await fetch(`${host}/api/v1/app_data/query`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Atto-Secret-Key": api_key, + }, + body: json_stringify(body), + }) + ).text(), + ), + ); + } + + async function insert(key, value) { + if (!api_key) { + throw Error("No API key provided."); + } + + return api_promise( + json_parse( + await ( + await fetch(`${host}/api/v1/app_data`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Atto-Secret-Key": api_key, + }, + body: json_stringify({ + key, + value, + }), + }) + ).text(), + ), + ); + } + + async function update(id, value) { + if (!api_key) { + throw Error("No API key provided."); + } + + return api_promise( + json_parse( + await ( + await fetch(`${host}/api/v1/app_data/${id}/value`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Atto-Secret-Key": api_key, + }, + body: json_stringify({ + value, + }), + }) + ).text(), + ), + ); + } + + async function rename(id, key) { + if (!api_key) { + throw Error("No API key provided."); + } + + return api_promise( + json_parse( + await ( + await fetch(`${host}/api/v1/app_data/${id}/key`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Atto-Secret-Key": api_key, + }, + body: json_stringify({ + key, + }), + }) + ).text(), + ), + ); + } + + async function remove(id) { + if (!api_key) { + throw Error("No API key provided."); + } + + return api_promise( + json_parse( + await ( + await fetch(`${host}/api/v1/app_data/${id}`, { + method: "DELETE", + headers: { + "Atto-Secret-Key": api_key, + }, + }) + ).text(), + ), + ); + } + + async function remove_query(body) { + if (!api_key) { + throw Error("No API key provided."); + } + + return api_promise( + json_parse( + await ( + await fetch(`${host}/api/v1/app_data/query`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + "Atto-Secret-Key": api_key, + }, + body: json_stringify(body), + }) + ).text(), + ), + ); + } + + // user connection + /// Extract the verifier, token, and user ID from the URL. + function extract_verifier_token_uid() { + const search = new URLSearchParams(window.location.search); + return [ + search.get("verifier"), + search.get("token"), + BigInt(search.get("uid")), + ]; + } + + /// Accept a connection grant and store it in localStorage. + function localstorage_accept_connection() { + const [verifier, token, uid] = extract_verifier_token_uid(); + window.localStorage.setItem("atto:grant.verifier", verifier); + window.localStorage.setItem("atto:grant.token", token); + window.localStorage.setItem("atto:grant.user_id", uid); + } + + async function refresh_token() { + return api_promise( + json_parse( + await ( + await fetch( + `${host}/api/v1/auth/user/${user_id}/grants/${app_id}/refresh`, + { + method, + headers: { + "Content-Type": "application/json", + "X-Cookie": `Atto-Grant=${user_token}`, + }, + body: json_stringify({ + verifier: user_verifier, + }), + }, + ) + ).text(), + ), + ); + } + + async function request({ + route, + method = "POST", + content_type = "application/json", + body = {}, + }) { + if (!user_token) { + throw Error("No user token provided."); + } + + return api_promise( + json_parse( + await ( + await fetch(`${host}/api/v1/${route}`, { + method, + headers: { + "Content-Type": + method === "GET" ? null : content_type, + "X-Cookie": `Atto-Grant=${user_token}`, + }, + body: + method === "GET" + ? null + : content_type === "application/json" + ? json_stringify(body) + : body, + }) + ).text(), + ), + ); + } + + // ... + return { + user_id, + user_token, + user_verifier, + app_id, + api_key, + // app data + app, + check_ip, + query, + insert, + update, + rename, + remove, + remove_query, + // user connection + GRANT_URL, + extract_verifier_token_uid, + refresh_token, + localstorage_accept_connection, + request, + }; +} + +export function from_localstorage({ + host = "https://tetratto.com", + app_id = 0n, +}) { + const user_verifier = window.localStorage.getItem("atto:grant.verifier"); + const user_token = window.localStorage.getItem("atto:grant.token"); + const user_id = window.localStorage.getItem("atto:grant.user_id"); + + return tetratto({ + host, + app_id, + user_verifier, + user_id, + user_token, + }); +} diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 1b2a4db..9c556cf 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -156,9 +156,7 @@ media_theme_pref(); .replaceAll(" year ago", "y"); } - element.innerText = - pretty === undefined ? then.toLocaleDateString() : pretty; - + element.innerText = !pretty ? then.toLocaleDateString() : pretty; element.style.display = "inline-block"; } }); @@ -198,9 +196,7 @@ media_theme_pref(); .replaceAll(" year ago", "y") .replaceAll("Yesterday", "1d") || ""; - element.innerText = - pretty === undefined ? then.toLocaleDateString() : pretty; - + element.innerText = !pretty ? then.toLocaleDateString() : pretty; element.style.display = "inline-block"; } }); @@ -419,33 +415,35 @@ media_theme_pref(); }); self.define("hooks::long_text.init", (_) => { - for (const element of Array.from( - document.querySelectorAll("[hook=long]") || [], - )) { - const is_long = element.innerText.length >= 64 * 8; + setTimeout(() => { + for (const element of Array.from( + document.querySelectorAll("[hook=long]") || [], + )) { + const is_long = element.innerText.length >= 64 * 8; - if (!is_long) { - continue; + if (!is_long) { + continue; + } + + element.classList.add("hook:long.hidden_text"); + + if (element.getAttribute("hook-arg") === "lowered") { + element.classList.add("hook:long.hidden_text+lowered"); + } + + const html = element.innerHTML; + const short = html.slice(0, 64 * 8); + element.innerHTML = `${short}...`; + + // event + const listener = () => { + self["hooks::long"](element, html); + element.removeEventListener("click", listener); + }; + + element.addEventListener("click", listener); } - - element.classList.add("hook:long.hidden_text"); - - if (element.getAttribute("hook-arg") === "lowered") { - element.classList.add("hook:long.hidden_text+lowered"); - } - - const html = element.innerHTML; - const short = html.slice(0, 64 * 8); - element.innerHTML = `${short}...`; - - // event - const listener = () => { - self["hooks::long"](element, html); - element.removeEventListener("click", listener); - }; - - element.addEventListener("click", listener); - } + }, 150); }); self.define("hooks::alt", (_) => { @@ -691,7 +689,7 @@ media_theme_pref(); }); self.define("hooks::check_message_reactions", async ({ $ }) => { - const observer = $.offload_work_to_client_when_in_view( + const observer = await $.offload_work_to_client_when_in_view( async (element) => { const reactions = await ( await fetch( @@ -1069,7 +1067,13 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} // permissions ui self.define( "generate_permissions_ui", - (_, permissions, field_id = "role") => { + ( + _, + permissions, + field_id = "role", + add_name = "add_permission_to_role", + remove_name = "remove_permission_from_role", + ) => { function all_matching_permissions(role) { const matching = []; const not_matching = []; @@ -1099,7 +1103,7 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} function get_permissions_html(role, id) { const [matching, not_matching] = all_matching_permissions(role); - globalThis.remove_permission_from_role = (permission) => { + globalThis[remove_name] = (permission) => { matching.splice(matching.indexOf(permission), 1); not_matching.push(permission); @@ -1107,7 +1111,7 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} get_permissions_html(rebuild_role(matching), id); }; - globalThis.add_permission_to_role = (permission) => { + globalThis[add_name] = (permission) => { not_matching.splice(not_matching.indexOf(permission), 1); matching.push(permission); @@ -1120,14 +1124,14 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} for (const match of matching) { permissions_html += `
    ${match} ${permissions[match]} - +
    `; } for (const match of not_matching) { permissions_html += `
    ${match} ${permissions[match]} - +
    `; } @@ -1139,8 +1143,15 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} ); // lightbox - self.define("lightbox_open", (_, src) => { + self.define("lightbox_open", async (_, src) => { document.getElementById("lightbox_img").src = src; + + const data = await (await fetch(`${src}/data`)).json(); + document + .getElementById("lightbox_img") + .setAttribute("alt", data.payload.alt); + document.getElementById("lightbox_img").title = data.payload.alt; + document.getElementById("lightbox_img_a").href = src; document.getElementById("lightbox").classList.remove("hidden"); }); diff --git a/crates/app/src/public/js/layout_editor.js b/crates/app/src/public/js/layout_editor.js deleted file mode 100644 index 13d3d8b..0000000 --- a/crates/app/src/public/js/layout_editor.js +++ /dev/null @@ -1,762 +0,0 @@ -/// Copy all the fields from one object to another. -function copy_fields(from, to) { - for (const field of Object.entries(from)) { - to[field[0]] = field[1]; - } - - return to; -} - -/// Simple template components. -const COMPONENT_TEMPLATES = { - EMPTY_COMPONENT: { component: "empty", options: {}, children: [] }, - FLEX_DEFAULT: { - component: "flex", - options: { - direction: "row", - gap: "2", - }, - children: [], - }, - FLEX_SIMPLE_ROW: { - component: "flex", - options: { - direction: "row", - gap: "2", - width: "full", - }, - children: [], - }, - FLEX_SIMPLE_COL: { - component: "flex", - options: { - direction: "col", - gap: "2", - width: "full", - }, - children: [], - }, - FLEX_MOBILE_COL: { - component: "flex", - options: { - collapse: "yes", - gap: "2", - width: "full", - }, - children: [], - }, - MARKDOWN_DEFAULT: { - component: "markdown", - options: { - text: "Hello, world!", - }, - }, - MARKDOWN_CARD: { - component: "markdown", - options: { - class: "card w-full", - text: "Hello, world!", - }, - }, -}; - -/// All available components with their label and JSON representation. -const COMPONENTS = [ - [ - "Markdown block", - COMPONENT_TEMPLATES.MARKDOWN_DEFAULT, - [["Card", COMPONENT_TEMPLATES.MARKDOWN_CARD]], - ], - [ - "Flex container", - COMPONENT_TEMPLATES.FLEX_DEFAULT, - [ - ["Simple rows", COMPONENT_TEMPLATES.FLEX_SIMPLE_ROW], - ["Simple columns", COMPONENT_TEMPLATES.FLEX_SIMPLE_COL], - ["Mobile columns", COMPONENT_TEMPLATES.FLEX_MOBILE_COL], - ], - ], - [ - "Profile tabs", - { - component: "tabs", - }, - ], - [ - "Profile feeds", - { - component: "feed", - }, - ], - [ - "Profile banner", - { - component: "banner", - }, - ], - [ - "Question box", - { - component: "ask", - }, - ], - [ - "Name & avatar", - { - component: "name", - }, - ], - [ - "About section", - { - component: "about", - }, - ], - [ - "Action buttons", - { - component: "actions", - }, - ], - [ - "CSS stylesheet", - { - component: "style", - options: { - data: "", - }, - }, - ], -]; - -// preload icons -trigger("app::icon", ["shapes"]); -trigger("app::icon", ["type"]); -trigger("app::icon", ["plus"]); -trigger("app::icon", ["move-up"]); -trigger("app::icon", ["move-down"]); -trigger("app::icon", ["trash"]); -trigger("app::icon", ["arrow-left"]); -trigger("app::icon", ["x"]); - -/// The location of an element as represented by array indexes. -class ElementPointer { - position = []; - - constructor(element) { - if (element) { - const pos = []; - - let target = element; - while (target.parentElement) { - const parent = target.parentElement; - - // push index - pos.push(Array.from(parent.children).indexOf(target) || 0); - - // update target - if (parent.id === "editor") { - break; - } - - target = parent; - } - - this.position = pos.reverse(); // indexes are added in reverse order because of how we traverse - } else { - this.position = []; - } - } - - get() { - return this.position; - } - - resolve(json, minus = 0) { - let out = json; - - if (this.position.length === 1) { - // this is the first element (this.position === [0]) - return out; - } - - const pos = this.position.slice(1, this.position.length); // the first one refers to the root element - - for (let i = 0; i < minus; i++) { - pos.pop(); - } - - for (const idx of pos) { - const child = ((out || { children: [] }).children || [])[idx]; - - if (!child) { - break; - } - - out = child; - } - - return out; - } -} - -/// The layout editor controller. -class LayoutEditor { - element; - json; - tree = ""; - current = { component: "empty" }; - pointer = new ElementPointer(); - - /// Create a new [`LayoutEditor`]. - constructor(element, json) { - this.element = element; - this.json = json; - - if (this.json.json) { - delete this.json.json; - } - - element.addEventListener("click", (e) => this.click(e, this)); - element.addEventListener("mouseover", (e) => { - e.stopImmediatePropagation(); - const ptr = new ElementPointer(e.target); - - if (document.getElementById("position")) { - document.getElementById( - "position", - ).parentElement.style.display = "flex"; - - document.getElementById("position").innerText = ptr - .get() - .join("."); - } - }); - - this.render(); - } - - /// Render layout. - render() { - fetch("/api/v0/auth/render_layout", { - method: "POST", - body: JSON.stringify({ - layout: this.json, - }), - headers: { - "Content-Type": "application/json", - }, - }) - .then((r) => r.json()) - .then((r) => { - this.element.innerHTML = r.block; - this.tree = r.tree; - - if (this.json.component !== "empty") { - // remove all "empty" components (if the root component isn't an empty) - for (const element of document.querySelectorAll( - '[data-component-name="empty"]', - )) { - element.remove(); - } - } - }); - } - - /// Editor clicked. - click(e, self) { - e.stopImmediatePropagation(); - trigger("app::hooks::dropdown.close"); - - const ptr = new ElementPointer(e.target); - self.current = ptr.resolve(self.json); - self.pointer = ptr; - - if (document.getElementById("current_position")) { - document.getElementById( - "current_position", - ).parentElement.style.display = "flex"; - - document.getElementById("current_position").innerText = ptr - .get() - .join("."); - } - - for (const element of document.querySelectorAll( - ".layout_editor_block.active", - )) { - element.classList.remove("active"); - } - - e.target.classList.add("active"); - self.screen("element"); - } - - /// Open sidebar. - open() { - document.getElementById("editor_sidebar").classList.add("open"); - document.getElementById("editor").style.transform = "scale(0.8)"; - } - - /// Close sidebar. - close() { - document.getElementById("editor_sidebar").style.animation = - "0.2s ease-in-out forwards to_left"; - - setTimeout(() => { - document.getElementById("editor_sidebar").classList.remove("open"); - document.getElementById("editor_sidebar").style.animation = - "0.2s ease-in-out forwards from_right"; - }, 250); - - document.getElementById("editor").style.transform = "scale(1)"; - } - - /// Render editor dialog. - screen(page = "element", data = {}) { - this.current.component = this.current.component.toLowerCase(); - - const sidebar = document.getElementById("editor_sidebar"); - sidebar.innerHTML = ""; - - // render page - if ( - page === "add" || - (page === "element" && this.current.component === "empty") - ) { - // add element - sidebar.appendChild( - (() => { - const heading = document.createElement("h3"); - heading.innerText = data.add_title || "Add component"; - return heading; - })(), - ); - - sidebar.appendChild(document.createElement("hr")); - - const container = document.createElement("div"); - container.className = "flex w-full gap-2 flex-wrap"; - - for (const component of data.components || COMPONENTS) { - container.appendChild( - (() => { - const button = document.createElement("button"); - button.classList.add("secondary"); - - trigger("app::icon", [ - data.icon || "shapes", - "icon", - ]).then((icon) => { - button.prepend(icon); - }); - - button.appendChild( - (() => { - const span = document.createElement("span"); - span.innerText = `${component[0]}${component[2] ? ` (${component[2].length + 1})` : ""}`; - return span; - })(), - ); - - button.addEventListener("click", () => { - if (component[2]) { - // render presets - return this.screen(page, { - back: ["add", {}], - add_title: "Select preset", - components: [ - ["Default", component[1]], - ...component[2], - ], - icon: "type", - }); - } - - // no presets - if ( - page === "element" && - this.current.component === "empty" - ) { - // replace with component - copy_fields(component[1], this.current); - } else { - // add component to children - this.current.children.push( - structuredClone(component[1]), - ); - } - - this.render(); - this.close(); - }); - - return button; - })(), - ); - } - - sidebar.appendChild(container); - } else if (page === "element") { - // edit element - const name = document.createElement("div"); - name.className = "flex flex-col gap-2"; - - name.appendChild( - (() => { - const heading = document.createElement("h3"); - heading.innerText = `Edit ${this.current.component}`; - return heading; - })(), - ); - - name.appendChild( - (() => { - const pos = document.createElement("div"); - pos.className = "notification w-content"; - pos.innerText = this.pointer.get().join("."); - return pos; - })(), - ); - - sidebar.appendChild(name); - sidebar.appendChild(document.createElement("hr")); - - // options - const options = document.createElement("div"); - options.className = "card flex flex-col gap-2 w-full"; - - const add_option = ( - label_text, - name, - valid = [], - input_element = "input", - ) => { - const card = document.createElement("details"); - card.className = "w-full"; - - const summary = document.createElement("summary"); - summary.className = "w-full"; - - const label = document.createElement("label"); - label.setAttribute("for", name); - label.className = "w-full"; - label.innerText = label_text; - label.style.cursor = "pointer"; - - label.addEventListener("click", () => { - // bubble to summary click - summary.click(); - }); - - const input_box = document.createElement("div"); - input_box.style.paddingLeft = "1rem"; - input_box.style.borderLeft = - "solid 2px var(--color-super-lowered)"; - - const input = document.createElement(input_element); - input.id = name; - input.setAttribute("name", name); - input.setAttribute("type", "text"); - - if (input_element === "input") { - input.setAttribute( - "value", - // biome-ignore lint/complexity/useOptionalChain: that's an unsafe change which would possibly break code - (this.current.options || {})[name] || "", - ); - } else { - // biome-ignore lint/complexity/useOptionalChain: that's an unsafe change which would possibly break code - input.innerHTML = (this.current.options || {})[name] || ""; - } - - // biome-ignore lint/complexity/useOptionalChain: that's an unsafe change which would possibly break code - if ((this.current.options || {})[name]) { - // open details if a value is set - card.setAttribute("open", ""); - } - - input.addEventListener("change", (e) => { - if ( - valid.length > 0 && - !valid.includes(e.target.value) && - e.target.value.length > 0 // anything can be set to empty - ) { - alert(`Must be one of: ${JSON.stringify(valid)}`); - return; - } - - if (!this.current.options) { - this.current.options = {}; - } - - this.current.options[name] = - e.target.value === "no" ? "" : e.target.value; - }); - - summary.appendChild(label); - card.appendChild(summary); - input_box.appendChild(input); - card.appendChild(input_box); - options.appendChild(card); - }; - - sidebar.appendChild(options); - - if (this.current.component === "flex") { - add_option("Gap", "gap", ["1", "2", "3", "4"]); - add_option("Direction", "direction", ["row", "col"]); - add_option("Do collapse", "collapse", ["yes", "no"]); - add_option("Width", "width", ["full", "content"]); - add_option("Class name", "class"); - add_option("Unique ID", "id"); - add_option("Style", "style", [], "textarea"); - } else if (this.current.component === "markdown") { - add_option("Content", "text", [], "textarea"); - add_option("Class name", "class"); - } else if (this.current.component === "divider") { - add_option("Class name", "class"); - } else if (this.current.component === "style") { - add_option("Style data", "data", [], "textarea"); - } else { - options.remove(); - } - - // action buttons - const buttons = document.createElement("div"); - buttons.className = "card w-full flex flex-wrap gap-2"; - - if (this.current.component === "flex") { - buttons.appendChild( - (() => { - const button = document.createElement("button"); - - trigger("app::icon", ["plus", "icon"]).then((icon) => { - button.prepend(icon); - }); - - button.appendChild( - (() => { - const span = document.createElement("span"); - span.innerText = "Add child"; - return span; - })(), - ); - - button.addEventListener("click", () => { - this.screen("add"); - }); - - return button; - })(), - ); - } - - buttons.appendChild( - (() => { - const button = document.createElement("button"); - - trigger("app::icon", ["move-up", "icon"]).then((icon) => { - button.prepend(icon); - }); - - button.appendChild( - (() => { - const span = document.createElement("span"); - span.innerText = "Move up"; - return span; - })(), - ); - - button.addEventListener("click", () => { - const idx = this.pointer.get().pop(); - const parent_ref = this.pointer.resolve( - this.json, - ).children; - - if (parent_ref[idx - 1] === undefined) { - alert("No space to move element."); - return; - } - - const clone = JSON.parse(JSON.stringify(this.current)); - const other_clone = JSON.parse( - JSON.stringify(parent_ref[idx - 1]), - ); - - copy_fields(clone, parent_ref[idx - 1]); // move here to here - copy_fields(other_clone, parent_ref[idx]); // move there to here - - this.close(); - this.render(); - }); - - return button; - })(), - ); - - buttons.appendChild( - (() => { - const button = document.createElement("button"); - - trigger("app::icon", ["move-down", "icon"]).then((icon) => { - button.prepend(icon); - }); - - button.appendChild( - (() => { - const span = document.createElement("span"); - span.innerText = "Move down"; - return span; - })(), - ); - - button.addEventListener("click", () => { - const idx = this.pointer.get().pop(); - const parent_ref = this.pointer.resolve( - this.json, - ).children; - - if (parent_ref[idx + 1] === undefined) { - alert("No space to move element."); - return; - } - - const clone = JSON.parse(JSON.stringify(this.current)); - const other_clone = JSON.parse( - JSON.stringify(parent_ref[idx + 1]), - ); - - copy_fields(clone, parent_ref[idx + 1]); // move here to here - copy_fields(other_clone, parent_ref[idx]); // move there to here - - this.close(); - this.render(); - }); - - return button; - })(), - ); - - buttons.appendChild( - (() => { - const button = document.createElement("button"); - button.classList.add("red"); - - trigger("app::icon", ["trash", "icon"]).then((icon) => { - button.prepend(icon); - }); - - button.appendChild( - (() => { - const span = document.createElement("span"); - span.innerText = "Delete"; - return span; - })(), - ); - - button.addEventListener("click", async () => { - if ( - !(await trigger("app::confirm", [ - "Are you sure you would like to do this?", - ])) - ) { - return; - } - - if (this.json === this.current) { - // this is the root element; replace with empty - copy_fields( - COMPONENT_TEMPLATES.EMPTY_COMPONENT, - this.current, - ); - } else { - // get parent - const idx = this.pointer.get().pop(); - const ref = this.pointer.resolve(this.json); - // remove element - ref.children.splice(idx, 1); - } - - this.render(); - this.close(); - }); - - return button; - })(), - ); - - sidebar.appendChild(buttons); - } else if (page === "tree") { - sidebar.innerHTML = this.tree; - } - - sidebar.appendChild(document.createElement("hr")); - - const buttons = document.createElement("div"); - buttons.className = "flex gap-2 flex-wrap"; - - if (data.back) { - buttons.appendChild( - (() => { - const button = document.createElement("button"); - button.className = "secondary"; - - trigger("app::icon", ["arrow-left", "icon"]).then( - (icon) => { - button.prepend(icon); - }, - ); - - button.appendChild( - (() => { - const span = document.createElement("span"); - span.innerText = "Back"; - return span; - })(), - ); - - button.addEventListener("click", () => { - this.screen(...data.back); - }); - - return button; - })(), - ); - } - - buttons.appendChild( - (() => { - const button = document.createElement("button"); - button.className = "red secondary"; - - trigger("app::icon", ["x", "icon"]).then((icon) => { - button.prepend(icon); - }); - - button.appendChild( - (() => { - const span = document.createElement("span"); - span.innerText = "Close"; - return span; - })(), - ); - - button.addEventListener("click", () => { - this.render(); - this.close(); - }); - - return button; - })(), - ); - - sidebar.appendChild(buttons); - - // ... - this.open(); - } -} - -define("ElementPointer", ElementPointer); -define("LayoutEditor", LayoutEditor); diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index 4fd2150..99fda4e 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -193,9 +193,13 @@ like.classList.add("green"); like.querySelector("svg").classList.add("filled"); - dislike.classList.remove("red"); + if (dislike) { + dislike.classList.remove("red"); + } } else { - dislike.classList.add("red"); + if (dislike) { + dislike.classList.add("red"); + } like.classList.remove("green"); like.querySelector("svg").classList.remove("filled"); @@ -1201,3 +1205,60 @@ ]); }); })(); + +(() => { + const self = reg_ns("seller"); + + self.define("register", async () => { + await trigger("atto::debounce", ["seller::register"]); + + if ( + !(await trigger("atto::confirm", [ + "Are you sure you want to do this?", + ])) + ) { + return; + } + + const res = await ( + await fetch("/api/v1/service_hooks/stripe/seller/register", { + method: "POST", + }) + ).json(); + + trigger("atto::toast", [res.ok ? "success" : "error", res.message]); + self.onboarding(); + }); + + self.define("onboarding", async () => { + await trigger("atto::debounce", ["seller::onboarding"]); + + const res = await ( + await fetch("/api/v1/service_hooks/stripe/seller/onboarding", { + method: "POST", + }) + ).json(); + + trigger("atto::toast", [res.ok ? "success" : "error", res.message]); + + if (res.ok) { + window.location.href = res.payload; + } + }); + + self.define("login", async () => { + await trigger("atto::debounce", ["seller::login"]); + + const res = await ( + await fetch("/api/v1/service_hooks/stripe/seller/login", { + method: "POST", + }) + ).json(); + + trigger("atto::toast", [res.ok ? "success" : "error", res.message]); + + if (res.ok) { + window.location.href = res.payload; + } + }); +})(); diff --git a/crates/app/src/public/js/proto_links.js b/crates/app/src/public/js/proto_links.js index ab5d938..9c8d9fd 100644 --- a/crates/app/src/public/js/proto_links.js +++ b/crates/app/src/public/js/proto_links.js @@ -31,7 +31,9 @@ function fix_atto_links() { if (TETRATTO_LINK_HANDLER_CTX === "embed") { // relative links for embeds - const path = window.location.pathname.slice("/api/v1/net/".length); + const path = window.location.pathname + .replace("atto://", "") + .slice("/api/v1/net/".length); function fix_element( selector = "a", @@ -43,25 +45,28 @@ function fix_atto_links() { continue; } - let x = new URL(y[property]).pathname; + const p = new URL(y[property]).pathname.replace("atto://", ""); + let x = p.startsWith("/api/v1/net/") + ? p.replace("/api/v1/net/", "") + : p.startsWith("/") + ? `${path.split("/")[0]}${p}` + : p; if (!x.includes(".html")) { x = `${x}/index.html`; } if (relative) { - y[property] = - `atto://${path.replace("atto://", "").split("/")[0]}${x}`; + y[property] = `atto://${x}`; } else { y[property] = - `/api/v1/net/atto://${path.replace("atto://", "").split("/")[0]}${x}`; + `/api/v1/net/${path.replace("atto://", "").split("/")[0]}${x}?s=${globalThis.SECRET_SESSION}`; } } } fix_element("a", "href", true); - fix_element("link", "href", false); - fix_element("script", "src", false); + fix_element("img", "src", false); // send message window.top.postMessage( @@ -108,12 +113,11 @@ function fix_atto_links() { } const href = structuredClone(anchor.href); - anchor.addEventListener("click", () => { if (TETRATTO_LINK_HANDLER_CTX === "net") { window.location.href = `/net/${href.replace("atto://", "")}`; } else { - window.location.href = `/api/v1/net/${href}`; + window.location.href = `/api/v1/net/${href}?s=${globalThis.SECRET_SESSION}`; } }); diff --git a/crates/app/src/routes/api/v1/app_data.rs b/crates/app/src/routes/api/v1/app_data.rs new file mode 100644 index 0000000..b5fa212 --- /dev/null +++ b/crates/app/src/routes/api/v1/app_data.rs @@ -0,0 +1,277 @@ +use crate::{ + get_app_from_key, + routes::api::v1::{InsertAppData, QueryAppData, UpdateAppDataValue, UpdateAppDataKey}, + State, +}; +use axum::{extract::Path, http::HeaderMap, response::IntoResponse, Extension, Json}; +use tetratto_core::model::{ + apps::{AppData, AppDataQuery, AppDataQueryResult}, + ApiReturn, Error, +}; + +pub async fn get_app_request( + headers: HeaderMap, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let app = match get_app_from_key!(data, headers) { + Some(x) => x, + None => return Json(Error::NotAllowed.into()), + }; + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(app), + }) +} + +pub async fn query_request( + headers: HeaderMap, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let app = match get_app_from_key!(data, headers) { + Some(x) => x, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .query_app_data(AppDataQuery { + app: app.id, + query: req.query, + mode: req.mode, + }) + .await + { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn create_request( + headers: HeaderMap, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let app = match get_app_from_key!(data, headers) { + Some(x) => x, + None => return Json(Error::NotAllowed.into()), + }; + + let owner = match data.get_user_by_id(app.owner).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + // check size + let new_size = app.data_used + req.value.len(); + 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 { + return Json(e.into()); + } + + match data + .create_app_data(AppData::new(app.id, req.key, req.value)) + .await + { + Ok(s) => Json(ApiReturn { + ok: true, + message: "Data inserted".to_string(), + payload: s.id.to_string(), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_key_request( + headers: HeaderMap, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let app = match get_app_from_key!(data, headers) { + Some(x) => x, + None => return Json(Error::NotAllowed.into()), + }; + + let app_data = match data.get_app_data_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if app_data.app != app.id { + return Json(Error::NotAllowed.into()); + } + + match data.update_app_data_key(id, &req.key).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Data updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_value_request( + headers: HeaderMap, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let app = match get_app_from_key!(data, headers) { + Some(x) => x, + None => return Json(Error::NotAllowed.into()), + }; + + let owner = match data.get_user_by_id(app.owner).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + let app_data = match data.get_app_data_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if app_data.app != app.id { + return Json(Error::NotAllowed.into()); + } + + // check size + 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, &app) { + return Json(Error::AppHitStorageLimit.into()); + } + + // ... + // 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()); + } + + match data.update_app_data_value(id, &req.value).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Data updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_request( + headers: HeaderMap, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let app = match get_app_from_key!(data, headers) { + Some(x) => x, + None => return Json(Error::NotAllowed.into()), + }; + + let app_data = match data.get_app_data_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if app_data.app != app.id { + return Json(Error::NotAllowed.into()); + } + + // ... + if let Err(e) = data + .add_app_data_used(app.id, -(app_data.value.len() as i32)) + .await + { + return Json(e.into()); + } + + match data.delete_app_data(id).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Data deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_query_request( + headers: HeaderMap, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let app = match get_app_from_key!(data, headers) { + Some(x) => x, + None => return Json(Error::NotAllowed.into()), + }; + + // ... + let rows = match data + .query_app_data(AppDataQuery { + app: app.id, + query: req.query.clone(), + mode: req.mode.clone(), + }) + .await + { + Ok(x) => match x { + AppDataQueryResult::One(x) => vec![x], + AppDataQueryResult::Many(x) => x, + }, + Err(e) => return Json(e.into()), + }; + + let mut subtract_amount: usize = 0; + for row in &rows { + subtract_amount += row.value.len(); + } + drop(rows); + + if let Err(e) = data + .add_app_data_used(app.id, -(subtract_amount as i32)) + .await + { + return Json(e.into()); + } + + match data + .query_delete_app_data(AppDataQuery { + app: app.id, + query: req.query, + mode: req.mode, + }) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Data deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/apps.rs b/crates/app/src/routes/api/v1/apps.rs index c4e2809..eac16ba 100644 --- a/crates/app/src/routes/api/v1/apps.rs +++ b/crates/app/src/routes/api/v1/apps.rs @@ -7,7 +7,7 @@ use crate::{ State, }; use axum::{Extension, Json, extract::Path, response::IntoResponse}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ apps::{AppQuota, ThirdPartyApp}, oauth::{AuthGrant, PkceChallengeMethod}, @@ -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, @@ -239,3 +268,34 @@ pub async fn grant_request( Err(e) => Json(e.into()), } } + +pub async fn roll_api_key_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> 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()), + }; + + let app = match data.get_app_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if user.id != app.owner { + return Json(Error::NotAllowed.into()); + } + + let new_key = tetratto_shared::hash::random_id_salted_len(32); + match data.update_app_api_key(id, &new_key).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "App updated".to_string(), + payload: Some(new_key), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/auth/connections/last_fm.rs b/crates/app/src/routes/api/v1/auth/connections/last_fm.rs index 9740b5a..be4176c 100644 --- a/crates/app/src/routes/api/v1/auth/connections/last_fm.rs +++ b/crates/app/src/routes/api/v1/auth/connections/last_fm.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; use axum::{extract::Path, response::IntoResponse, Extension, Json}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::{ database::connections::last_fm::LastFmConnection, model::{ diff --git a/crates/app/src/routes/api/v1/auth/connections/mod.rs b/crates/app/src/routes/api/v1/auth/connections/mod.rs index 5cd9813..8a98355 100644 --- a/crates/app/src/routes/api/v1/auth/connections/mod.rs +++ b/crates/app/src/routes/api/v1/auth/connections/mod.rs @@ -5,7 +5,7 @@ pub mod stripe; use std::collections::HashMap; use axum::{extract::Path, response::IntoResponse, Extension, Json}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use serde::Deserialize; use tetratto_core::model::{ auth::{ConnectionService, ExternalConnectionData}, diff --git a/crates/app/src/routes/api/v1/auth/connections/spotify.rs b/crates/app/src/routes/api/v1/auth/connections/spotify.rs index 8d0db30..d83057e 100644 --- a/crates/app/src/routes/api/v1/auth/connections/spotify.rs +++ b/crates/app/src/routes/api/v1/auth/connections/spotify.rs @@ -1,5 +1,5 @@ use axum::{response::IntoResponse, Extension, Json}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::{ database::connections::spotify::SpotifyConnection, model::{ diff --git a/crates/app/src/routes/api/v1/auth/connections/stripe.rs b/crates/app/src/routes/api/v1/auth/connections/stripe.rs index e62a0e8..2110924 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -1,14 +1,15 @@ -use std::time::Duration; +use std::{str::FromStr, time::Duration}; use axum::{http::HeaderMap, response::IntoResponse, Extension, Json}; +use crate::cookie::CookieJar; use tetratto_core::model::{ - auth::{User, Notification}, + auth::{Notification, User}, moderation::AuditLogEntry, - permissions::FinePermission, + permissions::{FinePermission, SecondaryPermission}, ApiReturn, Error, }; use stripe::{EventObject, EventType}; -use crate::State; +use crate::{get_user_from_token, State}; pub async fn stripe_webhook( Extension(data): Extension, @@ -17,9 +18,10 @@ pub async fn stripe_webhook( ) -> impl IntoResponse { let data = &(data.read().await).0; - if data.0.0.stripe.is_none() { - return Json(Error::MiscError("Disabled".to_string()).into()); - } + let stripe_cnf = match data.0.0.stripe { + Some(ref c) => c, + None => return Json(Error::MiscError("Disabled".to_string()).into()), + }; let sig = match headers.get("Stripe-Signature") { Some(s) => s, @@ -56,7 +58,7 @@ pub async fn stripe_webhook( Err(e) => return Json(e.into()), }; - tracing::info!("subscribe {} (stripe: {})", user.id, customer_id); + tracing::info!("payment {} (stripe: {})", user.id, customer_id); if let Err(e) = data .update_user_stripe_id(user.id, customer_id.as_str()) .await @@ -74,6 +76,48 @@ pub async fn stripe_webhook( }; let customer_id = invoice.customer.unwrap().id(); + let lines = invoice.lines.unwrap(); + + if lines.total_count.unwrap() > 1 { + if let Err(e) = data + .create_audit_log_entry(AuditLogEntry::new( + 0, + format!("too many invoice line items: stripe {customer_id}"), + )) + .await + { + return Json(e.into()); + } + + return Json(Error::MiscError("Too many line items".to_string()).into()); + } + + let item = match lines.data.get(0) { + Some(i) => i, + None => { + if let Err(e) = data + .create_audit_log_entry(AuditLogEntry::new( + 0, + format!("too few invoice line items: stripe {customer_id}"), + )) + .await + { + return Json(e.into()); + } + + return Json(Error::MiscError("Too few line items".to_string()).into()); + } + }; + + let product_id = item + .price + .as_ref() + .unwrap() + .product + .as_ref() + .unwrap() + .id() + .to_string(); // pull user and update role let mut retries: usize = 0; @@ -118,45 +162,91 @@ pub async fn stripe_webhook( } let user = user.unwrap(); - tracing::info!("found subscription user in {retries} tries"); - if user.permissions.check(FinePermission::SUPPORTER) { - return Json(ApiReturn { - ok: true, - message: "Already applied".to_string(), - payload: (), - }); - } + if product_id == stripe_cnf.product_ids.supporter { + // supporter + tracing::info!("found subscription user in {retries} tries"); - tracing::info!("invoice {} (stripe: {})", user.id, customer_id); - let new_user_permissions = user.permissions | FinePermission::SUPPORTER; + if user.permissions.check(FinePermission::SUPPORTER) { + return Json(ApiReturn { + ok: true, + message: "Already applied".to_string(), + payload: (), + }); + } - if let Err(e) = data - .update_user_role(user.id, new_user_permissions, user.clone(), true) - .await - { - return Json(e.into()); - } + tracing::info!("invoice {} (stripe: {})", user.id, customer_id); + let new_user_permissions = user.permissions | FinePermission::SUPPORTER; - if data.0.0.security.enable_invite_codes && user.awaiting_purchase { if let Err(e) = data - .update_user_awaiting_purchased_status(user.id, false, user.clone(), false) + .update_user_role(user.id, new_user_permissions, user.clone(), true) .await { return Json(e.into()); } - } - if let Err(e) = data - .create_notification(Notification::new( - "Welcome new supporter!".to_string(), - "Thank you for your support! Your account has been updated with your new role." - .to_string(), - user.id, - )) - .await - { - return Json(e.into()); + if data.0.0.security.enable_invite_codes && user.awaiting_purchase { + if let Err(e) = data + .update_user_awaiting_purchased_status(user.id, false, user.clone(), false) + .await + { + return Json(e.into()); + } + } + + if let Err(e) = data + .create_notification(Notification::new( + "Welcome new supporter!".to_string(), + "Thank you for your support! Your account has been updated with your new role." + .to_string(), + user.id, + )) + .await + { + return Json(e.into()); + } + } else if product_id == stripe_cnf.product_ids.dev_pass { + // dev pass + tracing::info!("found subscription user in {retries} tries"); + + if user + .secondary_permissions + .check(SecondaryPermission::DEVELOPER_PASS) + { + return Json(ApiReturn { + ok: true, + message: "Already applied".to_string(), + payload: (), + }); + } + + tracing::info!("invoice {} (stripe: {})", user.id, customer_id); + let new_user_permissions = + user.secondary_permissions | SecondaryPermission::DEVELOPER_PASS; + + if let Err(e) = data + .update_user_secondary_role(user.id, new_user_permissions, user.clone(), true) + .await + { + return Json(e.into()); + } + + if let Err(e) = data + .create_notification(Notification::new( + "Welcome new developer!".to_string(), + "Thank you for your support! Your account has been updated with your new role." + .to_string(), + user.id, + )) + .await + { + return Json(e.into()); + } + } else { + tracing::error!( + "received an invalid stripe product id, please check config.stripe.product_ids" + ); + return Json(Error::MiscError("Unknown product ID".to_string()).into()); } } EventType::CustomerSubscriptionDeleted => { @@ -167,34 +257,72 @@ pub async fn stripe_webhook( }; let customer_id = subscription.customer.id(); + let product_id = subscription + .items + .data + .get(0) + .as_ref() + .expect("cancelled nothing?") + .plan + .as_ref() + .expect("no subscription plan?") + .product + .as_ref() + .expect("plan with no product?") + .id() + .to_string(); let user = match data.get_user_by_stripe_id(customer_id.as_str()).await { Ok(ua) => ua, Err(e) => return Json(e.into()), }; - tracing::info!("unsubscribe {} (stripe: {})", user.id, customer_id); - let new_user_permissions = user.permissions - FinePermission::SUPPORTER; + // handle each subscription item + if product_id == stripe_cnf.product_ids.supporter { + // supporter + tracing::info!("unsubscribe {} (stripe: {})", user.id, customer_id); + let new_user_permissions = user.permissions - FinePermission::SUPPORTER; - if let Err(e) = data - .update_user_role(user.id, new_user_permissions, user.clone(), true) - .await - { - return Json(e.into()); - } - - if data.0.0.security.enable_invite_codes && user.was_purchased && user.invite_code == 0 - { - // user doesn't come from an invite code, and is a purchased account - // this means their account must be locked if they stop paying if let Err(e) = data - .update_user_awaiting_purchased_status(user.id, true, user.clone(), false) + .update_user_role(user.id, new_user_permissions, user.clone(), true) .await { return Json(e.into()); } + + if data.0.0.security.enable_invite_codes + && user.was_purchased + && user.invite_code == 0 + { + // user doesn't come from an invite code, and is a purchased account + // this means their account must be locked if they stop paying + if let Err(e) = data + .update_user_awaiting_purchased_status(user.id, true, user.clone(), false) + .await + { + return Json(e.into()); + } + } + } else if product_id == stripe_cnf.product_ids.dev_pass { + // dev pass + tracing::info!("unsubscribe {} (stripe: {})", user.id, customer_id); + let new_user_permissions = + user.secondary_permissions - SecondaryPermission::DEVELOPER_PASS; + + if let Err(e) = data + .update_user_secondary_role(user.id, new_user_permissions, user.clone(), true) + .await + { + return Json(e.into()); + } + } else { + tracing::error!( + "received an invalid stripe product id, please check config.stripe.product_ids" + ); + return Json(Error::MiscError("Unknown product ID".to_string()).into()); } + // send notification if let Err(e) = data .create_notification(Notification::new( "Sorry to see you go... :(".to_string(), @@ -209,44 +337,119 @@ pub async fn stripe_webhook( } EventType::InvoicePaymentFailed => { // payment failed - let subscription = match req.data.object { - EventObject::Subscription(c) => c, + let invoice = match req.data.object { + EventObject::Invoice(i) => i, _ => unreachable!("cannot be this"), }; - let customer_id = subscription.customer.id(); + let customer_id = invoice.customer.expect("TETRATTO_STRIPE_NO_CUSTOMER").id(); + + let item = match invoice.lines.as_ref().expect("no line items?").data.get(0) { + Some(i) => i, + None => { + if let Err(e) = data + .create_audit_log_entry(AuditLogEntry::new( + 0, + format!("too few invoice line items: stripe {customer_id}"), + )) + .await + { + return Json(e.into()); + } + + return Json(Error::MiscError("Too few line items".to_string()).into()); + } + }; + + let product_id = item + .price + .as_ref() + .unwrap() + .product + .as_ref() + .unwrap() + .id() + .to_string(); let user = match data.get_user_by_stripe_id(customer_id.as_str()).await { Ok(ua) => ua, Err(e) => return Json(e.into()), }; - tracing::info!( - "unsubscribe (pay fail) {} (stripe: {})", - user.id, - customer_id - ); - let new_user_permissions = user.permissions - FinePermission::SUPPORTER; + // handle each subscription item + if product_id == stripe_cnf.product_ids.supporter { + // supporter + if !user.permissions.check(FinePermission::SUPPORTER) { + // the user isn't currently a supporter, there's no reason to send this notification + return Json(ApiReturn { + ok: true, + message: "Acceptable".to_string(), + payload: (), + }); + } - if let Err(e) = data - .update_user_role(user.id, new_user_permissions, user.clone(), true) - .await - { - return Json(e.into()); - } + tracing::info!( + "unsubscribe (pay fail) {} (stripe: {})", + user.id, + customer_id + ); + let new_user_permissions = user.permissions - FinePermission::SUPPORTER; - if data.0.0.security.enable_invite_codes && user.was_purchased && user.invite_code == 0 - { - // user doesn't come from an invite code, and is a purchased account - // this means their account must be locked if they stop paying if let Err(e) = data - .update_user_awaiting_purchased_status(user.id, true, user.clone(), false) + .update_user_role(user.id, new_user_permissions, user.clone(), true) .await { return Json(e.into()); } + + if data.0.0.security.enable_invite_codes + && user.was_purchased + && user.invite_code == 0 + { + // user doesn't come from an invite code, and is a purchased account + // this means their account must be locked if they stop paying + if let Err(e) = data + .update_user_awaiting_purchased_status(user.id, true, user.clone(), false) + .await + { + return Json(e.into()); + } + } + } else if product_id == stripe_cnf.product_ids.dev_pass { + // dev pass + if !user + .secondary_permissions + .check(SecondaryPermission::DEVELOPER_PASS) + { + return Json(ApiReturn { + ok: true, + message: "Acceptable".to_string(), + payload: (), + }); + } + + tracing::info!( + "unsubscribe (pay fail) {} (stripe: {})", + user.id, + customer_id + ); + let new_user_permissions = + user.secondary_permissions - SecondaryPermission::DEVELOPER_PASS; + + if let Err(e) = data + .update_user_secondary_role(user.id, new_user_permissions, user.clone(), true) + .await + { + return Json(e.into()); + } + } else { + tracing::error!( + "received an invalid stripe product id, please check config.stripe.product_ids" + ); + return Json(Error::MiscError("Unknown product ID".to_string()).into()); } + // send notification if let Err(e) = data .create_notification(Notification::new( "It seems your recent payment has failed :(".to_string(), @@ -268,3 +471,145 @@ pub async fn stripe_webhook( payload: (), }) } + +pub async fn onboarding_account_link_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await); + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if user.seller_data.account_id.is_some() { + return Json(Error::NotAllowed.into()); + } + + let client = match data.3 { + Some(ref c) => c, + None => return Json(Error::Unknown.into()), + }; + + match stripe::AccountLink::create( + &client, + stripe::CreateAccountLink { + account: match user.seller_data.account_id { + Some(id) => stripe::AccountId::from_str(&id).unwrap(), + None => return Json(Error::NotAllowed.into()), + }, + type_: stripe::AccountLinkType::AccountOnboarding, + collect: None, + expand: &[], + refresh_url: Some(&format!( + "{}/auth/connections_link/seller/refresh", + data.0.0.0.host + )), + return_url: Some(&format!( + "{}/auth/connections_link/seller/return", + data.0.0.0.host + )), + collection_options: None, + }, + ) + .await + { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Acceptable".to_string(), + payload: Some(x.url), + }), + Err(e) => Json(Error::MiscError(e.to_string()).into()), + } +} + +pub async fn create_seller_account_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await); + let mut user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if user.seller_data.account_id.is_some() { + return Json(Error::NotAllowed.into()); + } + + let client = match data.3 { + Some(ref c) => c, + None => return Json(Error::Unknown.into()), + }; + + let account = match stripe::Account::create( + &client, + stripe::CreateAccount { + type_: Some(stripe::AccountType::Express), + capabilities: Some(stripe::CreateAccountCapabilities { + card_payments: Some(stripe::CreateAccountCapabilitiesCardPayments { + requested: Some(true), + }), + transfers: Some(stripe::CreateAccountCapabilitiesTransfers { + requested: Some(true), + }), + ..Default::default() + }), + ..Default::default() + }, + ) + .await + { + Ok(a) => a, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }; + + user.seller_data.account_id = Some(account.id.to_string()); + match data + .0 + .update_user_seller_data(user.id, user.seller_data) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Acceptable".to_string(), + payload: (), + }), + Err(e) => return Json(e.into()), + } +} + +pub async fn login_link_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await); + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if user.seller_data.account_id.is_none() | !user.seller_data.completed_onboarding { + return Json(Error::NotAllowed.into()); + } + + let client = match data.3 { + Some(ref c) => c, + None => return Json(Error::Unknown.into()), + }; + + match stripe::LoginLink::create( + &client, + &stripe::AccountId::from_str(&user.seller_data.account_id.unwrap()).unwrap(), + &data.0.0.0.host, + ) + .await + { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Acceptable".to_string(), + payload: Some(x.url), + }), + Err(e) => Json(Error::MiscError(e.to_string()).into()), + } +} diff --git a/crates/app/src/routes/api/v1/auth/images.rs b/crates/app/src/routes/api/v1/auth/images.rs index 4619a80..cbaf344 100644 --- a/crates/app/src/routes/api/v1/auth/images.rs +++ b/crates/app/src/routes/api/v1/auth/images.rs @@ -4,7 +4,7 @@ use axum::{ extract::{Path, Query}, response::IntoResponse, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use pathbufd::{PathBufD, pathd}; use serde::Deserialize; use std::{ diff --git a/crates/app/src/routes/api/v1/auth/ipbans.rs b/crates/app/src/routes/api/v1/auth/ipbans.rs index 8a71d25..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_extra::extract::CookieJar; +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/auth/mod.rs b/crates/app/src/routes/api/v1/auth/mod.rs index 085844a..dff259e 100644 --- a/crates/app/src/routes/api/v1/auth/mod.rs +++ b/crates/app/src/routes/api/v1/auth/mod.rs @@ -16,7 +16,7 @@ use axum::{ response::{IntoResponse, Redirect}, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use serde::Deserialize; use tetratto_core::model::addr::RemoteAddr; use tetratto_shared::hash::hash; diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index 8104c71..aec31ef 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -1,11 +1,12 @@ -use std::time::Duration; +use std::{str::FromStr, time::Duration}; use crate::{ get_user_from_token, model::{ApiReturn, Error}, routes::api::v1::{ AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken, - UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserInviteCode, - UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, UpdateUserUsername, + UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserBanReason, + UpdateUserInviteCode, UpdateUserIsDeactivated, UpdateUserIsVerified, UpdateUserPassword, + UpdateUserRole, UpdateUserUsername, }, State, }; @@ -17,7 +18,7 @@ use axum::{ response::{IntoResponse, Redirect}, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use futures_util::{sink::SinkExt, stream::StreamExt}; use tetratto_core::{ cache::Cache, @@ -371,6 +372,34 @@ pub async fn update_user_awaiting_purchase_request( } } +/// Update the deactivated status of the given user. +/// +/// Does not support third-party grants. +pub async fn update_user_is_deactivated_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + 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()), + }; + + match data + .update_user_is_deactivated(id, req.is_deactivated, user) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Deactivated status updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + /// Update the role of the given user. /// /// Does not support third-party grants. @@ -424,6 +453,35 @@ pub async fn update_user_secondary_role_request( } } +/// Update the ban reason of the given user. +/// +/// Does not support third-party grants. +pub async fn update_user_ban_reason_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + 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_USERS) { + return Json(Error::NotAllowed.into()); + } + + match data.update_user_ban_reason(id, &req.reason).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "User updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + /// Update the current user's last seen value. pub async fn seen_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { let data = &(data.read().await).0; @@ -451,8 +509,8 @@ pub async fn delete_user_request( Extension(data): Extension, Json(req): Json, ) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data) { + let data = &(data.read().await); + let user = match get_user_from_token!(jar, data.0) { Some(ua) => ua, None => return Json(Error::NotAllowed.into()), }; @@ -461,6 +519,7 @@ pub async fn delete_user_request( return Json(Error::NotAllowed.into()); } else if user.permissions.check(FinePermission::MANAGE_USERS) { if let Err(e) = data + .0 .create_audit_log_entry(AuditLogEntry::new( user.id, format!("invoked `delete_user` with x value `{id}`"), @@ -472,14 +531,32 @@ pub async fn delete_user_request( } match data + .0 .delete_user(id, &req.password, user.permissions.check_manager()) .await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "User deleted".to_string(), - payload: (), - }), + Ok(ua) => { + // delete stripe user + if let Some(stripe_id) = ua.seller_data.account_id + && let Some(ref client) = data.3 + { + if let Err(e) = stripe::Account::delete( + &client, + &stripe::AccountId::from_str(&stripe_id).unwrap(), + ) + .await + { + return Json(Error::MiscError(e.to_string()).into()); + } + } + + // ... + Json(ApiReturn { + ok: true, + message: "User deleted".to_string(), + payload: (), + }) + } Err(e) => Json(e.into()), } } diff --git a/crates/app/src/routes/api/v1/auth/social.rs b/crates/app/src/routes/api/v1/auth/social.rs index b80bd14..84e20c8 100644 --- a/crates/app/src/routes/api/v1/auth/social.rs +++ b/crates/app/src/routes/api/v1/auth/social.rs @@ -9,7 +9,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ addr::RemoteAddr, auth::{AchievementName, FollowResult, IpBlock, Notification, UserBlock, UserFollow}, @@ -17,7 +17,7 @@ use tetratto_core::model::{ }; /// Toggle following on the given user. -pub async fn follow_request( +pub async fn toggle_follow_request( jar: CookieJar, Path(id): Path, Extension(data): Extension, @@ -154,6 +154,96 @@ pub async fn accept_follow_request( } } +pub async fn follow_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageFollowing) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if data + .get_userfollow_by_initiator_receiver(user.id, id) + .await + .is_ok() + { + return Json(Error::MiscError("Already following user".to_string()).into()); + } else { + match data + .create_userfollow(UserFollow::new(user.id, id), &user, false) + .await + { + Ok(r) => { + if r == FollowResult::Followed { + if let Err(e) = data + .create_notification(Notification::new( + "Somebody has followed you!".to_string(), + format!( + "You have been followed by [@{}](/api/v1/auth/user/find/{}).", + user.username, user.id + ), + id, + )) + .await + { + return Json(e.into()); + }; + + // award achievement + if let Err(e) = data + .add_achievement(&mut user, AchievementName::FollowUser.into(), true) + .await + { + return Json(e.into()); + } + + // ... + Json(ApiReturn { + ok: true, + message: "User followed".to_string(), + payload: (), + }) + } else { + Json(ApiReturn { + ok: true, + message: "Asked to follow user".to_string(), + payload: (), + }) + } + } + Err(e) => Json(e.into()), + } + } +} + +pub async fn force_unfollow_me_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageFollowing) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if let Ok(userfollow) = data.get_userfollow_by_receiver_initiator(user.id, id).await { + match data.delete_userfollow(userfollow.id, &user, false).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "User is no longer following you".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } + } else { + return Json(Error::GeneralNotFound("user follow".to_string()).into()); + } +} + /// Toggle blocking on the given user. pub async fn block_request( jar: CookieJar, @@ -278,7 +368,10 @@ pub async fn followers_request( Ok(f) => Json(ApiReturn { ok: true, message: "Success".to_string(), - payload: match data.fill_userfollows_with_initiator(f).await { + payload: match data + .fill_userfollows_with_initiator(f, &Some(user.clone()), id == user.id) + .await + { Ok(f) => Some(data.userfollows_user_filter(&f)), Err(e) => return Json(e.into()), }, @@ -310,7 +403,10 @@ pub async fn following_request( Ok(f) => Json(ApiReturn { ok: true, message: "Success".to_string(), - payload: match data.fill_userfollows_with_receiver(f).await { + payload: match data + .fill_userfollows_with_receiver(f, &Some(user.clone()), id == user.id) + .await + { Ok(f) => Some(data.userfollows_user_filter(&f)), Err(e) => return Json(e.into()), }, diff --git a/crates/app/src/routes/api/v1/auth/user_warnings.rs b/crates/app/src/routes/api/v1/auth/user_warnings.rs index 321ab78..3020ec6 100644 --- a/crates/app/src/routes/api/v1/auth/user_warnings.rs +++ b/crates/app/src/routes/api/v1/auth/user_warnings.rs @@ -9,7 +9,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{auth::UserWarning, oauth, permissions::FinePermission}; /// Create a new user warning. diff --git a/crates/app/src/routes/api/v1/channels/channels.rs b/crates/app/src/routes/api/v1/channels/channels.rs index e3ead5a..2059a0f 100644 --- a/crates/app/src/routes/api/v1/channels/channels.rs +++ b/crates/app/src/routes/api/v1/channels/channels.rs @@ -1,5 +1,5 @@ use axum::{Extension, Json, extract::Path, response::IntoResponse}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{oauth, channels::Channel, ApiReturn, Error}; use crate::{ get_user_from_token, @@ -293,3 +293,62 @@ pub async fn get_request( Err(e) => Json(e.into()), } } + +pub async fn mute_channel_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageChannelMutes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if user.channel_mutes.contains(&id) { + return Json(Error::MiscError("Channel already muted".to_string()).into()); + } + + user.channel_mutes.push(id); + match data + .update_user_channel_mutes(user.id, user.channel_mutes) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Channel muted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn unmute_channel_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageChannelMutes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let pos = match user.channel_mutes.iter().position(|x| *x == id) { + Some(x) => x, + None => return Json(Error::MiscError("Channel not muted".to_string()).into()), + }; + + user.channel_mutes.remove(pos); + match data + .update_user_channel_mutes(user.id, user.channel_mutes) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Channel muted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/channels/message_reactions.rs b/crates/app/src/routes/api/v1/channels/message_reactions.rs index b9ccb53..5f5c79c 100644 --- a/crates/app/src/routes/api/v1/channels/message_reactions.rs +++ b/crates/app/src/routes/api/v1/channels/message_reactions.rs @@ -1,6 +1,6 @@ use crate::{get_user_from_token, routes::api::v1::CreateMessageReaction, State}; use axum::{Extension, Json, extract::Path, response::IntoResponse}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{channels::MessageReaction, oauth, ApiReturn, Error}; pub async fn get_request( diff --git a/crates/app/src/routes/api/v1/channels/messages.rs b/crates/app/src/routes/api/v1/channels/messages.rs index e88138e..92a5c48 100644 --- a/crates/app/src/routes/api/v1/channels/messages.rs +++ b/crates/app/src/routes/api/v1/channels/messages.rs @@ -7,7 +7,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::{ cache::{Cache, redis::Commands}, model::{ diff --git a/crates/app/src/routes/api/v1/communities/communities.rs b/crates/app/src/routes/api/v1/communities/communities.rs index 539cc08..a0793b1 100644 --- a/crates/app/src/routes/api/v1/communities/communities.rs +++ b/crates/app/src/routes/api/v1/communities/communities.rs @@ -3,7 +3,7 @@ use axum::{ extract::Path, response::{IntoResponse, Redirect}, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ auth::Notification, communities::{Community, CommunityMembership}, diff --git a/crates/app/src/routes/api/v1/communities/drafts.rs b/crates/app/src/routes/api/v1/communities/drafts.rs index 75f0948..559e4b3 100644 --- a/crates/app/src/routes/api/v1/communities/drafts.rs +++ b/crates/app/src/routes/api/v1/communities/drafts.rs @@ -3,7 +3,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{auth::AchievementName, communities::PostDraft, oauth, ApiReturn, Error}; use crate::{ get_user_from_token, diff --git a/crates/app/src/routes/api/v1/communities/emojis.rs b/crates/app/src/routes/api/v1/communities/emojis.rs index 1db4c0c..84fadc0 100644 --- a/crates/app/src/routes/api/v1/communities/emojis.rs +++ b/crates/app/src/routes/api/v1/communities/emojis.rs @@ -7,7 +7,7 @@ use crate::{ State, }; use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ oauth, uploads::{CustomEmoji, MediaType, MediaUpload}, @@ -17,6 +17,8 @@ use tetratto_core::model::{ /// Expand a unicode emoji into its Gemoji shortcode. pub async fn get_emoji_shortcode(emoji: String) -> impl IntoResponse { match emoji.as_str() { + // matches `CustomEmoji::replace` + "💯" => "100".to_string(), "👍" => "thumbs_up".to_string(), "👎" => "thumbs_down".to_string(), _ => match emojis::get(&emoji) { diff --git a/crates/app/src/routes/api/v1/communities/images.rs b/crates/app/src/routes/api/v1/communities/images.rs index 3ddee00..9f32ef3 100644 --- a/crates/app/src/routes/api/v1/communities/images.rs +++ b/crates/app/src/routes/api/v1/communities/images.rs @@ -1,5 +1,5 @@ use axum::{Extension, Json, body::Body, extract::Path, response::IntoResponse}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use pathbufd::{PathBufD, pathd}; use std::fs::exists; use tetratto_core::model::{ApiReturn, Error, permissions::FinePermission, oauth}; diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index d6554ff..13729b3 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -4,7 +4,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ addr::RemoteAddr, auth::AchievementName, @@ -152,10 +152,11 @@ pub async fn create_request( } // ... - match data.create_post(props.clone()).await { + let uploads = props.uploads.clone(); + match data.create_post(props).await { Ok(id) => { // write to uploads - for (i, upload_id) in props.uploads.iter().enumerate() { + for (i, upload_id) in uploads.iter().enumerate() { let image = match images.get(i) { Some(img) => img, None => { @@ -723,7 +724,7 @@ pub async fn from_communities_request( }; match data - .get_posts_from_user_communities(user.id, 12, props.page) + .get_posts_from_user_communities(user.id, 12, props.page, &user) .await { Ok(posts) => { diff --git a/crates/app/src/routes/api/v1/communities/questions.rs b/crates/app/src/routes/api/v1/communities/questions.rs index 1d1a7ba..de6cbb2 100644 --- a/crates/app/src/routes/api/v1/communities/questions.rs +++ b/crates/app/src/routes/api/v1/communities/questions.rs @@ -4,7 +4,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ addr::RemoteAddr, auth::{AchievementName, IpBlock}, @@ -96,6 +96,13 @@ pub async fn create_request( props.context.mask_owner = true; } + if !req.asking_about.is_empty() && !req.is_global { + props.context.asking_about = match req.asking_about.parse::() { + Ok(x) => Some(x), + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + } + } + match data .create_question(props, drawings.iter().map(|x| x.to_vec()).collect()) .await diff --git a/crates/app/src/routes/api/v1/domains.rs b/crates/app/src/routes/api/v1/domains.rs index 8cfd9dc..1e57049 100644 --- a/crates/app/src/routes/api/v1/domains.rs +++ b/crates/app/src/routes/api/v1/domains.rs @@ -3,12 +3,21 @@ use crate::{ routes::api::v1::{CreateDomain, UpdateDomainData}, State, }; -use axum::{extract::Path, response::IntoResponse, http::StatusCode, Extension, Json}; -use axum_extra::extract::CookieJar; -use tetratto_core::model::{ - littleweb::{Domain, ServiceFsMime}, - oauth, ApiReturn, Error, +use axum::{ + extract::{Path, Query}, + http::StatusCode, + response::IntoResponse, + Extension, Json, }; +use crate::cookie::CookieJar; +use tetratto_core::model::{ + auth::AchievementName, + littleweb::{Domain, ServiceFsMime}, + oauth, + permissions::FinePermission, + ApiReturn, Error, +}; +use serde::Deserialize; pub async fn get_request( Path(id): Path, @@ -48,11 +57,20 @@ pub async fn create_request( Json(req): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateDomains) { + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateDomains) { Some(ua) => ua, None => return Json(Error::NotAllowed.into()), }; + // award achievement + if let Err(e) = data + .add_achievement(&mut user, AchievementName::CreateDomain.into(), true) + .await + { + return Json(e.into()); + } + + // ... match data .create_domain(Domain::new(req.name, req.tld, user.id)) .await @@ -109,13 +127,38 @@ pub async fn delete_request( } } +#[derive(Deserialize)] +pub struct GetFileQuery { + #[serde(default, alias = "s")] + pub session: String, +} + pub async fn get_file_request( - Path(addr): Path, + Path(mut addr): Path, Extension(data): Extension, + Query(props): Query, ) -> impl IntoResponse { + if !addr.starts_with("atto://") { + addr = format!("atto://{addr}"); + } + + // ... let data = &(data.read().await).0; + let user = get_user_from_token!(--browser_session = props.session, data); let (subdomain, domain, tld, path) = Domain::from_str(&addr); + if path.starts_with("$") && user.is_none() { + return Err((StatusCode::BAD_REQUEST, Error::NotAllowed.to_string())); + } else if let Some(ref ua) = user + && path.starts_with("$paid") + && !ua.permissions.check(FinePermission::SUPPORTER) + { + return Err(( + StatusCode::BAD_REQUEST, + Error::RequiresSupporter.to_string(), + )); + } + // resolve domain let domain = match data.get_domain_by_name_tld(&domain, &tld).await { Ok(x) => x, @@ -145,16 +188,28 @@ pub async fn get_file_request( Some((f, _)) => Ok(( [("Content-Type".to_string(), f.mime.to_string())], if f.mime == ServiceFsMime::Html { - f.content.replace( - "", - &format!( - "", - data.0.0.host - ), - ) + f.content + .replace( + "", + &format!( + "", + data.0.0.host, props.session + ), + ) + .replace( + ".js\"", + &format!(".js?r={}&s={}\"", service.revision, props.session), + ) + .replace( + ".css\"", + &format!(".css?r={}&s={}\"", service.revision, props.session), + ) } else { f.content - }, + } + .replace("atto://", "/api/v1/net/"), )), None => { return Err(( diff --git a/crates/app/src/routes/api/v1/journals.rs b/crates/app/src/routes/api/v1/journals.rs index 0b1b394..d018903 100644 --- a/crates/app/src/routes/api/v1/journals.rs +++ b/crates/app/src/routes/api/v1/journals.rs @@ -3,7 +3,7 @@ use axum::{ extract::{Json, Path}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_shared::snow::Snowflake; use crate::{ get_user_from_token, diff --git a/crates/app/src/routes/api/v1/layouts.rs b/crates/app/src/routes/api/v1/layouts.rs deleted file mode 100644 index b86bfd2..0000000 --- a/crates/app/src/routes/api/v1/layouts.rs +++ /dev/null @@ -1,175 +0,0 @@ -use crate::{ - get_user_from_token, - routes::{ - api::v1::{CreateLayout, UpdateLayoutName, UpdateLayoutPages, UpdateLayoutPrivacy}, - }, - State, -}; -use axum::{extract::Path, response::IntoResponse, Extension, Json}; -use axum_extra::extract::CookieJar; -use tetratto_core::{ - model::{ - layouts::{Layout, LayoutPrivacy}, - oauth, - permissions::FinePermission, - ApiReturn, Error, - }, -}; - -pub async fn get_request( - jar: CookieJar, - Path(id): Path, - Extension(data): Extension, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadStacks) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - let layout = match data.get_layout_by_id(id).await { - Ok(x) => x, - Err(e) => return Json(e.into()), - }; - - if layout.privacy == LayoutPrivacy::Public - && user.id != layout.owner - && !user.permissions.check(FinePermission::MANAGE_USERS) - { - return Json(Error::NotAllowed.into()); - } - - Json(ApiReturn { - ok: true, - message: "Success".to_string(), - payload: Some(layout), - }) -} - -pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadLayouts) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - match data.get_layouts_by_user(user.id).await { - Ok(x) => Json(ApiReturn { - ok: true, - message: "Success".to_string(), - payload: Some(x), - }), - Err(e) => Json(e.into()), - } -} - -pub async fn create_request( - jar: CookieJar, - Extension(data): Extension, - Json(req): Json, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateLayouts) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - match data - .create_layout(Layout::new(req.name, user.id, req.replaces)) - .await - { - Ok(s) => Json(ApiReturn { - ok: true, - message: "Layout created".to_string(), - payload: s.id.to_string(), - }), - Err(e) => Json(e.into()), - } -} - -pub async fn update_name_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, oauth::AppScope::UserManageLayouts) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - match data.update_layout_title(id, &user, &req.name).await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Layout updated".to_string(), - payload: (), - }), - Err(e) => Json(e.into()), - } -} - -pub async fn update_privacy_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, oauth::AppScope::UserManageLayouts) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - match data.update_layout_privacy(id, &user, req.privacy).await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Layout updated".to_string(), - payload: (), - }), - Err(e) => Json(e.into()), - } -} - -pub async fn update_pages_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, oauth::AppScope::UserManageLayouts) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - match data.update_layout_pages(id, &user, req.pages).await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Layout updated".to_string(), - payload: (), - }), - Err(e) => Json(e.into()), - } -} - -pub async fn delete_request( - jar: CookieJar, - Extension(data): Extension, - Path(id): Path, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - match data.delete_layout(id, &user).await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Layout deleted".to_string(), - payload: (), - }), - Err(e) => Json(e.into()), - } -} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 506e74f..8a5d95a 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -1,12 +1,13 @@ +pub mod app_data; pub mod apps; pub mod auth; pub mod channels; pub mod communities; pub mod domains; pub mod journals; -pub mod layouts; pub mod notes; pub mod notifications; +pub mod products; pub mod reactions; pub mod reports; pub mod requests; @@ -21,7 +22,7 @@ use axum::{ }; use serde::Deserialize; use tetratto_core::model::{ - apps::AppQuota, + apps::{AppDataSelectMode, AppDataSelectQuery, AppQuota, DeveloperPassStorageQuota}, auth::AchievementName, communities::{ CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess, @@ -29,10 +30,10 @@ use tetratto_core::model::{ }, communities_permissions::CommunityPermission, journals::JournalPrivacyPermission, - layouts::{CustomizablePage, LayoutPage, LayoutPrivacy}, littleweb::{DomainData, DomainTld, ServiceFsEntry}, oauth::AppScope, permissions::{FinePermission, SecondaryPermission}, + products::{ProductPrice, ProductType}, reactions::AssetType, stacks::{StackMode, StackPrivacy, StackSort}, }; @@ -285,6 +286,10 @@ pub fn routes() -> Router { .route("/auth/user/{id}/avatar", get(auth::images::avatar_request)) .route("/auth/user/{id}/banner", get(auth::images::banner_request)) .route("/auth/user/{id}/follow", post(auth::social::follow_request)) + .route( + "/auth/user/{id}/follow/toggle", + post(auth::social::toggle_follow_request), + ) .route( "/auth/user/{id}/follow/cancel", post(auth::social::cancel_follow_request), @@ -293,6 +298,10 @@ pub fn routes() -> Router { "/auth/user/{id}/follow/accept", post(auth::social::accept_follow_request), ) + .route( + "/auth/user/{id}/force_unfollow_me", + post(auth::social::force_unfollow_me_request), + ) .route("/auth/user/{id}/block", post(auth::social::block_request)) .route( "/auth/user/{id}/block_ip", @@ -314,6 +323,10 @@ pub fn routes() -> Router { "/auth/user/{id}/role/2", post(auth::profile::update_user_secondary_role_request), ) + .route( + "/auth/user/{id}/ban_reason", + post(auth::profile::update_user_ban_reason_request), + ) .route( "/auth/user/{id}", delete(auth::profile::delete_user_request), @@ -338,6 +351,10 @@ pub fn routes() -> Router { "/auth/user/{id}/awaiting_purchase", post(auth::profile::update_user_awaiting_purchase_request), ) + .route( + "/auth/user/{id}/deactivate", + post(auth::profile::update_user_is_deactivated_request), + ) .route( "/auth/user/{id}/totp", post(auth::profile::enable_totp_request), @@ -407,6 +424,7 @@ pub fn routes() -> Router { ) // apps .route("/apps", post(apps::create_request)) + .route("/apps/{id}", delete(apps::delete_request)) .route("/apps/{id}/title", post(apps::update_title_request)) .route("/apps/{id}/homepage", post(apps::update_homepage_request)) .route("/apps/{id}/redirect", post(apps::update_redirect_request)) @@ -414,9 +432,21 @@ 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}", delete(apps::delete_request)) .route("/apps/{id}/grant", post(apps::grant_request)) + .route("/apps/{id}/roll", post(apps::roll_api_key_request)) + // app data + .route("/app_data", post(app_data::create_request)) + .route("/app_data/app", get(app_data::get_app_request)) + .route("/app_data/{id}", delete(app_data::delete_request)) + .route("/app_data/{id}/key", post(app_data::update_key_request)) + .route("/app_data/{id}/value", post(app_data::update_value_request)) + .route("/app_data/query", post(app_data::query_request)) + .route("/app_data/query", delete(app_data::delete_query_request)) // warnings .route("/warnings/{id}", get(auth::user_warnings::get_request)) .route("/warnings/{id}", post(auth::user_warnings::create_request)) @@ -465,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 @@ -514,6 +545,18 @@ pub fn routes() -> Router { "/service_hooks/stripe", post(auth::connections::stripe::stripe_webhook), ) + .route( + "/service_hooks/stripe/seller/register", + post(auth::connections::stripe::create_seller_account_request), + ) + .route( + "/service_hooks/stripe/seller/onboarding", + post(auth::connections::stripe::onboarding_account_link_request), + ) + .route( + "/service_hooks/stripe/seller/login", + post(auth::connections::stripe::login_link_request), + ) // channels .route("/channels", post(channels::channels::create_request)) .route( @@ -537,6 +580,14 @@ pub fn routes() -> Router { "/channels/{id}/kick", post(channels::channels::kick_member_request), ) + .route( + "/channels/{id}/mute", + post(channels::channels::mute_channel_request), + ) + .route( + "/channels/{id}/mute", + delete(channels::channels::unmute_channel_request), + ) .route("/channels/{id}", get(channels::channels::get_request)) .route( "/channels/community/{id}", @@ -625,17 +676,8 @@ pub fn routes() -> Router { // uploads .route("/uploads/{id}", get(uploads::get_request)) .route("/uploads/{id}", delete(uploads::delete_request)) - // layouts - .route("/layouts", get(layouts::list_request)) - .route("/layouts", post(layouts::create_request)) - .route("/layouts/{id}", get(layouts::get_request)) - .route("/layouts/{id}", delete(layouts::delete_request)) - .route("/layouts/{id}/title", post(layouts::update_name_request)) - .route( - "/layouts/{id}/privacy", - post(layouts::update_privacy_request), - ) - .route("/layouts/{id}/pages", post(layouts::update_pages_request)) + .route("/uploads/{id}/data", get(uploads::get_json_request)) + .route("/uploads/{id}/alt", post(uploads::update_alt_request)) // services .route("/services", get(services::list_request)) .route("/services", post(services::create_request)) @@ -653,6 +695,17 @@ pub fn routes() -> Router { .route("/domains/{id}", get(domains::get_request)) .route("/domains/{id}", delete(domains::delete_request)) .route("/domains/{id}/data", post(domains::update_data_request)) + // products + .route("/products", get(products::list_request)) + .route("/products", post(products::create_request)) + .route("/products/{id}", get(products::get_request)) + .route("/products/{id}", delete(products::delete_request)) + .route("/products/{id}/name", post(products::update_name_request)) + .route( + "/products/{id}/description", + post(products::update_description_request), + ) + .route("/products/{id}/price", post(products::update_price_request)) } pub fn lw_routes() -> Router { @@ -793,6 +846,11 @@ pub struct UpdateUserAwaitingPurchase { pub awaiting_purchase: bool, } +#[derive(Deserialize)] +pub struct UpdateUserIsDeactivated { + pub is_deactivated: bool, +} + #[derive(Deserialize)] pub struct UpdateNotificationRead { pub read: bool, @@ -818,6 +876,11 @@ pub struct UpdateSecondaryUserRole { pub role: SecondaryPermission, } +#[derive(Deserialize)] +pub struct UpdateUserBanReason { + pub reason: String, +} + #[derive(Deserialize)] pub struct UpdateUserInviteCode { pub invite_code: String, @@ -853,6 +916,8 @@ pub struct CreateQuestion { pub community: String, #[serde(default)] pub mask_owner: bool, + #[serde(default)] + pub asking_about: String, } #[derive(Deserialize)] @@ -947,6 +1012,7 @@ pub struct UpdatePostIsOpen { pub struct CreateApp { pub title: String, pub homepage: String, + #[serde(default)] pub redirect: String, } @@ -970,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, @@ -1055,27 +1126,6 @@ pub struct AwardAchievement { pub name: AchievementName, } -#[derive(Deserialize)] -pub struct CreateLayout { - pub name: String, - pub replaces: CustomizablePage, -} - -#[derive(Deserialize)] -pub struct UpdateLayoutName { - pub name: String, -} - -#[derive(Deserialize)] -pub struct UpdateLayoutPrivacy { - pub privacy: LayoutPrivacy, -} - -#[derive(Deserialize)] -pub struct UpdateLayoutPages { - pub pages: Vec, -} - #[derive(Deserialize)] pub struct CreateService { pub name: String, @@ -1108,3 +1158,53 @@ pub struct CreateDomain { pub struct UpdateDomainData { pub data: Vec<(String, DomainData)>, } + +#[derive(Deserialize)] +pub struct CreateProduct { + pub name: String, + pub description: String, + pub product_type: ProductType, + pub price: ProductPrice, +} + +#[derive(Deserialize)] +pub struct UpdateProductName { + pub name: String, +} + +#[derive(Deserialize)] +pub struct UpdateProductDescription { + pub description: String, +} + +#[derive(Deserialize)] +pub struct UpdateProductPrice { + pub price: ProductPrice, +} + +#[derive(Deserialize)] +pub struct UpdateUploadAlt { + pub alt: String, +} + +#[derive(Deserialize)] +pub struct UpdateAppDataKey { + pub key: String, +} + +#[derive(Deserialize)] +pub struct UpdateAppDataValue { + pub value: String, +} + +#[derive(Deserialize)] +pub struct InsertAppData { + pub key: String, + pub value: String, +} + +#[derive(Deserialize)] +pub struct QueryAppData { + pub query: AppDataSelectQuery, + pub mode: AppDataSelectMode, +} diff --git a/crates/app/src/routes/api/v1/notes.rs b/crates/app/src/routes/api/v1/notes.rs index 6b274ff..979dbf7 100644 --- a/crates/app/src/routes/api/v1/notes.rs +++ b/crates/app/src/routes/api/v1/notes.rs @@ -3,7 +3,7 @@ use axum::{ extract::{Json, Path}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_shared::unix_epoch_timestamp; use crate::{ get_user_from_token, @@ -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/api/v1/notifications.rs b/crates/app/src/routes/api/v1/notifications.rs index 06b2397..de683ae 100644 --- a/crates/app/src/routes/api/v1/notifications.rs +++ b/crates/app/src/routes/api/v1/notifications.rs @@ -5,7 +5,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{oauth, ApiReturn, Error}; pub async fn delete_request( diff --git a/crates/app/src/routes/api/v1/products.rs b/crates/app/src/routes/api/v1/products.rs new file mode 100644 index 0000000..4d53814 --- /dev/null +++ b/crates/app/src/routes/api/v1/products.rs @@ -0,0 +1,234 @@ +use crate::{ + get_user_from_token, + image::{save_webp_buffer, JsonMultipart}, + routes::{ + api::v1::{ + communities::posts::MAXIMUM_FILE_SIZE, CreateProduct, UpdateProductDescription, + UpdateProductName, UpdateProductPrice, + }, + pages::PaginatedQuery, + }, + State, +}; +use axum::{ + extract::{Path, Query}, + response::IntoResponse, + Extension, Json, +}; +use crate::cookie::CookieJar; +use tetratto_core::model::{ + oauth, + products::Product, + uploads::{MediaType, MediaUpload}, + ApiReturn, Error, +}; + +pub async fn get_request( + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + match data.get_product_by_id(id).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => return Json(e.into()), + } +} + +pub async fn list_request( + jar: CookieJar, + Extension(data): Extension, + Query(props): Query, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.get_products_by_user(user.id, 12, props.page).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + JsonMultipart(uploads, req): JsonMultipart, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if uploads.len() > 4 { + return Json( + Error::MiscError("Too many uploads. Please use a maximum of 4".to_string()).into(), + ); + } + + let mut product = Product::new( + user.id, + req.name, + req.description, + req.price, + req.product_type, + ); + + // check sizes + for img in &uploads { + if img.len() > MAXIMUM_FILE_SIZE { + return Json(Error::FileTooLarge.into()); + } + } + + // create uploads + for _ in 0..uploads.len() { + product.uploads.push( + match data + .create_upload(MediaUpload::new(MediaType::Webp, product.owner)) + .await + { + Ok(u) => u.id, + Err(e) => return Json(e.into()), + }, + ); + } + + let product_uploads = product.uploads.clone(); + match data.create_product(product).await { + Ok(x) => { + // store uploads + for (i, upload_id) in product_uploads.iter().enumerate() { + let image = match uploads.get(i) { + Some(img) => img, + None => { + if let Err(e) = data.delete_upload(*upload_id).await { + return Json(e.into()); + } + + continue; + } + }; + + let upload = match data.get_upload_by_id(*upload_id).await { + Ok(u) => u, + Err(e) => return Json(e.into()), + }; + + if let Err(e) = + save_webp_buffer(&upload.path(&data.0.0).to_string(), image.to_vec(), None) + { + return Json(Error::MiscError(e.to_string()).into()); + } + } + + // ... + Json(ApiReturn { + ok: true, + message: "Product created".to_string(), + payload: x.id.to_string(), + }) + } + Err(e) => Json(e.into()), + } +} + +pub async fn update_name_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, oauth::AppScope::UserManageProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_product_name(id, &user, &req.name).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_description_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, oauth::AppScope::UserManageProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .update_product_description(id, &user, &req.description) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_price_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, oauth::AppScope::UserManageProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_product_price(id, &user, req.price).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_product(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/reactions.rs b/crates/app/src/routes/api/v1/reactions.rs index 261a48d..b8589e4 100644 --- a/crates/app/src/routes/api/v1/reactions.rs +++ b/crates/app/src/routes/api/v1/reactions.rs @@ -5,7 +5,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{addr::RemoteAddr, oauth, reactions::Reaction, ApiReturn, Error}; pub async fn get_request( diff --git a/crates/app/src/routes/api/v1/reports.rs b/crates/app/src/routes/api/v1/reports.rs index 459b8a9..8509896 100644 --- a/crates/app/src/routes/api/v1/reports.rs +++ b/crates/app/src/routes/api/v1/reports.rs @@ -1,7 +1,7 @@ use super::CreateReport; use crate::{State, get_user_from_token}; use axum::{Extension, Json, extract::Path, response::IntoResponse}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ApiReturn, Error, moderation::Report}; pub async fn create_request( diff --git a/crates/app/src/routes/api/v1/requests.rs b/crates/app/src/routes/api/v1/requests.rs index 0169b72..90236cc 100644 --- a/crates/app/src/routes/api/v1/requests.rs +++ b/crates/app/src/routes/api/v1/requests.rs @@ -4,7 +4,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{oauth, ApiReturn, Error}; pub async fn delete_request( diff --git a/crates/app/src/routes/api/v1/services.rs b/crates/app/src/routes/api/v1/services.rs index 252fe5a..556924a 100644 --- a/crates/app/src/routes/api/v1/services.rs +++ b/crates/app/src/routes/api/v1/services.rs @@ -6,8 +6,9 @@ use crate::{ State, }; use axum::{extract::Path, response::IntoResponse, Extension, Json}; -use axum_extra::extract::CookieJar; -use tetratto_core::model::{littleweb::Service, oauth, ApiReturn, Error}; +use crate::cookie::CookieJar; +use tetratto_core::model::{auth::AchievementName, littleweb::Service, oauth, ApiReturn, Error}; +use tetratto_shared::unix_epoch_timestamp; pub async fn get_request( Path(id): Path, @@ -47,11 +48,20 @@ pub async fn create_request( Json(req): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateServices) { + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateServices) { Some(ua) => ua, None => return Json(Error::NotAllowed.into()), }; + // award achievement + if let Err(e) = data + .add_achievement(&mut user, AchievementName::CreateSite.into(), true) + .await + { + return Json(e.into()); + } + + // ... match data.create_service(Service::new(req.name, user.id)).await { Ok(x) => Json(ApiReturn { ok: true, @@ -147,11 +157,17 @@ pub async fn update_content_request( // ... match data.update_service_files(id, &user, service.files).await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Service updated".to_string(), - payload: (), - }), + Ok(_) => match data + .update_service_revision(id, unix_epoch_timestamp() as i64) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Service updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + }, Err(e) => Json(e.into()), } } diff --git a/crates/app/src/routes/api/v1/stacks.rs b/crates/app/src/routes/api/v1/stacks.rs index 1fe5c87..e46cfdc 100644 --- a/crates/app/src/routes/api/v1/stacks.rs +++ b/crates/app/src/routes/api/v1/stacks.rs @@ -4,7 +4,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::{ model::{ oauth, diff --git a/crates/app/src/routes/api/v1/uploads.rs b/crates/app/src/routes/api/v1/uploads.rs index 0e7d6ab..a1d11f8 100644 --- a/crates/app/src/routes/api/v1/uploads.rs +++ b/crates/app/src/routes/api/v1/uploads.rs @@ -1,8 +1,8 @@ use std::fs::exists; use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use pathbufd::PathBufD; -use crate::{get_user_from_token, State}; +use crate::{get_user_from_token, routes::api::v1::UpdateUploadAlt, State}; use super::auth::images::read_image; use tetratto_core::model::{carp::CarpGraph, oauth, uploads::MediaType, ApiReturn, Error}; @@ -52,6 +52,24 @@ pub async fn get_request( Ok(([("Content-Type", upload.what.mime())], Body::from(bytes))) } +pub async fn get_json_request( + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + + let upload = match data.get_upload_by_id(id).await { + Ok(u) => u, + Err(e) => return Json(e.into()), + }; + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(upload), + }) +} + pub async fn delete_request( jar: CookieJar, Extension(data): Extension, @@ -72,3 +90,25 @@ pub async fn delete_request( Err(e) => Json(e.into()), } } + +pub async fn update_alt_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageUploads) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_upload_alt(id, &user, &props.alt).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Upload updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/util.rs b/crates/app/src/routes/api/v1/util.rs index 8714968..501f0d9 100644 --- a/crates/app/src/routes/api/v1/util.rs +++ b/crates/app/src/routes/api/v1/util.rs @@ -7,7 +7,7 @@ use axum::{ response::IntoResponse, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use pathbufd::PathBufD; use serde::Deserialize; use tetratto_core::model::permissions::FinePermission; diff --git a/crates/app/src/routes/assets.rs b/crates/app/src/routes/assets.rs index 2aa1bc5..f18ede0 100644 --- a/crates/app/src/routes/assets.rs +++ b/crates/app/src/routes/assets.rs @@ -19,5 +19,5 @@ serve_asset!(atto_js_request: ATTO_JS("text/javascript")); serve_asset!(me_js_request: ME_JS("text/javascript")); serve_asset!(streams_js_request: STREAMS_JS("text/javascript")); serve_asset!(carp_js_request: CARP_JS("text/javascript")); -serve_asset!(layout_editor_js_request: LAYOUT_EDITOR_JS("text/javascript")); serve_asset!(proto_links_request: PROTO_LINKS_JS("text/javascript")); +serve_asset!(app_sdk_request: APP_SDK_JS("text/javascript")); diff --git a/crates/app/src/routes/mod.rs b/crates/app/src/routes/mod.rs index 0872632..cde54f5 100644 --- a/crates/app/src/routes/mod.rs +++ b/crates/app/src/routes/mod.rs @@ -20,11 +20,8 @@ pub fn routes(config: &Config) -> Router { .route("/js/me.js", get(assets::me_js_request)) .route("/js/streams.js", get(assets::streams_js_request)) .route("/js/carp.js", get(assets::carp_js_request)) - .route( - "/js/layout_editor.js", - get(assets::layout_editor_js_request), - ) .route("/js/proto_links.js", get(assets::proto_links_request)) + .route("/js/app_sdk.js", get(assets::app_sdk_request)) .nest_service( "/public", get_service(tower_http::services::ServeDir::new(&config.dirs.assets)), diff --git a/crates/app/src/routes/pages/auth.rs b/crates/app/src/routes/pages/auth.rs index e9f1699..a675e6a 100644 --- a/crates/app/src/routes/pages/auth.rs +++ b/crates/app/src/routes/pages/auth.rs @@ -4,7 +4,7 @@ use axum::{ response::{Html, IntoResponse}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{Error, auth::ConnectionService}; use super::render_error; diff --git a/crates/app/src/routes/pages/chats.rs b/crates/app/src/routes/pages/chats.rs index e6ef791..65ff437 100644 --- a/crates/app/src/routes/pages/chats.rs +++ b/crates/app/src/routes/pages/chats.rs @@ -5,7 +5,7 @@ use axum::{ response::{Html, IntoResponse, Redirect}, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ channels::Message, communities_permissions::CommunityPermission, permissions::FinePermission, Error, diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs index 30d2ce0..6f5524f 100644 --- a/crates/app/src/routes/pages/communities.rs +++ b/crates/app/src/routes/pages/communities.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use super::{render_error, PaginatedQuery, RepostsQuery, SearchedQuery}; use crate::{ assets::initial_context, check_user_blocked_or_private, get_lang, get_user_from_token, State, @@ -8,7 +10,7 @@ use axum::{ response::{Html, IntoResponse}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use serde::Deserialize; use tera::Context; use tetratto_core::model::{ @@ -122,12 +124,20 @@ macro_rules! community_context_bools { ) } else { false + } || if let Some(ref ua) = $user { + ua.permissions.check(tetratto_core::model::permissions::FinePermission::MANAGE_POSTS) + } else { + false }; let can_manage_community = if let Some(ref membership) = membership { membership.role.check(tetratto_core::model::communities_permissions::CommunityPermission::MANAGE_COMMUNITY) } else { false + } || if let Some(ref ua) = $user { + ua.permissions.check(tetratto_core::model::permissions::FinePermission::MANAGE_COMMUNITIES) + } else { + false }; let can_manage_roles = if let Some(ref membership) = membership { @@ -798,7 +808,11 @@ pub async fn post_request( let (_, reposting) = data.0.get_post_reposting(&post, &ignore_users, &user).await; // check question - let question = match data.0.get_post_question(&post, &ignore_users).await { + let question = match data + .0 + .get_post_question(&post, &ignore_users, &mut HashMap::new()) + .await + { Ok(q) => q, Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), }; @@ -918,7 +932,11 @@ pub async fn reposts_request( let reposting = data.0.get_post_reposting(&post, &ignore_users, &user).await; // check question - let question = match data.0.get_post_question(&post, &ignore_users).await { + let question = match data + .0 + .get_post_question(&post, &ignore_users, &mut HashMap::new()) + .await + { Ok(q) => q, Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), }; @@ -1069,7 +1087,11 @@ pub async fn likes_request( .await; // check question - let question = match data.0.get_post_question(&post, &ignore_users).await { + let question = match data + .0 + .get_post_question(&post, &ignore_users, &mut HashMap::new()) + .await + { Ok(q) => q, Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), }; diff --git a/crates/app/src/routes/pages/developer.rs b/crates/app/src/routes/pages/developer.rs index 76d94fe..a9e5f92 100644 --- a/crates/app/src/routes/pages/developer.rs +++ b/crates/app/src/routes/pages/developer.rs @@ -5,8 +5,8 @@ use axum::{ extract::Path, Extension, }; -use axum_extra::extract::CookieJar; -use tetratto_core::model::{permissions::FinePermission, Error}; +use crate::cookie::CookieJar; +use tetratto_core::model::{apps::AppData, permissions::FinePermission, Error}; /// `/developer` pub async fn home_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { @@ -62,9 +62,13 @@ pub async fn app_request( )); } + 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; + context.insert("app", &app); + context.insert("data_limit", &data_limit); // return Ok(Html(data.1.render("developer/app.html", &context).unwrap())) diff --git a/crates/app/src/routes/pages/forge.rs b/crates/app/src/routes/pages/forge.rs index be1769c..c09c04f 100644 --- a/crates/app/src/routes/pages/forge.rs +++ b/crates/app/src/routes/pages/forge.rs @@ -8,7 +8,7 @@ use axum::{ response::{Html, IntoResponse}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{communities::Community, Error}; /// `/forges` diff --git a/crates/app/src/routes/pages/journals.rs b/crates/app/src/routes/pages/journals.rs index cdfba32..8ac0a05 100644 --- a/crates/app/src/routes/pages/journals.rs +++ b/crates/app/src/routes/pages/journals.rs @@ -3,7 +3,7 @@ use axum::{ response::{Html, IntoResponse, Redirect}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use crate::{ assets::initial_context, check_user_blocked_or_private, get_lang, get_user_from_token, @@ -365,7 +365,7 @@ pub async fn global_view_request( Ok(( [( "content-security-policy", - "default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; worker-src * blob:; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *; frame-ancestors *", + "default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; worker-src * blob:; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' blob: *; frame-ancestors *", )], Html(data.1.render("journals/app.html", &context).unwrap()), )) diff --git a/crates/app/src/routes/pages/littleweb.rs b/crates/app/src/routes/pages/littleweb.rs index 9dc5907..18233ff 100644 --- a/crates/app/src/routes/pages/littleweb.rs +++ b/crates/app/src/routes/pages/littleweb.rs @@ -1,18 +1,23 @@ use super::render_error; -use crate::{assets::initial_context, get_lang, get_user_from_token, State}; +use crate::{ + assets::initial_context, get_lang, get_user_from_token, + routes::pages::misc::NotificationsProps, State, +}; use axum::{ response::{Html, IntoResponse}, extract::{Query, Path}, Extension, }; -use axum_extra::extract::CookieJar; -use tetratto_core::model::{littleweb::TLDS_VEC, Error}; +use crate::cookie::CookieJar; +use tetratto_core::model::{littleweb::TLDS_VEC, permissions::SecondaryPermission, Error}; use serde::Deserialize; +use tetratto_shared::hash::salt; /// `/services` pub async fn services_request( jar: CookieJar, Extension(data): Extension, + Query(props): Query, ) -> impl IntoResponse { let data = data.read().await; let user = match get_user_from_token!(jar, data.0) { @@ -24,7 +29,26 @@ pub async fn services_request( } }; - let list = match data.0.get_services_by_user(user.id).await { + let profile = if props.id != 0 { + match data.0.get_user_by_id(props.id).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), + } + } else { + user.clone() + }; + + if profile.id != user.id + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_SERVICES) + { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + + let list = match data.0.get_services_by_user(profile.id).await { Ok(x) => x, Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), }; @@ -32,6 +56,7 @@ pub async fn services_request( let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; context.insert("list", &list); + context.insert("profile", &profile); // return Ok(Html( @@ -43,6 +68,7 @@ pub async fn services_request( pub async fn domains_request( jar: CookieJar, Extension(data): Extension, + Query(props): Query, ) -> impl IntoResponse { let data = data.read().await; let user = match get_user_from_token!(jar, data.0) { @@ -54,7 +80,26 @@ pub async fn domains_request( } }; - let list = match data.0.get_domains_by_user(user.id).await { + let profile = if props.id != 0 { + match data.0.get_user_by_id(props.id).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), + } + } else { + user.clone() + }; + + if profile.id != user.id + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_DOMAINS) + { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + + let list = match data.0.get_domains_by_user(profile.id).await { Ok(x) => x, Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), }; @@ -64,6 +109,7 @@ pub async fn domains_request( context.insert("list", &list); context.insert("tlds", &*TLDS_VEC); + context.insert("profile", &profile); // return Ok(Html( @@ -99,7 +145,11 @@ pub async fn service_request( Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), }; - if user.id != service.owner { + if user.id != service.owner + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_SERVICES) + { return Err(Html( render_error(Error::NotAllowed, &jar, &data, &None).await, )); @@ -153,7 +203,11 @@ pub async fn domain_request( Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), }; - if user.id != domain.owner { + if user.id != domain.owner + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_DOMAINS) + { return Err(Html( render_error(Error::NotAllowed, &jar, &data, &None).await, )); @@ -177,12 +231,26 @@ pub async fn browser_home_request( let data = data.read().await; let user = get_user_from_token!(jar, data.0); + // update session + let session = salt(); + + if let Some(ref ua) = user { + if let Err(e) = data.0.update_user_browser_session(ua.id, &session).await { + return Err(Html(render_error(e.into(), &jar, &data, &None).await)); + } + } + + // ... let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &user).await; + context.insert("path", &""); + context.insert("session", &session); // return - Html(data.1.render("littleweb/browser.html", &context).unwrap()) + Ok(Html( + data.1.render("littleweb/browser.html", &context).unwrap(), + )) } /// `/net/{uri}` @@ -202,10 +270,24 @@ pub async fn browser_request( uri = format!("atto://{uri}"); } + // update session + let session = salt(); + + if let Some(ref ua) = user { + if let Err(e) = data.0.update_user_browser_session(ua.id, &session).await { + return Err(Html(render_error(e.into(), &jar, &data, &None).await)); + } + } + + // ... let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &user).await; - context.insert("path", &uri); + + context.insert("session", &session); + context.insert("path", &uri.replace("atto://", "")); // return - Html(data.1.render("littleweb/browser.html", &context).unwrap()) + Ok(Html( + data.1.render("littleweb/browser.html", &context).unwrap(), + )) } diff --git a/crates/app/src/routes/pages/marketplace.rs b/crates/app/src/routes/pages/marketplace.rs new file mode 100644 index 0000000..8d5a3be --- /dev/null +++ b/crates/app/src/routes/pages/marketplace.rs @@ -0,0 +1,107 @@ +use super::render_error; +use crate::{ + assets::initial_context, get_lang, get_user_from_token, State, routes::pages::PaginatedQuery, +}; +use axum::{ + extract::Query, + response::{Html, IntoResponse}, + Extension, +}; +use crate::cookie::CookieJar; +use tetratto_core::model::Error; + +/// `/settings/seller` +pub async fn seller_settings_request( + jar: CookieJar, + Extension(data): Extension, + Query(props): Query, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + let products = match data.0.get_products_by_user(user.id, 12, props.page).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + + context.insert("list", &products); + context.insert("page", &props.page); + + // return + Ok(Html( + data.1.render("marketplace/seller.html", &context).unwrap(), + )) +} + +pub async fn connection_return_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let mut user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + // update user + user.seller_data.completed_onboarding = true; + if let Err(e) = data + .0 + .update_user_seller_data(user.id, user.seller_data.clone()) + .await + { + return Err(Html(render_error(e, &jar, &data, &None).await)); + } + + // ... + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + context.insert("connection_type", "return"); + + // return + Ok(Html( + data.1 + .render("auth/seller_connection.html", &context) + .unwrap(), + )) +} + +pub async fn connection_reload_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + context.insert("connection_type", "reload"); + + // return + Ok(Html( + data.1 + .render("auth/seller_connection.html", &context) + .unwrap(), + )) +} diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs index e65f4b5..7ee2f72 100644 --- a/crates/app/src/routes/pages/misc.rs +++ b/crates/app/src/routes/pages/misc.rs @@ -7,7 +7,7 @@ use axum::{ response::{Html, IntoResponse}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use serde::Deserialize; use tetratto_core::model::{ auth::{AchievementName, DefaultTimelineChoice, ACHIEVEMENTS}, @@ -58,7 +58,7 @@ pub async fn index_request( let list = match data .0 - .get_posts_from_user_communities(user.id, 12, req.page) + .get_posts_from_user_communities(user.id, 12, req.page, &user) .await { Ok(l) => match data @@ -725,7 +725,7 @@ pub async fn swiss_army_timeline_request( DefaultTimelineChoice::MyCommunities => { if let Some(ref ua) = user { data.0 - .get_posts_from_user_communities(ua.id, 12, req.page) + .get_posts_from_user_communities(ua.id, 12, req.page, ua) .await } else { return Err(Html( diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 6ce6318..2f3c9d5 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -5,6 +5,7 @@ pub mod developer; pub mod forge; pub mod journals; pub mod littleweb; +pub mod marketplace; pub mod misc; pub mod mod_panel; pub mod profile; @@ -14,14 +15,13 @@ use axum::{ routing::{get, post}, Router, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use serde::Deserialize; use tetratto_core::{ - DataManager, model::{Error, auth::User}, }; -use crate::{assets::initial_context, get_lang}; +use crate::{assets::initial_context, get_lang, InnerState}; pub fn routes() -> Router { Router::new() @@ -77,6 +77,14 @@ pub fn routes() -> Router { "/auth/connections_link/app/{id}", get(developer::connection_callback_request), ) + .route( + "/auth/connections_link/seller/reload", + get(marketplace::connection_reload_request), + ) + .route( + "/auth/connections_link/seller/return", + get(marketplace::connection_return_request), + ) // profile .route("/settings", get(profile::settings_request)) .route("/@{username}", get(profile::posts_request)) @@ -147,6 +155,11 @@ pub fn routes() -> Router { .route("/domains/{id}", get(littleweb::domain_request)) .route("/net", get(littleweb::browser_home_request)) .route("/net/{*uri}", get(littleweb::browser_request)) + // marketplace + .route( + "/settings/seller", + get(marketplace::seller_settings_request), + ) } pub fn lw_routes() -> Router { @@ -156,7 +169,7 @@ pub fn lw_routes() -> Router { pub async fn render_error( e: Error, jar: &CookieJar, - data: &(DataManager, tera::Tera, reqwest::Client), + data: &InnerState, user: &Option, ) -> String { let lang = get_lang!(jar, data.0); diff --git a/crates/app/src/routes/pages/mod_panel.rs b/crates/app/src/routes/pages/mod_panel.rs index 7a9b6f7..2b82cf1 100644 --- a/crates/app/src/routes/pages/mod_panel.rs +++ b/crates/app/src/routes/pages/mod_panel.rs @@ -5,7 +5,7 @@ use axum::{ response::{Html, IntoResponse}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use serde::Deserialize; use tetratto_core::{ cache::Cache, diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index 186d291..3f6274b 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -8,7 +8,7 @@ use axum::{ extract::{Path, Query}, response::{Html, IntoResponse}, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use serde::Deserialize; use tera::Context; use tetratto_core::model::{ @@ -63,13 +63,31 @@ pub async fn settings_request( } }; + let followers = match data + .0 + .fill_userfollows_with_initiator( + data.0 + .get_userfollows_by_receiver(profile.id, 12, req.page) + .await + .unwrap_or(Vec::new()), + &None, + false, + ) + .await + { + Ok(r) => r, + Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), + }; + let following = match data .0 .fill_userfollows_with_receiver( data.0 - .get_userfollows_by_initiator_all(profile.id) + .get_userfollows_by_initiator(profile.id, 12, req.page) .await .unwrap_or(Vec::new()), + &None, + false, ) .await { @@ -136,6 +154,7 @@ pub async fn settings_request( context.insert("page", &req.page); context.insert("uploads", &uploads); context.insert("stacks", &stacks); + context.insert("followers", &followers); context.insert("following", &following); context.insert("blocks", &blocks); context.insert("stackblocks", &stackblocks); @@ -712,13 +731,28 @@ 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 .get_userfollows_by_initiator(other_user.id, 12, props.page) .await { - Ok(l) => match data.0.fill_userfollows_with_receiver(l).await { + Ok(l) => match data.0.fill_userfollows_with_receiver(l, &user, true).await { Ok(l) => l, Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), }, @@ -807,13 +841,28 @@ 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 .get_userfollows_by_receiver(other_user.id, 12, props.page) .await { - Ok(l) => match data.0.fill_userfollows_with_initiator(l).await { + Ok(l) => match data.0.fill_userfollows_with_initiator(l, &user, true).await { Ok(l) => l, Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), }, diff --git a/crates/app/src/routes/pages/stacks.rs b/crates/app/src/routes/pages/stacks.rs index e8285e9..f4ee986 100644 --- a/crates/app/src/routes/pages/stacks.rs +++ b/crates/app/src/routes/pages/stacks.rs @@ -3,7 +3,7 @@ use axum::{ response::{Html, IntoResponse}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ auth::User, permissions::FinePermission, diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index ffbd2c2..bd7ac03 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,26 +1,38 @@ [package] name = "tetratto-core" -version = "11.0.0" +description = "The core behind Tetratto" +version = "12.0.2" edition = "2024" +authors.workspace = true +repository.workspace = true +license.workspace = true +homepage.workspace = true + +[features] +database = ["dep:oiseau", "dep:base64", "dep:base16ct", "dep:async-recursion", "dep:md-5"] +types = ["dep:totp-rs", "dep:paste", "dep:bitflags"] +sdk = ["types", "dep:reqwest"] +default = ["database", "types", "sdk"] [dependencies] pathbufd = "0.1.4" serde = { version = "1.0.219", features = ["derive"] } -toml = "0.8.23" -tetratto-shared = { path = "../shared" } -tetratto-l10n = { path = "../l10n" } -serde_json = "1.0.140" -totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"] } -reqwest = { version = "0.12.20", features = ["json"] } -bitflags = "2.9.1" -async-recursion = "1.1.1" -md-5 = "0.10.6" -base16ct = { version = "0.2.0", features = ["alloc"] } -base64 = "0.22.1" +toml = "0.9.2" +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 } +reqwest = { version = "0.12.22", features = ["json", "multipart"], optional = true } +bitflags = { version = "2.9.1", optional = true } +async-recursion = { version = "1.1.1", optional = true } +md-5 = { version = "0.10.6", optional = true } +base16ct = { version = "0.2.0", features = ["alloc"], optional = true } +base64 = { version = "0.22.1", optional = true } emojis = "0.7.0" regex = "1.11.1" oiseau = { version = "0.1.2", default-features = false, features = [ "postgres", "redis", -] } -paste = "1.0.15" +], optional = true } +paste = { version = "1.0.15", optional = true } +tokio = { version = "1.46.1", features = ["macros", "rt-multi-thread"] } diff --git a/crates/core/examples/sdk_db.rs b/crates/core/examples/sdk_db.rs new file mode 100644 index 0000000..becdca1 --- /dev/null +++ b/crates/core/examples/sdk_db.rs @@ -0,0 +1,65 @@ +extern crate tetratto_core; +use tetratto_core::{ + model::apps::{AppDataSelectMode, AppDataSelectQuery, AppDataQueryResult}, + sdk::{DataClient, SimplifiedQuery}, +}; +use std::env::var; + +// mirror of https://trisua.com/t/tetratto/src/branch/master/example/app_sdk_test.js ... but in rust +#[tokio::main] +pub async fn main() { + let client = DataClient::new( + Some("http://localhost:4118".to_string()), + var("APP_API_KEY").unwrap(), + ); + + println!("data used: {}", client.get_app().await.unwrap().data_used); + + // record insert + client + .insert("rust_test".to_string(), "Hello, world!".to_string()) + .await + .unwrap(); + println!("record created"); + println!("data used: {}", client.get_app().await.unwrap().data_used); + + // testing record query then delete + let record = match client + .query(&SimplifiedQuery { + query: AppDataSelectQuery::KeyIs("rust_test".to_string()), + mode: AppDataSelectMode::One(0), + }) + .await + .unwrap() + { + AppDataQueryResult::One(x) => x, + AppDataQueryResult::Many(_) => unreachable!(), + }; + + println!("{:?}", record); + + client + .update(record.id, "Hello, world! 1".to_string()) + .await + .unwrap(); + println!("record updated"); + println!("data used: {}", client.get_app().await.unwrap().data_used); + + let record = match client + .query(&SimplifiedQuery { + query: AppDataSelectQuery::KeyIs("rust_test".to_string()), + mode: AppDataSelectMode::One(0), + }) + .await + .unwrap() + { + AppDataQueryResult::One(x) => x, + AppDataQueryResult::Many(_) => unreachable!(), + }; + + println!("{:?}", record); + + client.remove(record.id).await.unwrap(); + println!("record deleted"); + println!("data used: {}", client.get_app().await.unwrap().data_used); +} diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 309c851..e1637b1 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -173,13 +173,15 @@ pub struct ConnectionsConfig { /// - Use testing card numbers: #[derive(Clone, Serialize, Deserialize, Debug, Default)] pub struct StripeConfig { - /// Payment link from the Stripe dashboard. + /// Your Stripe API secret. + pub secret: String, + /// Payment links from the Stripe dashboard. /// /// 1. Create a product and set the price for your membership /// 2. Set the product price to a recurring subscription /// 3. Create a payment link for the new product /// 4. The payment link pasted into this config field should NOT include a query string - pub payment_link: String, + pub payment_links: StripePaymentLinks, /// To apply benefits to user accounts, you should then go into the Stripe developer /// "workbench" and create a new webhook. The webhook needs the scopes: /// `invoice.payment_succeeded`, `customer.subscription.deleted`, `checkout.session.completed`. @@ -192,8 +194,30 @@ pub struct StripeConfig { /// /// pub billing_portal_url: String, - /// The text representation of the price of supporter. (like `$4 USD`) - pub supporter_price_text: String, + /// The text representation of prices. (like `$4 USD`) + pub price_texts: StripePriceTexts, + /// Product IDs from the Stripe dashboard. + /// + /// These are checked when we receive a webhook to ensure we provide the correct product. + pub product_ids: StripeProductIds, +} + +#[derive(Clone, Serialize, Deserialize, Debug, Default)] +pub struct StripePriceTexts { + pub supporter: String, + pub dev_pass: String, +} + +#[derive(Clone, Serialize, Deserialize, Debug, Default)] +pub struct StripePaymentLinks { + pub supporter: String, + pub dev_pass: String, +} + +#[derive(Clone, Serialize, Deserialize, Debug, Default)] +pub struct StripeProductIds { + pub supporter: String, + pub dev_pass: String, } /// Manuals config (search help, etc) diff --git a/crates/core/src/database/app_data.rs b/crates/core/src/database/app_data.rs new file mode 100644 index 0000000..9aeafc1 --- /dev/null +++ b/crates/core/src/database/app_data.rs @@ -0,0 +1,189 @@ +use oiseau::cache::Cache; +use crate::model::apps::{AppDataQuery, AppDataQueryResult, AppDataSelectMode}; +use crate::model::{apps::AppData, permissions::FinePermission, Error, Result}; +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 = 26_214_400; + +impl DataManager { + /// Get a [`AppData`] from an SQL row. + pub(crate) fn get_app_data_from_row(x: &PostgresRow) -> AppData { + AppData { + id: get!(x->0(i64)) as usize, + app: get!(x->1(i64)) as usize, + key: get!(x->2(String)), + value: get!(x->3(String)), + } + } + + auto_method!(get_app_data_by_id(usize as i64)@get_app_data_from_row -> "SELECT * FROM app_data WHERE id = $1" --name="app_data" --returns=AppData --cache-key-tmpl="atto.app_data:{}"); + + /// Get all app_data by app. + /// + /// # Arguments + /// * `id` - the ID of the app to fetch app_data for + pub async fn get_app_data_by_app(&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 app_data WHERE app = $1 ORDER BY created DESC", + &[&(id as i64)], + |x| { Self::get_app_data_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("app_data".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get all app_data by the given query. + pub async fn query_app_data(&self, query: AppDataQuery) -> Result { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let query_str = query.to_string().replace("%q%", &query.query.selector()); + + let res = match query.mode { + AppDataSelectMode::One(_) => AppDataQueryResult::One( + match query_row!(&conn, &query_str, params![&query.query.to_string()], |x| { + Ok(Self::get_app_data_from_row(x)) + }) { + Ok(x) => x, + Err(_) => return Err(Error::GeneralNotFound("app_data".to_string())), + }, + ), + AppDataSelectMode::Many(_, _) => AppDataQueryResult::Many( + match query_rows!(&conn, &query_str, params![&query.query.to_string()], |x| { + Self::get_app_data_from_row(x) + }) { + Ok(x) => x, + Err(_) => return Err(Error::GeneralNotFound("app_data".to_string())), + }, + ), + AppDataSelectMode::ManyJson(_, _, _) => AppDataQueryResult::Many( + match query_rows!(&conn, &query_str, params![&query.query.to_string()], |x| { + Self::get_app_data_from_row(x) + }) { + Ok(x) => x, + Err(_) => return Err(Error::GeneralNotFound("app_data".to_string())), + }, + ), + }; + + Ok(res) + } + + /// Delete all app_data matched by the given query. + pub async fn query_delete_app_data(&self, query: AppDataQuery) -> Result<()> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let query_str = query + .to_string() + .replace("%q%", &query.query.selector()) + .replace("SELECT * FROM", "SELECT id FROM"); + + if let Err(e) = execute!( + &conn, + &format!("DELETE FROM app_data WHERE id IN ({query_str})"), + params![&query.query.to_string()] + ) { + return Err(Error::MiscError(e.to_string())); + } + + Ok(()) + } + + const MAXIMUM_FREE_APP_DATA: usize = 5; + const MAXIMUM_DATA_SIZE: usize = 205_000; + + /// Create a new app_data in the database. + /// + /// # Arguments + /// * `data` - a mock [`AppData`] object to insert + pub async fn create_app_data(&self, data: AppData) -> Result { + let app = self.get_app_by_id(data.app).await?; + + // check values + if data.key.len() < 1 { + return Err(Error::DataTooShort("key".to_string())); + } else if data.key.len() > 128 { + return Err(Error::DataTooLong("key".to_string())); + } + + 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())); + } + + // check number of app_data + let owner = self.get_user_by_id(app.owner).await?; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + let app_data = self + .get_table_row_count_where("app_data", &format!("app = {}", data.app)) + .await? as usize; + + if app_data >= Self::MAXIMUM_FREE_APP_DATA { + return Err(Error::MiscError( + "You already have the maximum number of app_data you can have".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 app_data VALUES ($1, $2, $3, $4)", + params![ + &(data.id as i64), + &(data.app as i64), + &data.key, + &data.value + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_app_data(&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 = execute!(&conn, "DELETE FROM app_data WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.0.1.remove(format!("atto.app_data:{}", id)).await; + Ok(()) + } + + auto_method!(update_app_data_key(&str) -> "UPDATE app_data SET k = $1 WHERE id = $2" --cache-key-tmpl="atto.app_data:{}"); + auto_method!(update_app_data_value(&str) -> "UPDATE app_data SET v = $1 WHERE id = $2" --cache-key-tmpl="atto.app_data:{}"); +} diff --git a/crates/core/src/database/apps.rs b/crates/core/src/database/apps.rs index f24b427..72334a8 100644 --- a/crates/core/src/database/apps.rs +++ b/crates/core/src/database/apps.rs @@ -1,16 +1,13 @@ use oiseau::cache::Cache; use crate::model::{ - apps::{AppQuota, ThirdPartyApp}, + apps::{AppQuota, ThirdPartyApp, DeveloperPassStorageQuota}, auth::User, oauth::AppScope, - permissions::FinePermission, + permissions::{FinePermission, SecondaryPermission}, Error, Result, }; use crate::{auto_method, DataManager}; - -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_rows, params}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { /// Get a [`ThirdPartyApp`] from an SQL row. @@ -26,10 +23,14 @@ impl DataManager { banned: get!(x->7(i32)) as i8 == 1, grants: get!(x->8(i32)) as usize, 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(), } } auto_method!(get_app_by_id(usize as i64)@get_app_from_row -> "SELECT * FROM apps WHERE id = $1" --name="app" --returns=ThirdPartyApp --cache-key-tmpl="atto.app:{}"); + auto_method!(get_app_by_api_key(&str)@get_app_from_row -> "SELECT * FROM apps WHERE api_key = $1" --name="app" --returns=ThirdPartyApp --cache-key-tmpl="atto.app_k:{}"); /// Get all apps by user. /// @@ -72,10 +73,15 @@ impl DataManager { // check number of apps let owner = self.get_user_by_id(data.owner).await?; - if !owner.permissions.check(FinePermission::SUPPORTER) { - let apps = self.get_apps_by_owner(data.owner).await?; + if !owner + .secondary_permissions + .check(SecondaryPermission::DEVELOPER_PASS) + { + let apps = self + .get_table_row_count_where("apps", &format!("owner = {}", owner.id)) + .await? as usize; - if apps.len() >= Self::MAXIMUM_FREE_APPS { + if apps >= Self::MAXIMUM_FREE_APPS { return Err(Error::MiscError( "You already have the maximum number of apps you can have".to_string(), )); @@ -90,7 +96,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO apps VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + "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), @@ -102,6 +108,9 @@ impl DataManager { &{ if data.banned { 1 } else { 0 } }, &(data.grants as i32), &serde_json::to_string(&data.scopes).unwrap(), + &data.api_key, + &(data.data_used as i32), + &serde_json::to_string(&data.storage_capacity).unwrap(), ] ); @@ -132,16 +141,39 @@ impl DataManager { return Err(Error::DatabaseError(e.to_string())); } - self.0.1.remove(format!("atto.app:{}", id)).await; + self.cache_clear_app(&app).await; + + // remove data + let res = execute!( + &conn, + "DELETE FROM app_data WHERE app = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... Ok(()) } - auto_method!(update_app_title(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); - auto_method!(update_app_homepage(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET homepage = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); - auto_method!(update_app_redirect(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET redirect = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); - auto_method!(update_app_quota_status(AppQuota) -> "UPDATE apps SET quota_status = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.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="atto.app:{}"); + pub async fn cache_clear_app(&self, app: &ThirdPartyApp) { + self.0.1.remove(format!("atto.app:{}", app.id)).await; + self.0.1.remove(format!("atto.app_k:{}", app.api_key)).await; + } - auto_method!(incr_app_grants() -> "UPDATE apps SET grants = grants + 1 WHERE id = $1" --cache-key-tmpl="atto.app:{}" --incr); - auto_method!(decr_app_grants()@get_app_by_id -> "UPDATE apps SET grants = grants - 1 WHERE id = $1" --cache-key-tmpl="atto.app:{}" --decr=grants); + auto_method!(update_app_title(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET title = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_app); + auto_method!(update_app_homepage(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET homepage = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_app); + auto_method!(update_app_redirect(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET redirect = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_app); + 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); + + auto_method!(incr_app_grants()@get_app_by_id -> "UPDATE apps SET grants = grants + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_app --incr); + auto_method!(decr_app_grants()@get_app_by_id -> "UPDATE apps SET grants = grants - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_app --decr=grants); } diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index fbf229b..4ced643 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -1,7 +1,8 @@ use super::common::NAME_REGEX; use oiseau::cache::Cache; use crate::model::auth::{ - Achievement, AchievementName, AchievementRarity, Notification, UserConnections, ACHIEVEMENTS, + Achievement, AchievementName, AchievementRarity, Notification, StripeSellerData, + UserConnections, ACHIEVEMENTS, }; use crate::model::moderation::AuditLogEntry; use crate::model::oauth::AuthGrant; @@ -99,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(), @@ -116,12 +124,18 @@ impl DataManager { achievements: serde_json::from_str(&get!(x->23(String)).to_string()).unwrap(), awaiting_purchase: get!(x->24(i32)) as i8 == 1, was_purchased: get!(x->25(i32)) as i8 == 1, + browser_session: get!(x->26(String)), + seller_data: serde_json::from_str(&get!(x->27(String)).to_string()).unwrap(), + ban_reason: get!(x->28(String)), + channel_mutes: serde_json::from_str(&get!(x->29(String)).to_string()).unwrap(), + is_deactivated: get!(x->30(i32)) as i8 == 1, } } auto_method!(get_user_by_id(usize as i64)@get_user_from_row -> "SELECT * FROM users WHERE id = $1" --name="user" --returns=User --cache-key-tmpl="atto.user:{}"); auto_method!(get_user_by_username(&str)@get_user_from_row -> "SELECT * FROM users WHERE username = $1" --name="user" --returns=User --cache-key-tmpl="atto.user:{}"); auto_method!(get_user_by_username_no_cache(&str)@get_user_from_row -> "SELECT * FROM users WHERE username = $1" --name="user" --returns=User); + auto_method!(get_user_by_browser_session(&str)@get_user_from_row -> "SELECT * FROM users WHERE browser_session = $1" --name="user" --returns=User); /// Get a user given just their ID. Returns the void user if the user doesn't exist. /// @@ -190,8 +204,8 @@ impl DataManager { let res = query_row!( &conn, - "SELECT * FROM users WHERE (SELECT jsonb_array_elements(grants::jsonb) @> ('{\"token\":\"' || $1 || '\"}')::jsonb)", - &[&token], + "SELECT * FROM users WHERE grants LIKE $1", + &[&format!("%\"token\":\"{token}\"%")], |x| Ok(Self::get_user_from_row(x)) ); @@ -271,7 +285,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26)", + "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31)", params![ &(data.id as i64), &(data.created as i64), @@ -299,6 +313,11 @@ impl DataManager { &serde_json::to_string(&data.achievements).unwrap(), &if data.awaiting_purchase { 1_i32 } else { 0_i32 }, &if data.was_purchased { 1_i32 } else { 0_i32 }, + &data.browser_session, + &serde_json::to_string(&data.seller_data).unwrap(), + &data.ban_reason, + &serde_json::to_string(&data.channel_mutes).unwrap(), + &if data.is_deactivated { 1_i32 } else { 0_i32 }, ] ); @@ -315,7 +334,7 @@ impl DataManager { /// * `id` - the ID of the user /// * `password` - the current password of the user /// * `force` - if we should delete even if the given password is incorrect - pub async fn delete_user(&self, id: usize, password: &str, force: bool) -> Result<()> { + pub async fn delete_user(&self, id: usize, password: &str, force: bool) -> Result { let user = self.get_user_by_id(id).await?; if (hash_salted(password.to_string(), user.salt.clone()) != user.password) && !force { @@ -525,6 +544,11 @@ impl DataManager { self.delete_userfollow(follow.id, &user, true).await?; } + // delete apps + for app in self.get_apps_by_owner(id).await? { + self.delete_app(app.id, &user).await?; + } + // remove images let avatar = PathBufD::current().extend(&[ self.0.0.dirs.media.as_str(), @@ -575,7 +599,7 @@ impl DataManager { } // ... - Ok(()) + Ok(user) } pub async fn update_user_verified_status(&self, id: usize, x: bool, user: User) -> Result<()> { @@ -616,6 +640,44 @@ impl DataManager { Ok(()) } + pub async fn update_user_is_deactivated(&self, id: usize, x: bool, user: User) -> Result<()> { + if id != user.id && !user.permissions.check(FinePermission::MANAGE_USERS) { + return Err(Error::NotAllowed); + } + + let other_user = self.get_user_by_id(id).await?; + + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "UPDATE users SET is_deactivated = $1 WHERE id = $2", + params![&{ if x { 1 } else { 0 } }, &(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.cache_clear_user(&other_user).await; + + // create audit log entry + self.create_audit_log_entry(AuditLogEntry::new( + user.id, + format!( + "invoked `update_user_is_deactivated` with x value `{}` and y value `{}`", + other_user.id, x + ), + )) + .await?; + + // ... + Ok(()) + } + pub async fn update_user_password( &self, id: usize, @@ -993,6 +1055,10 @@ impl DataManager { auto_method!(update_user_associated(Vec)@get_user_by_id -> "UPDATE users SET associated = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_achievements(Vec)@get_user_by_id -> "UPDATE users SET achievements = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_invite_code(i64)@get_user_by_id -> "UPDATE users SET invite_code = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); + auto_method!(update_user_browser_session(&str)@get_user_by_id -> "UPDATE users SET browser_session = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); + auto_method!(update_user_seller_data(StripeSellerData)@get_user_by_id -> "UPDATE users SET seller_data = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); + auto_method!(update_user_ban_reason(&str)@get_user_by_id -> "UPDATE users SET ban_reason = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); + auto_method!(update_user_channel_mutes(Vec)@get_user_by_id -> "UPDATE users SET channel_mutes = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(get_user_by_stripe_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE stripe_id = $1" --name="user" --returns=User); auto_method!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); diff --git a/crates/core/src/database/channels.rs b/crates/core/src/database/channels.rs index ee42d4b..c1e9938 100644 --- a/crates/core/src/database/channels.rs +++ b/crates/core/src/database/channels.rs @@ -5,7 +5,6 @@ use crate::model::{ communities_permissions::CommunityPermission, channels::Channel, }; use crate::{auto_method, DataManager}; - use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 969b014..5e10783 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -40,9 +40,15 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_NOTES).unwrap(); execute!(&conn, common::CREATE_TABLE_MESSAGE_REACTIONS).unwrap(); execute!(&conn, common::CREATE_TABLE_INVITE_CODES).unwrap(); - execute!(&conn, common::CREATE_TABLE_LAYOUTS).unwrap(); execute!(&conn, common::CREATE_TABLE_DOMAINS).unwrap(); 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(); + } self.0 .1 @@ -75,6 +81,26 @@ impl DataManager { Ok(res.unwrap()) } + + pub async fn get_table_row_count_where(&self, table: &str, r#where: &str) -> Result { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_row!( + &conn, + &format!("SELECT COUNT(*)::int FROM {} WHERE {}", table, r#where), + params![], + |x| Ok(x.get::(0)) + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(res.unwrap()) + } } #[macro_export] diff --git a/crates/core/src/database/communities.rs b/crates/core/src/database/communities.rs index fa7c234..df107e9 100644 --- a/crates/core/src/database/communities.rs +++ b/crates/core/src/database/communities.rs @@ -3,6 +3,7 @@ use super::common::NAME_REGEX; use oiseau::cache::Cache; use crate::model::communities::{CommunityContext, CommunityJoinAccess, CommunityMembership}; use crate::model::communities_permissions::CommunityPermission; +use crate::model::permissions::SecondaryPermission; use crate::model::{ Error, Result, auth::User, @@ -255,7 +256,11 @@ impl DataManager { // check is_forge // only supporters can CREATE forge communities... anybody can contribute to them - if data.is_forge && !owner.permissions.check(FinePermission::SUPPORTER) { + if data.is_forge + && !owner + .secondary_permissions + .check(SecondaryPermission::DEVELOPER_PASS) + { return Err(Error::RequiresSupporter); } diff --git a/crates/core/src/database/domains.rs b/crates/core/src/database/domains.rs index 672de1c..737bd5f 100644 --- a/crates/core/src/database/domains.rs +++ b/crates/core/src/database/domains.rs @@ -74,7 +74,7 @@ impl DataManager { Ok(res.unwrap()) } - const MAXIMUM_FREE_DOMAINS: usize = 5; + const MAXIMUM_FREE_DOMAINS: usize = 10; /// Create a new domain in the database. /// diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index efa3eae..bccbfb9 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -1,3 +1,4 @@ +pub const VERSION_MIGRATIONS: &str = include_str!("./sql/version_migrations.sql"); pub const CREATE_TABLE_USERS: &str = include_str!("./sql/create_users.sql"); pub const CREATE_TABLE_COMMUNITIES: &str = include_str!("./sql/create_communities.sql"); pub const CREATE_TABLE_POSTS: &str = include_str!("./sql/create_posts.sql"); @@ -27,6 +28,8 @@ pub const CREATE_TABLE_JOURNALS: &str = include_str!("./sql/create_journals.sql" pub const CREATE_TABLE_NOTES: &str = include_str!("./sql/create_notes.sql"); pub const CREATE_TABLE_MESSAGE_REACTIONS: &str = include_str!("./sql/create_message_reactions.sql"); pub const CREATE_TABLE_INVITE_CODES: &str = include_str!("./sql/create_invite_codes.sql"); -pub const CREATE_TABLE_LAYOUTS: &str = include_str!("./sql/create_layouts.sql"); 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_app_data.sql b/crates/core/src/database/drivers/sql/create_app_data.sql new file mode 100644 index 0000000..64cdd3f --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_app_data.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS app_data ( + id BIGINT NOT NULL PRIMARY KEY, + app BIGINT NOT NULL, + k TEXT NOT NULL, + v TEXT NOT NULL +) diff --git a/crates/core/src/database/drivers/sql/create_apps.sql b/crates/core/src/database/drivers/sql/create_apps.sql index 575ce5c..70f2b8d 100644 --- a/crates/core/src/database/drivers/sql/create_apps.sql +++ b/crates/core/src/database/drivers/sql/create_apps.sql @@ -8,5 +8,7 @@ CREATE TABLE IF NOT EXISTS apps ( quota_status TEXT NOT NULL, banned INT NOT NULL, grants INT NOT NULL, - scopes TEXT NOT NULL + scopes TEXT NOT NULL, + data_used INT NOT NULL CHECK (data_used >= 0), + storage_capacity TEXT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/create_layouts.sql b/crates/core/src/database/drivers/sql/create_layouts.sql deleted file mode 100644 index 3f28c0a..0000000 --- a/crates/core/src/database/drivers/sql/create_layouts.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE IF NOT EXISTS layouts ( - id BIGINT NOT NULL PRIMARY KEY, - created BIGINT NOT NULL, - owner BIGINT NOT NULL, - title TEXT NOT NULL, - privacy TEXT NOT NULL, - pages TEXT NOT NULL, - replaces 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/create_products.sql b/crates/core/src/database/drivers/sql/create_products.sql new file mode 100644 index 0000000..4a972aa --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_products.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS products ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL, + likes INT NOT NULL, + dislikes INT NOT NULL, + product_type TEXT NOT NULL, + price TEXT NOT NULL, + uploads TEXT NOT NULL +) diff --git a/crates/core/src/database/drivers/sql/create_services.sql b/crates/core/src/database/drivers/sql/create_services.sql index 78277b5..ecb04d6 100644 --- a/crates/core/src/database/drivers/sql/create_services.sql +++ b/crates/core/src/database/drivers/sql/create_services.sql @@ -3,5 +3,6 @@ CREATE TABLE IF NOT EXISTS services ( created BIGINT NOT NULL, owner BIGINT NOT NULL, name TEXT NOT NULL, - files TEXT NOT NULL + files TEXT NOT NULL, + revision BIGINT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/create_uploads.sql b/crates/core/src/database/drivers/sql/create_uploads.sql index a563080..57d4037 100644 --- a/crates/core/src/database/drivers/sql/create_uploads.sql +++ b/crates/core/src/database/drivers/sql/create_uploads.sql @@ -2,5 +2,6 @@ CREATE TABLE IF NOT EXISTS uploads ( id BIGINT NOT NULL PRIMARY KEY, created BIGINT NOT NULL, owner BIGINT NOT NULL, - what TEXT NOT NULL + what TEXT NOT NULL, + alt TEXT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql index 3257a2d..6a939e5 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -20,8 +20,14 @@ CREATE TABLE IF NOT EXISTS users ( stripe_id TEXT NOT NULL, grants TEXT NOT NULL, associated TEXT NOT NULL, + invite_code TEXT NOT NULL, secondary_permissions INT NOT NULL, achievements TEXT NOT NULL, awaiting_purchase INT NOT NULL, - was_purchased INT NOT NULL + was_purchased INT NOT NULL, + browser_session TEXT NOT NULL, + seller_data TEXT NOT NULL, + ban_reason TEXT NOT NULL, + channel_mutes TEXT NOT NULL, + is_deactivated INT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/version_migrations.sql b/crates/core/src/database/drivers/sql/version_migrations.sql new file mode 100644 index 0000000..c101e7d --- /dev/null +++ b/crates/core/src/database/drivers/sql/version_migrations.sql @@ -0,0 +1,15 @@ +-- users channel_mutes +ALTER TABLE users +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/layouts.rs b/crates/core/src/database/layouts.rs deleted file mode 100644 index 052a733..0000000 --- a/crates/core/src/database/layouts.rs +++ /dev/null @@ -1,117 +0,0 @@ -use crate::model::{ - auth::User, - layouts::{Layout, LayoutPage, LayoutPrivacy}, - permissions::FinePermission, - Error, Result, -}; -use crate::{auto_method, DataManager}; -use oiseau::{PostgresRow, execute, get, query_rows, params, cache::Cache}; - -impl DataManager { - /// Get a [`Layout`] from an SQL row. - pub(crate) fn get_layout_from_row(x: &PostgresRow) -> Layout { - Layout { - id: get!(x->0(i64)) as usize, - created: get!(x->1(i64)) as usize, - owner: get!(x->2(i64)) as usize, - title: get!(x->3(String)), - privacy: serde_json::from_str(&get!(x->4(String))).unwrap(), - pages: serde_json::from_str(&get!(x->5(String))).unwrap(), - replaces: serde_json::from_str(&get!(x->6(String))).unwrap(), - } - } - - auto_method!(get_layout_by_id(usize as i64)@get_layout_from_row -> "SELECT * FROM layouts WHERE id = $1" --name="layout" --returns=Layout --cache-key-tmpl="atto.layout:{}"); - - /// Get all layouts by user. - /// - /// # Arguments - /// * `id` - the ID of the user to fetch layouts for - pub async fn get_layouts_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 layouts WHERE owner = $1 ORDER BY created DESC", - &[&(id as i64)], - |x| { Self::get_layout_from_row(x) } - ); - - if res.is_err() { - return Err(Error::GeneralNotFound("layout".to_string())); - } - - Ok(res.unwrap()) - } - - /// Create a new layout in the database. - /// - /// # Arguments - /// * `data` - a mock [`Layout`] object to insert - pub async fn create_layout(&self, data: Layout) -> Result { - // check values - if data.title.len() < 2 { - return Err(Error::DataTooShort("title".to_string())); - } else if data.title.len() > 32 { - return Err(Error::DataTooLong("title".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 layouts VALUES ($1, $2, $3, $4, $5, $6, $7)", - params![ - &(data.id as i64), - &(data.created as i64), - &(data.owner as i64), - &data.title, - &serde_json::to_string(&data.privacy).unwrap(), - &serde_json::to_string(&data.pages).unwrap(), - &serde_json::to_string(&data.replaces).unwrap(), - ] - ); - - if let Err(e) = res { - return Err(Error::DatabaseError(e.to_string())); - } - - Ok(data) - } - - pub async fn delete_layout(&self, id: usize, user: &User) -> Result<()> { - let layout = self.get_layout_by_id(id).await?; - - // check user permission - if user.id != layout.owner && !user.permissions.check(FinePermission::MANAGE_STACKS) { - 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 layouts WHERE id = $1", &[&(id as i64)]); - - if let Err(e) = res { - return Err(Error::DatabaseError(e.to_string())); - } - - // ... - self.0.1.remove(format!("atto.layout:{}", id)).await; - Ok(()) - } - - auto_method!(update_layout_title(&str)@get_layout_by_id:FinePermission::MANAGE_USERS; -> "UPDATE layouts SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.layout:{}"); - auto_method!(update_layout_privacy(LayoutPrivacy)@get_layout_by_id:FinePermission::MANAGE_USERS; -> "UPDATE layouts SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.layout:{}"); - auto_method!(update_layout_pages(Vec)@get_layout_by_id:FinePermission::MANAGE_USERS; -> "UPDATE layouts SET pages = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.layout:{}"); -} 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/messages.rs b/crates/core/src/database/messages.rs index 64157f0..3acb2ee 100644 --- a/crates/core/src/database/messages.rs +++ b/crates/core/src/database/messages.rs @@ -190,6 +190,11 @@ impl DataManager { continue; } + let user = self.get_user_by_id(member).await?; + if user.channel_mutes.contains(&channel.id) { + continue; + } + let mut notif = Notification::new( "You've received a new message!".to_string(), format!( diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 1009797..218bcd6 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -1,3 +1,4 @@ +pub mod app_data; mod apps; mod audit_log; mod auth; @@ -13,7 +14,7 @@ mod invite_codes; mod ipbans; mod ipblocks; mod journals; -mod layouts; +mod letters; mod memberships; mod message_reactions; mod messages; @@ -22,6 +23,7 @@ mod notifications; mod polls; mod pollvotes; mod posts; +mod products; mod questions; mod reactions; mod reports; diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index becb780..0891fed 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -22,10 +22,11 @@ pub type FullPost = ( User, Community, Option<(User, Post)>, - Option<(Question, User)>, + Option<(Question, User, Option<(User, Post)>)>, Option<(Poll, bool, bool)>, Option, ); +pub type FullQuestion = (Question, User, Option<(User, Post)>); macro_rules! private_post_replying { ($post:ident, $replying_posts:ident, $ua1:ident, $data:ident) => { @@ -224,8 +225,14 @@ impl DataManager { &self, post: &Post, ignore_users: &[usize], - ) -> Result> { + seen_questions: &mut HashMap, + ) -> Result> { if post.context.answering != 0 { + if let Some(q) = seen_questions.get(&post.context.answering) { + return Ok(Some(q.to_owned())); + } + + // ... let question = self.get_question_by_id(post.context.answering).await?; if ignore_users.contains(&question.owner) { @@ -238,7 +245,11 @@ impl DataManager { self.get_user_by_id_with_void(question.owner).await? }; - Ok(Some((question, user))) + let asking_about = self.get_question_asking_about(&question).await?; + let full_question = (question, user, asking_about); + + seen_questions.insert(post.context.answering, full_question.to_owned()); + Ok(Some(full_question)) } else { Ok(None) } @@ -322,7 +333,7 @@ impl DataManager { Post, User, Option<(User, Post)>, - Option<(Question, User)>, + Option<(Question, User, Option<(User, Post)>)>, Option<(Poll, bool, bool)>, Option, )>, @@ -332,6 +343,7 @@ impl DataManager { let mut users: HashMap = HashMap::new(); let mut seen_user_follow_statuses: HashMap<(usize, usize), bool> = HashMap::new(); let mut seen_stacks: HashMap = HashMap::new(); + let mut seen_questions: HashMap = HashMap::new(); let mut replying_posts: HashMap = HashMap::new(); for post in posts { @@ -373,7 +385,8 @@ impl DataManager { post.clone(), ua.clone(), reposting, - self.get_post_question(&post, ignore_users).await?, + self.get_post_question(&post, ignore_users, &mut seen_questions) + .await?, self.get_post_poll(&post, user).await?, stack, )); @@ -384,7 +397,9 @@ impl DataManager { continue; } - if ua.permissions.check_banned() | ignore_users.contains(&owner) + if (ua.permissions.check_banned() + | ignore_users.contains(&owner) + | ua.is_deactivated) && !ua.permissions.check(FinePermission::MANAGE_POSTS) { continue; @@ -454,7 +469,8 @@ impl DataManager { post.clone(), ua, reposting, - self.get_post_question(&post, ignore_users).await?, + self.get_post_question(&post, ignore_users, &mut seen_questions) + .await?, self.get_post_poll(&post, user).await?, stack, )); @@ -477,6 +493,7 @@ impl DataManager { let mut seen_before: HashMap<(usize, usize), (User, Community)> = HashMap::new(); let mut seen_user_follow_statuses: HashMap<(usize, usize), bool> = HashMap::new(); let mut seen_stacks: HashMap = HashMap::new(); + let mut seen_questions: HashMap = HashMap::new(); let mut replying_posts: HashMap = HashMap::new(); let mut memberships: HashMap = HashMap::new(); @@ -544,7 +561,8 @@ impl DataManager { ua.clone(), community.to_owned(), reposting, - self.get_post_question(&post, ignore_users).await?, + self.get_post_question(&post, ignore_users, &mut seen_questions) + .await?, self.get_post_poll(&post, user).await?, stack, )); @@ -643,7 +661,8 @@ impl DataManager { ua, community, reposting, - self.get_post_question(&post, ignore_users).await?, + self.get_post_question(&post, ignore_users, &mut seen_questions) + .await?, self.get_post_poll(&post, user).await?, stack, )); @@ -716,8 +735,12 @@ impl DataManager { } // question - if let Some((_, ref mut x)) = post.4 { + if let Some((_, ref mut x, ref mut y)) = post.4 { x.clean(); + + if y.is_some() { + y.as_mut().unwrap().0.clean(); + } } // ... @@ -1452,6 +1475,14 @@ impl DataManager { false }; + // check if we should hide nsfw posts + let mut hide_nsfw: bool = true; + + if let Some(ua) = as_user { + hide_nsfw = !ua.settings.show_nsfw; + } + + // ... let conn = match self.0.connect().await { Ok(c) => c, Err(e) => return Err(Error::DatabaseConnection(e.to_string())), @@ -1460,12 +1491,17 @@ impl DataManager { let res = query_rows!( &conn, &format!( - "SELECT * FROM posts WHERE replying_to = 0{} AND NOT context LIKE '%\"is_nsfw\":true%'{} ORDER BY created DESC LIMIT $1 OFFSET $2", + "SELECT * FROM posts WHERE replying_to = 0{}{}{} AND NOT context LIKE '%\"full_unlist\":true%' ORDER BY created DESC LIMIT $1 OFFSET $2", if before_time > 0 { format!(" AND created < {before_time}") } else { String::new() }, + if hide_nsfw { + " AND NOT context LIKE '%\"is_nsfw\":true%'" + } else { + "" + }, if hide_answers { " AND context::jsonb->>'answering' = '0'" } else { @@ -1494,6 +1530,7 @@ impl DataManager { id: usize, batch: usize, page: usize, + user: &User, ) -> Result> { let memberships = self.get_memberships_by_owner(id).await?; let mut memberships = memberships.iter(); @@ -1508,6 +1545,9 @@ impl DataManager { query_string.push_str(&format!(" OR community = {}", membership.community)); } + // check if we should hide nsfw posts + let hide_nsfw: bool = !user.settings.show_nsfw; + // ... let conn = match self.0.connect().await { Ok(c) => c, @@ -1517,8 +1557,13 @@ impl DataManager { let res = query_rows!( &conn, &format!( - "SELECT * FROM posts WHERE (community = {} {query_string}) AND NOT context LIKE '%\"is_nsfw\":true%' AND replying_to = 0 AND is_deleted = 0 ORDER BY created DESC LIMIT $1 OFFSET $2", - first.community + "SELECT * FROM posts WHERE (community = {} {query_string}){} AND NOT context LIKE '%\"full_unlist\":true%' AND replying_to = 0 AND is_deleted = 0 ORDER BY created DESC LIMIT $1 OFFSET $2", + first.community, + if hide_nsfw { + " AND NOT context LIKE '%\"is_nsfw\":true%'" + } else { + "" + }, ), &[&(batch as i64), &((page * batch) as i64)], |x| { Self::get_post_from_row(x) } @@ -1957,6 +2002,10 @@ impl DataManager { data.context.is_nsfw = true; } + if owner.settings.auto_full_unlist { + data.context.full_unlist = true; + } + // ... let conn = match self.0.connect().await { Ok(c) => c, @@ -2357,6 +2406,10 @@ impl DataManager { x.is_nsfw = true; } + if user.settings.auto_full_unlist { + x.full_unlist = true; + } + // ... let conn = match self.0.connect().await { Ok(c) => c, diff --git a/crates/core/src/database/products.rs b/crates/core/src/database/products.rs new file mode 100644 index 0000000..0eab9aa --- /dev/null +++ b/crates/core/src/database/products.rs @@ -0,0 +1,175 @@ +use crate::model::{ + auth::User, + permissions::{FinePermission, SecondaryPermission}, + products::{Product, ProductPrice}, + Error, Result, +}; +use crate::{auto_method, DataManager}; +use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow}; + +impl DataManager { + /// Get a [`Product`] from an SQL row. + pub(crate) fn get_product_from_row(x: &PostgresRow) -> Product { + Product { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + name: get!(x->3(String)), + description: get!(x->4(String)), + likes: get!(x->5(i32)) as isize, + dislikes: get!(x->6(i32)) as isize, + product_type: serde_json::from_str(&get!(x->7(String))).unwrap(), + price: serde_json::from_str(&get!(x->8(String))).unwrap(), + uploads: serde_json::from_str(&get!(x->9(String))).unwrap(), + } + } + + auto_method!(get_product_by_id(usize as i64)@get_product_from_row -> "SELECT * FROM products WHERE id = $1" --name="product" --returns=Product --cache-key-tmpl="atto.product:{}"); + + /// Get all products by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch products for + /// * `batch` + /// * `page` + pub async fn get_products_by_user( + &self, + id: usize, + batch: usize, + page: 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 products WHERE owner = $1 ORDER BY created DESC LIMIT {} OFFSET {}", + &[&(id as i64), &(batch as i64), &((page * batch) as i64)], + |x| { Self::get_product_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("product".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get all products by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch products for + pub async fn get_products_by_user_all(&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 products WHERE owner = $1 ORDER BY created DESC", + &[&(id as i64)], + |x| { Self::get_product_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("product".to_string())); + } + + Ok(res.unwrap()) + } + + const MAXIMUM_FREE_PRODUCTS: usize = 15; + + /// Create a new product in the database. + /// + /// # Arguments + /// * `data` - a mock [`Product`] object to insert + pub async fn create_product(&self, data: Product) -> Result { + // check values + if data.name.len() < 2 { + return Err(Error::DataTooShort("name".to_string())); + } else if data.name.len() > 128 { + return Err(Error::DataTooLong("name".to_string())); + } + + // check number of products + let owner = self.get_user_by_id(data.owner).await?; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + let products = self + .get_table_row_count_where("products", &format!("owner = {}", owner.id)) + .await? as usize; + + if products >= Self::MAXIMUM_FREE_PRODUCTS { + return Err(Error::MiscError( + "You already have the maximum number of products you can have".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 products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &data.name, + &data.description, + &0_i32, + &0_i32, + &serde_json::to_string(&data.product_type).unwrap(), + &serde_json::to_string(&data.price).unwrap(), + &serde_json::to_string(&data.uploads).unwrap(), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_product(&self, id: usize, user: &User) -> Result<()> { + let product = self.get_product_by_id(id).await?; + + // check user permission + if user.id != product.owner + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_PRODUCTS) + { + 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 products WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... + self.0.1.remove(format!("atto.product:{}", id)).await; + Ok(()) + } + + auto_method!(update_product_name(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); + auto_method!(update_product_description(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET description = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); + auto_method!(update_product_price(ProductPrice)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET price = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.product:{}"); +} diff --git a/crates/core/src/database/questions.rs b/crates/core/src/database/questions.rs index 1cee527..3703d4f 100644 --- a/crates/core/src/database/questions.rs +++ b/crates/core/src/database/questions.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use oiseau::cache::Cache; use tetratto_shared::unix_epoch_timestamp; use crate::model::addr::RemoteAddr; +use crate::model::communities::Post; use crate::model::communities_permissions::CommunityPermission; use crate::model::uploads::{MediaType, MediaUpload}; use crate::model::{ @@ -38,13 +39,30 @@ impl DataManager { auto_method!(get_question_by_id()@get_question_from_row -> "SELECT * FROM questions WHERE id = $1" --name="question" --returns=Question --cache-key-tmpl="atto.question:{}"); + /// Get the post a given question is asking about. + pub async fn get_question_asking_about( + &self, + question: &Question, + ) -> Result> { + Ok(if let Some(id) = question.context.asking_about { + let post = match self.get_post_by_id(id).await { + Ok(x) => x, + Err(_) => return Ok(None), + }; + + Some((self.get_user_by_id(post.owner).await?, post)) + } else { + None + }) + } + /// Fill the given vector of questions with their owner as well. pub async fn fill_questions( &self, questions: Vec, ignore_users: &[usize], - ) -> Result> { - let mut out: Vec<(Question, User)> = Vec::new(); + ) -> Result)>> { + let mut out: Vec<(Question, User, Option<(User, Post)>)> = Vec::new(); let mut seen_users: HashMap = HashMap::new(); for question in questions { @@ -53,7 +71,8 @@ impl DataManager { } if let Some(ua) = seen_users.get(&question.owner) { - out.push((question, ua.to_owned())); + let asking_about = self.get_question_asking_about(&question).await?; + out.push((question, ua.to_owned(), asking_about)); } else { let user = if question.owner == 0 { User::anonymous() @@ -62,7 +81,9 @@ impl DataManager { }; seen_users.insert(question.owner, user.clone()); - out.push((question, user)); + + let asking_about = self.get_question_asking_about(&question).await?; + out.push((question, user, asking_about)); } } @@ -72,12 +93,17 @@ impl DataManager { /// Filter to update questions to clean their owner for public APIs. pub fn questions_owner_filter( &self, - questions: &Vec<(Question, User)>, - ) -> Vec<(Question, User)> { - let mut out: Vec<(Question, User)> = Vec::new(); + questions: &Vec<(Question, User, Option<(User, Post)>)>, + ) -> Vec<(Question, User, Option<(User, Post)>)> { + let mut out: Vec<(Question, User, Option<(User, Post)>)> = Vec::new(); for mut question in questions.clone() { question.1.clean(); + + if question.2.is_some() { + question.2.as_mut().unwrap().0.clean(); + } + out.push(question); } @@ -361,23 +387,8 @@ impl DataManager { // inherit nsfw status data.context.is_nsfw = community.context.is_nsfw; } else { - let receiver = self.get_user_by_id(data.receiver).await?; - - if !receiver.settings.enable_questions { - return Err(Error::QuestionsDisabled); - } - - // check for ip block - if self - .get_ipblock_by_initiator_receiver( - receiver.id, - &RemoteAddr::from(data.ip.as_str()), - ) - .await - .is_ok() - { - return Err(Error::NotAllowed); - } + // this should be unreachable + return Err(Error::Unknown); } } else { // single @@ -395,6 +406,18 @@ impl DataManager { return Err(Error::DrawingsDisabled); } + // 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); + } + } + // check for ip block if self .get_ipblock_by_initiator_receiver(receiver.id, &RemoteAddr::from(data.ip.as_str())) @@ -405,6 +428,22 @@ impl DataManager { } } + // check asking_about + if let Some(id) = data.context.asking_about { + let post = self.get_post_by_id(id).await?; + let owner = self.get_user_by_id(post.owner).await?; + + if post.stack != 0 { + return Err(Error::MiscError( + "Cannot ask about posts in a circle".to_string(), + )); + } else if owner.settings.private_profile { + return Err(Error::MiscError( + "Cannot ask about posts from a private user".to_string(), + )); + } + } + // create uploads if drawings.len() > 2 { return Err(Error::MiscError( diff --git a/crates/core/src/database/services.rs b/crates/core/src/database/services.rs index adadf7e..adc9bc6 100644 --- a/crates/core/src/database/services.rs +++ b/crates/core/src/database/services.rs @@ -16,6 +16,7 @@ impl DataManager { owner: get!(x->2(i64)) as usize, name: get!(x->3(String)), files: serde_json::from_str(&get!(x->4(String))).unwrap(), + revision: get!(x->5(i64)) as usize, } } @@ -45,7 +46,7 @@ impl DataManager { Ok(res.unwrap()) } - const MAXIMUM_FREE_SERVICES: usize = 5; + const MAXIMUM_FREE_SERVICES: usize = 10; /// Create a new service in the database. /// @@ -80,13 +81,14 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO services VALUES ($1, $2, $3, $4, $5)", + "INSERT INTO services VALUES ($1, $2, $3, $4, $5, $6)", params![ &(data.id as i64), &(data.created as i64), &(data.owner as i64), &data.name, &serde_json::to_string(&data.files).unwrap(), + &(data.created as i64) ] ); @@ -128,4 +130,5 @@ impl DataManager { auto_method!(update_service_name(&str)@get_service_by_id:FinePermission::MANAGE_USERS; -> "UPDATE services SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.service:{}"); auto_method!(update_service_files(Vec)@get_service_by_id:FinePermission::MANAGE_USERS; -> "UPDATE services SET files = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.service:{}"); + auto_method!(update_service_revision(i64) -> "UPDATE services SET revision = $1 WHERE id = $2" --cache-key-tmpl="atto.service:{}"); } diff --git a/crates/core/src/database/stacks.rs b/crates/core/src/database/stacks.rs index 6a64b53..cea2be9 100644 --- a/crates/core/src/database/stacks.rs +++ b/crates/core/src/database/stacks.rs @@ -165,9 +165,11 @@ impl DataManager { let owner = self.get_user_by_id(data.owner).await?; if !owner.permissions.check(FinePermission::SUPPORTER) { - let stacks = self.get_stacks_by_user(data.owner).await?; + let stacks = self + .get_table_row_count_where("stacks", &format!("owner = {}", owner.id)) + .await? as usize; - if stacks.len() >= Self::MAXIMUM_FREE_STACKS { + if stacks >= Self::MAXIMUM_FREE_STACKS { return Err(Error::MiscError( "You already have the maximum number of stacks you can have".to_string(), )); diff --git a/crates/core/src/database/uploads.rs b/crates/core/src/database/uploads.rs index e3b2cb5..f669c53 100644 --- a/crates/core/src/database/uploads.rs +++ b/crates/core/src/database/uploads.rs @@ -16,10 +16,11 @@ impl DataManager { created: get!(x->1(i64)) as usize, owner: get!(x->2(i64)) as usize, what: serde_json::from_str(&get!(x->3(String))).unwrap(), + alt: get!(x->4(String)), } } - auto_method!(get_upload_by_id(usize as i64)@get_upload_from_row -> "SELECT * FROM uploads WHERE id = $1" --name="upload" --returns=MediaUpload --cache-key-tmpl="atto.uploads:{}"); + auto_method!(get_upload_by_id(usize as i64)@get_upload_from_row -> "SELECT * FROM uploads WHERE id = $1" --name="upload" --returns=MediaUpload --cache-key-tmpl="atto.upload:{}"); /// Get all uploads (paginated). /// @@ -113,12 +114,13 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO uploads VALUES ($1, $2, $3, $4)", + "INSERT INTO uploads VALUES ($1, $2, $3, $4, $5)", params![ &(data.id as i64), &(data.created as i64), &(data.owner as i64), &serde_json::to_string(&data.what).unwrap().as_str(), + &data.alt, ] ); @@ -187,4 +189,6 @@ impl DataManager { self.0.1.remove(format!("atto.upload:{}", id)).await; Ok(()) } + + auto_method!(update_upload_alt(&str)@get_upload_by_id:FinePermission::MANAGE_UPLOADS; -> "UPDATE uploads SET alt = $1 WHERE id = $2" --cache-key-tmpl="atto.upload:{}"); } diff --git a/crates/core/src/database/userfollows.rs b/crates/core/src/database/userfollows.rs index 5428f67..4b22835 100644 --- a/crates/core/src/database/userfollows.rs +++ b/crates/core/src/database/userfollows.rs @@ -195,18 +195,29 @@ impl DataManager { pub async fn fill_userfollows_with_receiver( &self, userfollows: Vec, + as_user: &Option, + do_check: bool, ) -> Result> { let mut out: Vec<(UserFollow, User)> = Vec::new(); for userfollow in userfollows { let receiver = userfollow.receiver; - out.push(( - userfollow, - match self.get_user_by_id(receiver).await { - Ok(u) => u, - Err(_) => continue, - }, - )); + let user = match self.get_user_by_id(receiver).await { + Ok(u) => u, + Err(_) => continue, + }; + + if user.settings.hide_from_social_lists && do_check { + if let Some(ua) = as_user { + if !ua.permissions.check(FinePermission::MANAGE_USERS) { + continue; + } + } else { + continue; + } + } + + out.push((userfollow, user)); } Ok(out) @@ -216,18 +227,29 @@ impl DataManager { pub async fn fill_userfollows_with_initiator( &self, userfollows: Vec, + as_user: &Option, + do_check: bool, ) -> Result> { let mut out: Vec<(UserFollow, User)> = Vec::new(); for userfollow in userfollows { let initiator = userfollow.initiator; - out.push(( - userfollow, - match self.get_user_by_id(initiator).await { - Ok(u) => u, - Err(_) => continue, - }, - )); + let user = match self.get_user_by_id(initiator).await { + Ok(u) => u, + Err(_) => continue, + }; + + if user.settings.hide_from_social_lists && do_check { + if let Some(ua) = as_user { + if !ua.permissions.check(FinePermission::MANAGE_USERS) { + continue; + } + } else { + continue; + } + } + + out.push((userfollow, user)); } Ok(out) @@ -378,9 +400,13 @@ impl DataManager { // decr counts (if we aren't deleting the user OR the user id isn't the deleted user id) if !is_deleting_user | (follow.initiator != user.id) { - self.decr_user_following_count(follow.initiator) + if self + .decr_user_following_count(follow.initiator) .await - .unwrap(); + .is_err() + { + println!("ERR_TETRATTO_DECR_FOLLOWS: could not decr initiator follow count") + } } if !is_deleting_user | (follow.receiver != user.id) { 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. @@ -62,7 +96,7 @@ pub struct ThirdPartyApp { /// if the verifier doesn't match, it won't pass the challenge. /// /// Requests to API endpoints using your grant token should be sent with a - /// cookie (in the `Cookie` header) named `Atto-Grant`. This cookie should + /// cookie (in the `Cookie` or `X-Cookie` header) named `Atto-Grant`. This cookie should /// contain the token you received from either the initial connection, /// or a token refresh. pub redirect: String, @@ -81,6 +115,12 @@ pub struct ThirdPartyApp { /// /// Your app should handle informing users when scopes change. pub scopes: Vec, + /// The app's secret API key (for app_data access). + 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 { @@ -93,10 +133,135 @@ 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(), } } } + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct AppData { + pub id: usize, + pub app: usize, + pub key: String, + pub value: String, +} + +impl AppData { + /// Create a new [`AppData`]. + pub fn new(app: usize, key: String, value: String) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + app, + key, + value, + } + } + + /// Get the data limit of a given user. + pub fn user_limit(user: &User, app: &ThirdPartyApp) -> usize { + if user + .secondary_permissions + .check(SecondaryPermission::DEVELOPER_PASS) + { + if app.storage_capacity != DeveloperPassStorageQuota::Tier1 { + app.storage_capacity.limit() + } else { + PASS_DATA_LIMIT + } + } else { + FREE_DATA_LIMIT + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum AppDataSelectQuery { + KeyIs(String), + KeyLike(String), + ValueLike(String), + LikeJson(String, String), +} + +impl Display for AppDataSelectQuery { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&match self { + Self::KeyIs(k) => k.to_owned(), + Self::KeyLike(k) => k.to_owned(), + Self::ValueLike(v) => v.to_owned(), + Self::LikeJson(k, v) => format!("%\"{k}\":\"{v}\"%"), + }) + } +} + +impl AppDataSelectQuery { + pub fn selector(&self) -> String { + match self { + AppDataSelectQuery::KeyIs(_) => format!("k = $1"), + AppDataSelectQuery::KeyLike(_) => format!("k LIKE $1"), + AppDataSelectQuery::ValueLike(_) => format!("v LIKE $1"), + AppDataSelectQuery::LikeJson(_, _) => format!("v LIKE $1"), + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum AppDataSelectMode { + /// Select a single row (with offset). + One(usize), + /// Select multiple rows at once. + /// + /// `(limit, offset)` + Many(usize, usize), + /// Select multiple rows at once. + /// + /// `(order by top level key, limit, offset)` + ManyJson(String, usize, usize), +} + +impl Display for AppDataSelectMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&match self { + Self::One(offset) => format!("LIMIT 1 OFFSET {offset}"), + Self::Many(limit, offset) => { + format!( + "ORDER BY k DESC LIMIT {} OFFSET {offset}", + if *limit > 24 { 24 } else { *limit } + ) + } + Self::ManyJson(order_by_top_level_key, limit, offset) => { + format!( + "ORDER BY v::jsonb->>'{order_by_top_level_key}' DESC LIMIT {} OFFSET {offset}", + if *limit > 24 { 24 } else { *limit } + ) + } + }) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct AppDataQuery { + pub app: usize, + pub query: AppDataSelectQuery, + pub mode: AppDataSelectMode, +} + +impl Display for AppDataQuery { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&format!( + "SELECT * FROM app_data WHERE app = {} AND %q% {}", + self.app, self.mode + )) + } +} + +#[derive(Serialize, Deserialize)] +pub enum AppDataQueryResult { + One(AppData), + Many(Vec), +} diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 2b47562..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}, @@ -70,6 +69,29 @@ pub struct User { /// used an invite code. #[serde(default)] pub was_purchased: bool, + /// This value is updated for every **new** littleweb browser session. + /// + /// This means the user can only have one of these sessions open at once + /// (unless this token is stored somewhere with a way to say we already have one, + /// but this does not happen yet). + /// + /// Without this token, the user can still use the browser, they just cannot + /// view pages which require authentication (all `$` routes). + #[serde(default)] + pub browser_session: String, + /// Stripe connected account information (for Tetratto marketplace). + #[serde(default)] + pub seller_data: StripeSellerData, + /// The reason the user was banned. + #[serde(default)] + pub ban_reason: String, + /// IDs of channels the user has muted. + #[serde(default)] + pub channel_mutes: Vec, + /// If the user is deactivated. Deactivated users act almost like deleted + /// users, but their data is not wiped. + #[serde(default)] + pub is_deactivated: bool, } pub type UserConnections = @@ -302,6 +324,31 @@ pub struct UserSettings { /// Which tab is shown by default on the user's profile. #[serde(default)] pub default_profile_tab: DefaultProfileTabChoice, + /// If the user is hidden from followers/following tabs. + /// + /// The user will still impact the followers/following numbers, but will not + /// be shown in the UI (or API). + #[serde(default)] + pub hide_from_social_lists: bool, + /// Automatically hide your posts from all timelines except your profile + /// and the following timeline. + #[serde(default)] + pub auto_full_unlist: bool, + /// 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)] +pub struct StripeSellerData { + #[serde(default)] + pub account_id: Option, + #[serde(default)] + pub completed_onboarding: bool, } fn mime_avif() -> String { @@ -347,6 +394,11 @@ impl User { achievements: Vec::new(), awaiting_purchase: false, was_purchased: false, + browser_session: String::new(), + seller_data: StripeSellerData::default(), + ban_reason: String::new(), + channel_mutes: Vec::new(), + is_deactivated: false, } } @@ -521,7 +573,7 @@ pub struct ExternalConnectionData { } /// The total number of achievements needed to 100% Tetratto! -pub const ACHIEVEMENTS: usize = 34; +pub const ACHIEVEMENTS: usize = 36; /// "self-serve" achievements can be granted by the user through the API. pub const SELF_SERVE_ACHIEVEMENTS: &[AchievementName] = &[ AchievementName::OpenReference, @@ -567,6 +619,8 @@ pub enum AchievementName { GetAllOtherAchievements, AcceptProfileWarning, OpenSessionSettings, + CreateSite, + CreateDomain, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -613,6 +667,8 @@ impl AchievementName { Self::GetAllOtherAchievements => "The final performance", Self::AcceptProfileWarning => "I accept the risks!", Self::OpenSessionSettings => "Am I alone in here?", + Self::CreateSite => "Littlewebmaster", + Self::CreateDomain => "LittleDNS", } } @@ -652,6 +708,8 @@ impl AchievementName { Self::GetAllOtherAchievements => "Get every other achievement.", Self::AcceptProfileWarning => "Accept a profile warning.", Self::OpenSessionSettings => "Open your session settings.", + Self::CreateSite => "Create a site.", + Self::CreateDomain => "Create a domain.", } } @@ -693,6 +751,8 @@ impl AchievementName { Self::GetAllOtherAchievements => Rare, Self::AcceptProfileWarning => Common, Self::OpenSessionSettings => Common, + Self::CreateSite => Common, + Self::CreateDomain => Common, } } } diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index 4df9795..14f640f 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -190,6 +190,8 @@ pub struct PostContext { pub content_warning: String, #[serde(default)] pub tags: Vec, + #[serde(default)] + pub full_unlist: bool, } fn default_comments_enabled() -> bool { @@ -218,6 +220,7 @@ impl Default for PostContext { reactions_enabled: default_reactions_enabled(), content_warning: String::new(), tags: Vec::new(), + full_unlist: false, } } } @@ -384,6 +387,9 @@ pub struct QuestionContext { /// If the owner is shown as anonymous in the UI. #[serde(default)] pub mask_owner: bool, + /// The POST this question is asking about. + #[serde(default)] + pub asking_about: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] diff --git a/crates/core/src/model/layouts.rs b/crates/core/src/model/layouts.rs deleted file mode 100644 index a9d60a4..0000000 --- a/crates/core/src/model/layouts.rs +++ /dev/null @@ -1,403 +0,0 @@ -use std::{collections::HashMap, fmt::Display}; -use serde::{Deserialize, Serialize}; -use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; -use crate::model::auth::DefaultTimelineChoice; - -/// Each different page which can be customized. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] -pub enum CustomizablePage { - Home, - All, - Popular, -} - -/// Layouts allow you to customize almost every page in the Tetratto UI through -/// simple blocks. -#[derive(Serialize, Deserialize)] -pub struct Layout { - pub id: usize, - pub created: usize, - pub owner: usize, - pub title: String, - pub privacy: LayoutPrivacy, - pub pages: Vec, - pub replaces: CustomizablePage, -} - -impl Layout { - /// Create a new [`Layout`]. - pub fn new(title: String, owner: usize, replaces: CustomizablePage) -> Self { - Self { - id: Snowflake::new().to_string().parse::().unwrap(), - created: unix_epoch_timestamp(), - owner, - title, - privacy: LayoutPrivacy::Public, - pages: Vec::new(), - replaces, - } - } -} - -/// The privacy of the layout, which controls who has the ability to view it. -#[derive(Serialize, Deserialize, PartialEq, Eq)] -pub enum LayoutPrivacy { - Public, - Private, -} - -impl Display for Layout { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut out = String::new(); - - for (i, page) in self.pages.iter().enumerate() { - let mut x = page.to_string(); - - if i == 0 { - x = x.replace("%?%", ""); - } else { - x = x.replace("%?%", "hidden"); - } - - out.push_str(&x); - } - - f.write_str(&out) - } -} - -/// Layouts are able to contain subpages within them. -/// -/// Each layout is only allowed 2 subpages pages, meaning one main page and one extra. -#[derive(Serialize, Deserialize)] -pub struct LayoutPage { - pub name: String, - pub blocks: Vec, - pub css: String, - pub js: String, -} - -impl Display for LayoutPage { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&format!( - "
    {}
    ", - { - let mut out = String::new(); - - for block in &self.blocks { - out.push_str(&block.to_string()); - } - - out - }, - self.css, - self.js - )) - } -} - -/// Blocks are the basis of each layout page. They are simple and composable. -#[derive(Serialize, Deserialize)] -pub struct LayoutBlock { - pub r#type: BlockType, - pub children: Vec, -} - -impl LayoutBlock { - pub fn render_children(&self) -> String { - let mut out = String::new(); - - for child in &self.children { - out.push_str(&child.to_string()); - } - - out - } -} - -impl Display for LayoutBlock { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut out = String::new(); - - // head - out.push_str(&match self.r#type { - BlockType::Block(ref x) => format!("<{} {}>", x.element, x), - BlockType::Flexible(ref x) => format!("<{} {}>", x.element, x), - BlockType::Markdown(ref x) => format!("<{} {}>", x.element, x), - BlockType::Timeline(ref x) => format!("<{} {}>", x.element, x), - }); - - // body - out.push_str(&match self.r#type { - BlockType::Block(_) => self.render_children(), - BlockType::Flexible(_) => self.render_children(), - BlockType::Markdown(ref x) => x.sub_options.content.to_string(), - BlockType::Timeline(ref x) => { - format!( - "
    ", - x.sub_options.timeline - ) - } - }); - - // tail - out.push_str(&self.r#type.unwrap_cloned().element.tail()); - - // ... - f.write_str(&out) - } -} - -/// Each different type of block has different attributes associated with it. -#[derive(Serialize, Deserialize)] -pub enum BlockType { - Block(GeneralBlockOptions), - Flexible(GeneralBlockOptions), - Markdown(GeneralBlockOptions), - Timeline(GeneralBlockOptions), -} - -impl BlockType { - pub fn unwrap(self) -> GeneralBlockOptions> { - match self { - Self::Block(x) => x.boxed(), - Self::Flexible(x) => x.boxed(), - Self::Markdown(x) => x.boxed(), - Self::Timeline(x) => x.boxed(), - } - } - - pub fn unwrap_cloned(&self) -> GeneralBlockOptions> { - match self { - Self::Block(x) => x.boxed_cloned::(), - Self::Flexible(x) => x.boxed_cloned::(), - Self::Markdown(x) => x.boxed_cloned::(), - Self::Timeline(x) => x.boxed_cloned::(), - } - } -} - -#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum HtmlElement { - Div, - Span, - Italics, - Bold, - Heading1, - Heading2, - Heading3, - Heading4, - Heading5, - Heading6, - Image, -} - -impl HtmlElement { - pub fn tail(&self) -> String { - match self { - Self::Image => String::new(), - _ => format!(""), - } - } -} - -impl Display for HtmlElement { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::Div => "div", - Self::Span => "span", - Self::Italics => "i", - Self::Bold => "b", - Self::Heading1 => "h1", - Self::Heading2 => "h2", - Self::Heading3 => "h3", - Self::Heading4 => "h4", - Self::Heading5 => "h5", - Self::Heading6 => "h6", - Self::Image => "img", - }) - } -} - -/// This trait is used to provide cloning capabilities to structs which DO implement -/// clone, but we aren't allowed to tell the compiler that they implement clone -/// (through a trait bound), as Clone is not dyn compatible. -/// -/// Implementations for this trait should really just take reference to another -/// value (T), then just run `.to_owned()` on it. This means T and F (Self) MUST -/// be the same type. -pub trait RefFrom { - fn ref_from(value: &T) -> Self; -} - -#[derive(Serialize, Deserialize)] -pub struct GeneralBlockOptions -where - T: Display, -{ - pub element: HtmlElement, - pub class_list: String, - pub id: String, - pub attributes: HashMap, - pub sub_options: T, -} - -impl GeneralBlockOptions { - pub fn boxed(self) -> GeneralBlockOptions> { - GeneralBlockOptions { - element: self.element, - class_list: self.class_list, - id: self.id, - attributes: self.attributes, - sub_options: Box::new(self.sub_options), - } - } - - pub fn boxed_cloned + 'static>( - &self, - ) -> GeneralBlockOptions> { - let x: F = F::ref_from(&self.sub_options); - GeneralBlockOptions { - element: self.element.clone(), - class_list: self.class_list.clone(), - id: self.id.clone(), - attributes: self.attributes.clone(), - sub_options: Box::new(x), - } - } -} - -impl Display for GeneralBlockOptions { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&format!( - "class=\"{} {}\" {} id={} {}", - self.class_list, - self.sub_options.to_string(), - { - let mut attrs = String::new(); - - for (k, v) in &self.attributes { - attrs.push_str(&format!("{k}=\"{v}\"")); - } - - attrs - }, - self.id, - if self.element == HtmlElement::Image { - "/" - } else { - "" - } - )) - } -} -#[derive(Clone, Serialize, Deserialize)] -pub struct EmptyBlockOptions; - -impl Display for EmptyBlockOptions { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("") - } -} - -impl RefFrom for EmptyBlockOptions { - fn ref_from(value: &EmptyBlockOptions) -> Self { - value.to_owned() - } -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct FlexibleBlockOptions { - pub gap: FlexibleBlockGap, - pub direction: FlexibleBlockDirection, - pub wrap: bool, - pub collapse: bool, -} - -impl Display for FlexibleBlockOptions { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&format!( - "flex {} {} {} {}", - self.gap, - self.direction, - if self.wrap { "flex-wrap" } else { "" }, - if self.collapse { "flex-collapse" } else { "" } - )) - } -} - -impl RefFrom for FlexibleBlockOptions { - fn ref_from(value: &FlexibleBlockOptions) -> Self { - value.to_owned() - } -} - -#[derive(Clone, Serialize, Deserialize)] -pub enum FlexibleBlockGap { - Tight, - Comfortable, - Spacious, - Large, -} - -impl Display for FlexibleBlockGap { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::Tight => "gap-1", - Self::Comfortable => "gap-2", - Self::Spacious => "gap-3", - Self::Large => "gap-4", - }) - } -} - -#[derive(Clone, Serialize, Deserialize)] -pub enum FlexibleBlockDirection { - Row, - Column, -} - -impl Display for FlexibleBlockDirection { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::Row => "flex-row", - Self::Column => "flex-col", - }) - } -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct MarkdownBlockOptions { - pub content: String, -} - -impl Display for MarkdownBlockOptions { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("") - } -} - -impl RefFrom for MarkdownBlockOptions { - fn ref_from(value: &MarkdownBlockOptions) -> Self { - value.to_owned() - } -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct TimelineBlockOptions { - pub timeline: DefaultTimelineChoice, -} - -impl Display for TimelineBlockOptions { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("w-full flex flex-col gap-2\" ui_ident=\"io_data_load") - } -} - -impl RefFrom for TimelineBlockOptions { - fn ref_from(value: &TimelineBlockOptions) -> Self { - value.to_owned() - } -} diff --git a/crates/core/src/model/littleweb.rs b/crates/core/src/model/littleweb.rs index 479a444..f06154d 100644 --- a/crates/core/src/model/littleweb.rs +++ b/crates/core/src/model/littleweb.rs @@ -11,6 +11,7 @@ pub struct Service { pub owner: usize, pub name: String, pub files: Vec, + pub revision: usize, } impl Service { @@ -22,6 +23,7 @@ impl Service { owner, name, files: Vec::new(), + revision: unix_epoch_timestamp(), } } @@ -193,7 +195,8 @@ macro_rules! define_domain_tlds { define_domain_tlds!( Bunny, Tet, Cool, Qwerty, Boy, Girl, Them, Quack, Bark, Meow, Silly, Wow, Neko, Yay, Lol, Love, - Fun, Gay, City, Woah, Clown, Apple, Yaoi, Yuri, World, Wav, Zero, Evil, Dragon, Yum, Site + Fun, Gay, City, Woah, Clown, Apple, Yaoi, Yuri, World, Wav, Zero, Evil, Dragon, Yum, Site, All, + Me, Bug, Slop, Retro, Eye, Neo, Spring, Nurse, Pony ); #[derive(Debug, Clone, Serialize, Deserialize)] 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 e825340..06c4149 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -6,11 +6,12 @@ pub mod channels; pub mod communities; pub mod communities_permissions; pub mod journals; -pub mod layouts; pub mod littleweb; +pub mod mail; pub mod moderation; pub mod oauth; pub mod permissions; +pub mod products; pub mod reactions; pub mod requests; pub mod socket; @@ -51,6 +52,7 @@ pub enum Error { QuestionsDisabled, RequiresSupporter, DrawingsDisabled, + AppHitStorageLimit, Unknown, } @@ -75,6 +77,7 @@ impl Display for Error { Self::QuestionsDisabled => "You are not allowed to ask questions there".to_string(), Self::RequiresSupporter => "Only site supporters can do this".to_string(), Self::DrawingsDisabled => "You are not allowed to submit drawings there".to_string(), + Self::AppHitStorageLimit => "This app has already hit its storage limit, or will do so if this data is processed.".to_string(), _ => format!("An unknown error as occurred: ({:?})", self), }) } diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index 07a23c3..aa0e00a 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -74,6 +74,8 @@ pub enum AppScope { UserReadDomains, /// Read the user's services. UserReadServices, + /// Read the user's products. + UserReadProducts, /// Create posts as the user. UserCreatePosts, /// Create messages as the user. @@ -98,6 +100,8 @@ pub enum AppScope { UserCreateDomains, /// Create services on behalf of the user. UserCreateServices, + /// Create products on behalf of the user. + UserCreateProducts, /// Delete posts owned by the user. UserDeletePosts, /// Delete messages owned by the user. @@ -138,6 +142,10 @@ pub enum AppScope { UserManageDomains, /// Manage the user's services. UserManageServices, + /// Manage the user's products. + UserManageProducts, + /// Manage the user's channel mutes. + UserManageChannelMutes, /// Edit posts created by the user. UserEditPosts, /// Edit drafts created by the user. diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs index 55cf9cc..61ebb61 100644 --- a/crates/core/src/model/permissions.rs +++ b/crates/core/src/model/permissions.rs @@ -176,6 +176,9 @@ bitflags! { const ADMINISTRATOR = 1 << 1; const MANAGE_DOMAINS = 1 << 2; 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/model/products.rs b/crates/core/src/model/products.rs new file mode 100644 index 0000000..2b90ca5 --- /dev/null +++ b/crates/core/src/model/products.rs @@ -0,0 +1,88 @@ +use std::fmt::Display; + +use serde::{Serialize, Deserialize}; +use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Product { + pub id: usize, + pub created: usize, + pub owner: usize, + pub name: String, + pub description: String, + pub likes: isize, + pub dislikes: isize, + pub product_type: ProductType, + pub price: ProductPrice, + /// Optional uploads to accompany the product title and description. Maximum of 4. + pub uploads: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ProductType { + /// Text + images. + Data, + /// When a commission product is purchased, the creator will receive a request + /// prompting them to respond with text + images. + /// + /// This is the only product type which does not immediately return data to the + /// customer, as seller input is required. + /// + /// If the request is deleted, the purchase should be immediately refunded. + /// + /// Commissions are paid beforehand to prevent theft. This means it is vital + /// that refunds are enforced. + Commission, +} + +/// A currency. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Currency { + USD, + EUR, + GBP, +} + +impl Display for Currency { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Currency::USD => "$", + Currency::EUR => "€", + Currency::GBP => "£", + }) + } +} + +/// Price in USD. `(dollars, cents)`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProductPrice(u64, u64, Currency); + +impl Display for ProductPrice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&format!("{}{}.{}", self.2, self.0, self.1)) + } +} + +impl Product { + /// Create a new [`Product`]. + pub fn new( + owner: usize, + name: String, + description: String, + price: ProductPrice, + r#type: ProductType, + ) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + owner, + name, + description, + likes: 0, + dislikes: 0, + product_type: r#type, + price, + uploads: Vec::new(), + } + } +} diff --git a/crates/core/src/model/uploads.rs b/crates/core/src/model/uploads.rs index 35165c6..bed6dad 100644 --- a/crates/core/src/model/uploads.rs +++ b/crates/core/src/model/uploads.rs @@ -44,6 +44,7 @@ pub struct MediaUpload { pub created: usize, pub owner: usize, pub what: MediaType, + pub alt: String, } impl MediaUpload { @@ -54,6 +55,7 @@ impl MediaUpload { created: unix_epoch_timestamp(), owner, what, + alt: String::new(), } } @@ -129,9 +131,14 @@ impl CustomEmoji { if emoji.1 == 0 { out = out.replace( &emoji.0, - match emojis::get_by_shortcode(&emoji.2) { - Some(e) => e.as_str(), - None => &emoji.0, + match emoji.2.as_str() { + "100" => "💯", + "thumbs_up" => "👍", + "thumbs_down" => "👎", + _ => match emojis::get_by_shortcode(&emoji.2) { + Some(e) => e.as_str(), + None => &emoji.0, + }, }, ); } else { diff --git a/crates/core/src/sdk.rs b/crates/core/src/sdk.rs new file mode 100644 index 0000000..0e5add6 --- /dev/null +++ b/crates/core/src/sdk.rs @@ -0,0 +1,348 @@ +use crate::model::{ + apps::{ + AppDataQuery, AppDataQueryResult, AppDataSelectMode, AppDataSelectQuery, ThirdPartyApp, + }, + ApiReturn, Error, Result, +}; +use reqwest::{ + multipart::{Form, Part}, + Client as HttpClient, +}; +pub use reqwest::Method; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + +macro_rules! api_return_ok { + ($ret:ty, $res:ident) => { + match $res.json::>().await { + Ok(x) => { + if x.ok { + Ok(x.payload) + } else { + Err(Error::MiscError(x.message)) + } + } + Err(e) => Err(Error::MiscError(e.to_string())), + } + }; +} + +/// A simplified app data query which matches what the API endpoint actually requires. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimplifiedQuery { + pub query: AppDataSelectQuery, + pub mode: AppDataSelectMode, +} + +/// The data client is used to access an app's data storage capabilities. +#[derive(Debug, Clone)] +pub struct DataClient { + /// The HTTP client associated with this client. + pub http: HttpClient, + /// The app's API key. You can retrieve this from the web dashboard. + pub api_key: String, + /// The origin of the Tetratto server. When creating with [`DataClient::new`], + /// you can provide `None` to use `https://tetratto.com`. + pub host: String, +} + +impl DataClient { + /// Create a new [`DataClient`]. + pub fn new(host: Option, api_key: String) -> Self { + Self { + http: HttpClient::new(), + api_key, + host: host.unwrap_or("https://tetratto.com".to_string()), + } + } + + /// Get the current app using the provided API key. + /// + /// # Usage + /// ```rust + /// let client = DataClient::new("https://tetratto.com".to_string(), "...".to_string()); + /// let app = client.get_app().await.expect("failed to get app"); + /// ``` + pub async fn get_app(&self) -> Result { + match self + .http + .get(format!("{}/api/v1/app_data/app", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .send() + .await + { + Ok(x) => api_return_ok!(ThirdPartyApp, x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + + /// 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 + .http + .post(format!("{}/api/v1/app_data/query", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .json(&query) + .send() + .await + { + Ok(x) => api_return_ok!(AppDataQueryResult, x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + + /// Insert a key, value pair into the app's data. + pub async fn insert(&self, key: String, value: String) -> Result { + match self + .http + .post(format!("{}/api/v1/app_data", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .json(&serde_json::Value::Object({ + let mut map = serde_json::Map::new(); + map.insert("key".to_string(), serde_json::Value::String(key)); + map.insert("value".to_string(), serde_json::Value::String(value)); + map + })) + .send() + .await + { + Ok(x) => api_return_ok!(String, x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + + /// Update a record's value given its ID and the new value. + pub async fn update(&self, id: usize, value: String) -> Result<()> { + match self + .http + .post(format!("{}/api/v1/app_data/{id}/value", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .json(&serde_json::Value::Object({ + let mut map = serde_json::Map::new(); + map.insert("value".to_string(), serde_json::Value::String(value)); + map + })) + .send() + .await + { + Ok(x) => api_return_ok!((), x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + + /// Update a record's key given its ID and the new key. + pub async fn rename(&self, id: usize, key: String) -> Result<()> { + match self + .http + .post(format!("{}/api/v1/app_data/{id}/key", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .json(&serde_json::Value::Object({ + let mut map = serde_json::Map::new(); + map.insert("key".to_string(), serde_json::Value::String(key)); + map + })) + .send() + .await + { + Ok(x) => api_return_ok!((), x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + + /// Delete a row from the app's data by its `id`. + pub async fn remove(&self, id: usize) -> Result<()> { + match self + .http + .delete(format!("{}/api/v1/app_data/{id}", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .send() + .await + { + Ok(x) => api_return_ok!((), x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + + /// Delete row(s) from the app's data by a query. + pub async fn remove_query(&self, query: &AppDataQuery) -> Result<()> { + match self + .http + .delete(format!("{}/api/v1/app_data/query", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .json(&query) + .send() + .await + { + Ok(x) => api_return_ok!((), x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } +} + +/// The state of the [`ApiClient`]. +#[derive(Debug, Clone, Default)] +pub struct ApiClientState { + /// The token you received from an app grant request. + pub user_token: String, + /// The verifier you received from an app grant request. + pub user_verifier: String, + /// The ID of the user this client is connecting to. + pub user_id: usize, + /// The ID of the app that is being used for user grants. + /// + /// You can get this from the web dashboard. + pub app_id: usize, +} + +/// The API client is used to manage authentication flow and send requests on behalf of a user. +/// +/// This client assumes you already have the required information for the given user. +/// If you don't, try using the JS SDK to extract this information. +#[derive(Debug, Clone)] +pub struct ApiClient { + /// The HTTP client associated with this client. + pub http: HttpClient, + /// The general state of the client. Will be updated whenever you refresh the user's token. + pub state: ApiClientState, + /// The origin of the Tetratto server. When creating with [`ApiClient::new`], + /// you can provide `None` to use `https://tetratto.com`. + pub host: String, +} + +impl ApiClient { + /// Create a new [`ApiClient`]. + pub fn new(host: Option, state: ApiClientState) -> Self { + Self { + http: HttpClient::new(), + state, + host: host.unwrap_or("https://tetratto.com".to_string()), + } + } + + /// Refresh the client's user_token. + pub async fn refresh_token(&mut self) -> Result { + match self + .http + .post(format!( + "{}/api/v1/auth/user/{}/grants/{}/refresh", + self.host, self.state.user_id, self.state.app_id + )) + .header("X-Cookie", &format!("Atto-Grant={}", self.state.user_token)) + .json(&serde_json::Value::Object({ + let mut map = serde_json::Map::new(); + map.insert( + "verifier".to_string(), + serde_json::Value::String(self.state.user_verifier.to_owned()), + ); + map + })) + .send() + .await + { + Ok(x) => { + let ret = api_return_ok!(String, x)?; + self.state.user_token = ret.clone(); + Ok(ret) + } + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + + /// Send a simple JSON request to the given endpoint. + pub async fn request( + &self, + route: String, + method: Method, + body: Option<&B>, + ) -> Result> + where + T: Serialize + DeserializeOwned, + B: Serialize + ?Sized, + { + if let Some(body) = body { + match self + .http + .request(method, format!("{}/api/v1/auth/{route}", self.host)) + .header("X-Cookie", &format!("Atto-Grant={}", self.state.user_token)) + .json(&body) + .send() + .await + { + Ok(x) => api_return_ok!(ApiReturn, x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } else { + match self + .http + .request(method, format!("{}/api/v1/auth/{route}", self.host)) + .header("X-Cookie", &format!("Atto-Grant={}", self.state.user_token)) + .send() + .await + { + Ok(x) => api_return_ok!(ApiReturn, x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + } + + /// Send a JSON request with attachments to the given endpoint. + /// + /// This type of request is only required for routes which use JsonMultipart, + /// such as `POST /api/v1/posts` (`create_post`). + /// + /// Method is locked to `POST` for this type of request. + pub async fn request_attachments( + &self, + route: String, + attachments: Vec>, + body: &B, + ) -> Result> + where + T: Serialize + DeserializeOwned, + B: Serialize + ?Sized, + { + let mut multipart_body = Form::new(); + + // add attachments + for v in attachments.clone() { + // the file name doesn't matter + multipart_body = multipart_body.part(String::new(), Part::bytes(v)); + } + + drop(attachments); + + // add json + multipart_body = multipart_body.part( + String::new(), + Part::text(serde_json::to_string(body).unwrap()), + ); + + // ... + match self + .http + .post(format!("{}/api/v1/auth/{route}", self.host)) + .header("X-Cookie", &format!("Atto-Grant={}", self.state.user_token)) + .multipart(multipart_body) + .send() + .await + { + Ok(x) => api_return_ok!(ApiReturn, x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } +} diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index 9544981..5993ffc 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -1,12 +1,14 @@ [package] name = "tetratto-l10n" -version = "11.0.0" +description = "Localization for Tetratto" +version = "12.0.0" edition = "2024" authors.workspace = true repository.workspace = true license.workspace = true +homepage.workspace = true [dependencies] pathbufd = "0.1.4" serde = { version = "1.0.219", features = ["derive"] } -toml = "0.8.23" +toml = "0.9.2" diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 633984b..a866bd1 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -1,18 +1,20 @@ [package] name = "tetratto-shared" -version = "11.0.0" +description = "Shared stuff for Tetratto" +version = "12.0.6" edition = "2024" authors.workspace = true repository.workspace = true license.workspace = true [dependencies] -ammonia = "4.1.0" +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" -serde = "1.0.219" +regex = "1.11.1" +serde = { version = "1.0.219", features = ["derive"] } sha2 = "0.10.9" snowflaked = "1.0.3" uuid = { version = "1.17.0", features = ["v4"] } diff --git a/crates/shared/src/hash.rs b/crates/shared/src/hash.rs index f346861..a267bc4 100644 --- a/crates/shared/src/hash.rs +++ b/crates/shared/src/hash.rs @@ -33,6 +33,18 @@ pub fn salt() -> String { .collect() } +pub fn salt_len(len: usize) -> String { + rng() + .sample_iter(&Alphanumeric) + .take(len) + .map(char::from) + .collect() +} + pub fn random_id() -> String { hash(uuid()) } + +pub fn random_id_salted_len(len: usize) -> String { + hash(uuid() + &salt_len(len)) +} 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("