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 ce61c71..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" @@ -488,7 +482,7 @@ checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" dependencies = [ "chrono", "chrono-tz-build", - "phf", + "phf 0.11.3", ] [[package]] @@ -498,7 +492,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" dependencies = [ "parse-zoneinfo", - "phf", + "phf 0.11.3", "phf_codegen", ] @@ -648,7 +642,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf", + "phf 0.11.3", "smallvec", ] @@ -728,11 +722,11 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "emojis" -version = "0.6.4" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99e1f1df1f181f2539bac8bf027d31ca5ffbf9e559e3f2d09413b9107b5c02f4" +checksum = "0a08afd8e599463c275703532e707c767b8c068a826eea9ca8fceaf3435029df" dependencies = [ - "phf", + "phf 0.12.1", ] [[package]] @@ -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" @@ -2164,7 +2174,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ "phf_macros", - "phf_shared", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", ] [[package]] @@ -2174,7 +2193,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.11.3", ] [[package]] @@ -2183,7 +2202,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", "rand 0.8.5", ] @@ -2194,7 +2213,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.11.3", "proc-macro2", "quote", "syn 2.0.101", @@ -2209,6 +2228,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -2327,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" @@ -2621,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", @@ -2641,6 +2688,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", @@ -2856,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", @@ -2907,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" @@ -3067,7 +3124,7 @@ checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.11.3", "precomputed-hash", "serde", ] @@ -3079,7 +3136,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.11.3", "proc-macro2", "quote", ] @@ -3173,7 +3230,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml", + "toml 0.8.23", "version-compare", ] @@ -3231,19 +3288,20 @@ dependencies = [ [[package]] name = "tetratto" -version = "10.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", @@ -3262,7 +3320,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "10.0.0" +version = "12.0.2" dependencies = [ "async-recursion", "base16ct", @@ -3271,6 +3329,7 @@ dependencies = [ "emojis", "md-5", "oiseau", + "paste", "pathbufd", "regex", "reqwest", @@ -3278,28 +3337,30 @@ dependencies = [ "serde_json", "tetratto-l10n", "tetratto-shared", - "toml", + "tokio", + "toml 0.9.2", "totp-rs", ] [[package]] name = "tetratto-l10n" -version = "10.0.0" +version = "12.0.0" dependencies = [ "pathbufd", "serde", - "toml", + "toml 0.9.2", ] [[package]] name = "tetratto-shared" -version = "10.0.0" +version = "12.0.6" dependencies = [ "ammonia", "chrono", "hex_fmt", - "markdown", + "pulldown-cmark", "rand 0.9.1", + "regex", "serde", "sha2", "snowflaked", @@ -3425,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", @@ -3476,7 +3539,7 @@ dependencies = [ "log", "parking_lot", "percent-encoding", - "phf", + "phf 0.11.3", "pin-project-lite", "postgres-protocol", "postgres-types", @@ -3529,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" @@ -3543,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" @@ -3551,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" @@ -3796,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" @@ -3823,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" @@ -4066,7 +4161,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "954c5a41f2bcb7314344079d0891505458cc2f4b422bdea1d5bfbe6d1a04903b" dependencies = [ - "phf", + "phf 0.11.3", "phf_codegen", "string_cache", "string_cache_codegen", 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/README.md b/README.md index e1ac999..d1a3d80 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ A `docs` directory will be generated in the same directory that you ran the `tet You can configure your port through the `port` key of the configuration file. You can also run the server with the `PORT` environment variable, which will override whatever is set in the configuration file. You can also use the `CACHE_BREAKER` environment variable to specify a version number to be used in static asset links in order to break cache entries. +You can launch with the `LITTLEWEB=true` environment variable to start the littleweb viewer/fake DNS server. This should be used in combination with `PORT`, as well as set as the `lw_host` in your configuration file. This secondary server is required to allow users to view their littleweb projects. + ## Usage (as a user) Tetratto is very simple once you get the hang of it! At the top of the page (or bottom if you're on mobile), you'll see the navigation bar. Once logged in, you'll be able to access "Home", "Popular", and "Communities" from there! You can also press your profile picture (on the right) to view your own profile, settings, or log out! diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 3c66674..e5ca15f 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,7 +1,11 @@ [package] name = "tetratto" -version = "10.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" @@ -9,19 +13,23 @@ serde = { version = "1.0.219", features = ["derive"] } tera = "1.20.0" tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } -tower-http = { version = "0.6.6", features = ["trace", "fs", "catch-panic", "set-header"] } +tower-http = { version = "0.6.6", features = [ + "trace", + "fs", + "catch-panic", + "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" @@ -32,7 +40,9 @@ async-stripe = { version = "0.41.0", features = [ "webhook-events", "billing", "runtime-tokio-hyper", + "connect", ] } -emojis = "0.6.4" +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 3958f09..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,6 +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 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"); @@ -57,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"); @@ -70,6 +69,7 @@ pub const PROFILE_BANNED: &str = include_str!("./public/html/profile/banned.lisp pub const PROFILE_REPLIES: &str = include_str!("./public/html/profile/replies.lisp"); pub const PROFILE_MEDIA: &str = include_str!("./public/html/profile/media.lisp"); pub const PROFILE_OUTBOX: &str = include_str!("./public/html/profile/outbox.lisp"); +pub const PROFILE_RESPONSES: &str = include_str!("./public/html/profile/responses.lisp"); pub const COMMUNITIES_LIST: &str = include_str!("./public/html/communities/list.lisp"); pub const COMMUNITIES_BASE: &str = include_str!("./public/html/communities/base.lisp"); @@ -131,6 +131,14 @@ pub const DEVELOPER_LINK: &str = include_str!("./public/html/developer/link.lisp pub const JOURNALS_APP: &str = include_str!("./public/html/journals/app.lisp"); +pub const LITTLEWEB_SERVICES: &str = include_str!("./public/html/littleweb/services.lisp"); +pub const LITTLEWEB_DOMAINS: &str = include_str!("./public/html/littleweb/domains.lisp"); +pub const LITTLEWEB_SERVICE: &str = include_str!("./public/html/littleweb/service.lisp"); +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"); @@ -138,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; @@ -228,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 { @@ -248,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); @@ -335,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); // ... @@ -356,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); @@ -369,6 +300,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"profile/replies.html"(crate::assets::PROFILE_REPLIES) --config=config --lisp plugins); write_template!(html_path->"profile/media.html"(crate::assets::PROFILE_MEDIA) --config=config --lisp plugins); write_template!(html_path->"profile/outbox.html"(crate::assets::PROFILE_OUTBOX) --config=config --lisp plugins); + write_template!(html_path->"profile/responses.html"(crate::assets::PROFILE_RESPONSES) --config=config --lisp plugins); write_template!(html_path->"communities/list.html"(crate::assets::COMMUNITIES_LIST) -d "communities" --config=config --lisp plugins); write_template!(html_path->"communities/base.html"(crate::assets::COMMUNITIES_BASE) --config=config --lisp plugins); @@ -425,6 +357,14 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"journals/app.html"(crate::assets::JOURNALS_APP) -d "journals" --config=config --lisp plugins); + write_template!(html_path->"littleweb/services.html"(crate::assets::LITTLEWEB_SERVICES) -d "littleweb" --config=config --lisp plugins); + write_template!(html_path->"littleweb/domains.html"(crate::assets::LITTLEWEB_DOMAINS) --config=config --lisp plugins); + write_template!(html_path->"littleweb/service.html"(crate::assets::LITTLEWEB_SERVICE) --config=config --lisp plugins); + 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 } @@ -492,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 13b6b64..bd692f3 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -18,6 +18,7 @@ version = "1.0.0" "general:link.search" = "Search" "general:link.journals" = "Journals" "general:link.achievements" = "Achievements" +"general:link.little_web" = "Little web" "general:action.save" = "Save" "general:action.delete" = "Delete" "general:action.purge" = "Purge" @@ -29,7 +30,9 @@ version = "1.0.0" "general:action.open" = "Open" "general:action.view" = "View" "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" @@ -43,6 +46,8 @@ version = "1.0.0" "general:label.could_not_find_post" = "Could not find original post..." "general:label.timeline_end" = "That's a wrap!" "general:label.loading" = "Working on it!" +"general:label.send_anonymously" = "Send anonymously" +"general:label.must_activate_account" = "You need to activate your account!" "general:label.supporter_motivation" = "Become a supporter!" "general:action.become_supporter" = "Become supporter" @@ -74,6 +79,7 @@ version = "1.0.0" "auth:label.recent_replies" = "Recent replies" "auth:label.recent_posts_with_media" = "Recent posts (with media)" "auth:label.posts" = "Posts" +"auth:label.responses" = "Answers" "auth:label.replies" = "Replies" "auth:label.media" = "Media" "auth:label.outbox" = "Outbox" @@ -87,6 +93,9 @@ version = "1.0.0" "auth:action.message" = "Message" "auth:label.banned" = "Banned" "auth:label.banned_message" = "This user has been banned for breaking the site's rules." +"auth:action.create_account" = "Create account" +"auth:action.purchase_account" = "Purchase account" +"auth:action.continue" = "Continue" "communities:action.create" = "Create" "communities:action.select" = "Select" @@ -122,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" @@ -153,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" @@ -169,8 +180,14 @@ version = "1.0.0" "settings:label.export" = "Export" "settings:label.manage_blocks" = "Manage blocks" "settings:label.users" = "Users" +"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" @@ -185,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" @@ -208,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" @@ -220,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" @@ -228,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" @@ -236,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" @@ -260,3 +286,25 @@ version = "1.0.0" "journals:action.publish" = "Publish" "journals:action.unpublish" = "Unpublish" "journals:action.view" = "View" + +"littleweb:label.create_new" = "Create new site" +"littleweb:label.create_new_domain" = "Create new domain" +"littleweb:label.my_services" = "My sites" +"littleweb:label.my_domains" = "My domains" +"littleweb:label.browser" = "Browser" +"littleweb:label.tld" = "Top-level domain" +"littleweb:label.services" = "Sites" +"littleweb:label.domains" = "Domains" +"littleweb:label.domain_data" = "Domain data" +"littleweb:label.type" = "Type" +"littleweb:label.name" = "Name" +"littleweb:label.value" = "Value" +"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 01406bb..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] @@ -166,7 +183,7 @@ macro_rules! user_banned { let mut context = initial_context(&$data.0.0.0, lang, &$user).await; context.insert("profile", &$other_user); - return Ok(Html( + return Err(Html( $data.1.render("profile/banned.html", &context).unwrap(), )); }; @@ -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( @@ -233,7 +271,7 @@ macro_rules! check_user_blocked_or_private { .is_ok(), ); - return Ok(Html( + return Err(Html( $data.1.render("profile/blocked.html", &context).unwrap(), )); } @@ -281,7 +319,7 @@ macro_rules! check_user_blocked_or_private { .is_ok(), ); - return Ok(Html( + return Err(Html( $data.1.render("profile/private.html", &context).unwrap(), )); } @@ -293,7 +331,7 @@ macro_rules! check_user_blocked_or_private { context.insert("follow_requested", &false); context.insert("is_following", &false); - return Ok(Html( + return Err(Html( $data.1.render("profile/private.html", &context).unwrap(), )); } @@ -352,7 +390,14 @@ macro_rules! ignore_users_gen { ($user:ident, $data:ident) => { if let Some(ref ua) = $user { [ - $data.0.get_userblocks_receivers(ua.id).await, + $data + .0 + .get_userblocks_receivers( + ua.id, + &ua.associated, + ua.settings.hide_associated_blocked_users, + ) + .await, $data.0.get_userblocks_initiator_by_receivers(ua.id).await, $data.0.get_user_stack_blocked_users(ua.id).await, ] @@ -364,7 +409,14 @@ macro_rules! ignore_users_gen { ($user:ident!, $data:ident) => {{ [ - $data.0.get_userblocks_receivers($user.id).await, + $data + .0 + .get_userblocks_receivers( + $user.id, + &$user.associated, + $user.settings.hide_associated_blocked_users, + ) + .await, $data .0 .get_userblocks_initiator_by_receivers($user.id) @@ -376,9 +428,29 @@ macro_rules! ignore_users_gen { ($user:ident!, #$data:ident) => { [ - $data.get_userblocks_receivers($user.id).await, + $data + .get_userblocks_receivers( + $user.id, + &$user.associated, + $user.settings.hide_associated_blocked_users, + ) + .await, $data.get_userblocks_initiator_by_receivers($user.id).await, ] .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 52b35be..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,16 +123,42 @@ 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); tera.register_filter("emojis", render_emojis); let client = Client::new(); + let mut app = Router::new(); - let app = Router::new() - .merge(routes::routes(&config)) - .layer(Extension(Arc::new(RwLock::new((database, tera, client))))) + // 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()); + } else { + app = app + .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' blob: *; frame-ancestors 'self'"), + )); + } + + // add junk + app = app + .layer(Extension(Arc::new(RwLock::new(( + database, + tera, + client, + stripe_client, + ))))) .layer(axum::extract::DefaultBodyLimit::max( var("BODY_LIMIT") .unwrap_or("8388608".to_string()) @@ -128,12 +170,9 @@ async fn main() { .make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO)) .on_response(trace::DefaultOnResponse::new().level(Level::INFO)), ) - .layer(SetResponseHeaderLayer::if_not_present( - HeaderName::from_static("content-security-policy"), - HeaderValue::from_static("default-src 'self' blob: *.spotify.com musicbrainz.org; frame-ancestors 'self'; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' blob: *; script-src 'self' 'unsafe-inline' blob: *; object-src 'self' blob: *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' blob: data: *"), - )) .layer(CatchPanicLayer::new()); + // ... let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.port)) .await .unwrap(); diff --git a/crates/app/src/public/css/root.css b/crates/app/src/public/css/root.css index 3de8708..e1c196b 100644 --- a/crates/app/src/public/css/root.css +++ b/crates/app/src/public/css/root.css @@ -38,6 +38,10 @@ --pad-2: 0.5rem; --pad-3: 0.75rem; --pad-4: 1rem; + + --online: var(--color-green); + --idle: var(--color-yellow); + --offline: hsl(0, 0%, 50%); } .dark, @@ -263,7 +267,7 @@ span, code { max-width: 100%; overflow-wrap: normal; - text-wrap: pretty; + text-wrap: stable; word-wrap: break-word; } diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 5533a96..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; @@ -582,6 +598,10 @@ input[type="checkbox"]:checked { font-size: 12px; border-radius: 6px; height: max-content; + font-weight: 600; + display: flex; + justify-content: center; + align-items: center; } .notification.tr { @@ -596,6 +616,11 @@ input[type="checkbox"]:checked { padding: 0; } +.notification:not(.chip) .icon { + width: 100%; + height: 100%; +} + /* chip */ .chip { background: var(--color-primary); @@ -670,7 +695,7 @@ nav .button:not(.title):not(.active):hover { margin-bottom: 0; backdrop-filter: none; bottom: 0; - position: absolute; + position: fixed; height: max-content; top: unset; } @@ -930,7 +955,7 @@ dialog::backdrop { transition: transform 0.15s; } -.dropdown:has(.inner.open) .dropdown-arrow { +.dropdown:has(.inner.open) .dropdown_arrow { transform: rotateZ(180deg); } @@ -1110,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; @@ -1133,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; @@ -1142,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); } @@ -1156,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/base.lisp b/crates/app/src/public/html/auth/base.lisp index c13c336..3ed7b5a 100644 --- a/crates/app/src/public/html/auth/base.lisp +++ b/crates/app/src/public/html/auth/base.lisp @@ -1,7 +1,7 @@ (text "{% extends \"root.html\" %} {% block body %}") (main ("class" "flex flex-col gap-2") - ("style" "max-width: 25rem") + ("style" "max-width: 48ch") (h2 ("class" "w-full text-center") ; block for title diff --git a/crates/app/src/public/html/auth/login.lisp b/crates/app/src/public/html/auth/login.lisp index cb8bfff..e887b2b 100644 --- a/crates/app/src/public/html/auth/login.lisp +++ b/crates/app/src/public/html/auth/login.lisp @@ -48,7 +48,8 @@ ("name" "totp") ("id" "totp")))) (button - (text "Submit"))) + (icon (text "arrow-right")) + (str (text "auth:action.continue")))) (script (text "let flow_page = 1; diff --git a/crates/app/src/public/html/auth/register.lisp b/crates/app/src/public/html/auth/register.lisp index 9e6c22b..05b3d71 100644 --- a/crates/app/src/public/html/auth/register.lisp +++ b/crates/app/src/public/html/auth/register.lisp @@ -37,16 +37,31 @@ (text "{% if config.security.enable_invite_codes -%}") (div ("class" "flex flex-col gap-1") + ("oninput" "check_should_show_purchase(event)") (label ("for" "invite_code") (b - (text "Invite code"))) + (text "Invite code (optional)"))) (input ("type" "text") ("placeholder" "invite code") - ("required" "") ("name" "invite_code") ("id" "invite_code"))) + + (script + (text "function check_should_show_purchase(e) { + if (e.target.value.length > 0) { + document.querySelector('[ui_ident=purchase_account]').classList.add('hidden'); + document.querySelector('[ui_ident=create_account]').classList.remove('hidden'); + globalThis.DO_PURCHASE = false; + } else { + document.querySelector('[ui_ident=purchase_account]').classList.remove('hidden'); + document.querySelector('[ui_ident=create_account]').classList.add('hidden'); + globalThis.DO_PURCHASE = true; + } + } + + globalThis.DO_PURCHASE = true;")) (text "{%- endif %}") (hr) (div @@ -84,8 +99,33 @@ ("class" "cf-turnstile") ("data-sitekey" "{{ config.turnstile.site_key }}")) (hr) + (text "{% if config.security.enable_invite_codes -%}") + (div + ("class" "w-full flex gap-2 justify-between") + ("ui_ident" "purchase_account") + + (button + (icon (text "credit-card")) + (str (text "auth:action.purchase_account"))) + + (button + ("class" "small square lowered") + ("type" "button") + ("onclick" "document.querySelector('[ui_ident=purchase_help]').classList.toggle('hidden')") + (icon (text "circle-question-mark")))) + + (div + ("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.price_texts.supporter }}.")) + (p (text "Alternatively, you can provide an invite code to create your account for free."))) + (text "{%- endif %}") (button - (text "Submit"))) + ("class" "{% if config.security.enable_invite_codes -%} hidden {%- endif %}") + ("ui_ident" "create_account") + (icon (text "plus")) + (str (text "auth:action.create_account")))) (script (text "async function register(e) { @@ -104,6 +144,7 @@ \"[name=cf-turnstile-response]\", ).value, invite_code: (e.target.invite_code || { value: \"\" }).value, + purchase: globalThis.DO_PURCHASE, }), }) .then((res) => res.json()) 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/body.lisp b/crates/app/src/public/html/body.lisp index 227a8f1..0e7caf9 100644 --- a/crates/app/src/public/html/body.lisp +++ b/crates/app/src/public/html/body.lisp @@ -94,6 +94,8 @@ atto[\"hooks::spotify_time_text\"](); // spotify durations atto[\"hooks::verify_emoji\"](); + fix_atto_links(); + if (document.getElementById(\"tokens\")) { trigger(\"me::render_token_picker\", [ document.getElementById(\"tokens\"), @@ -101,6 +103,11 @@ } setTimeout(() => { + if (globalThis.notifs_stream_init) { + return; + } + + globalThis.notifs_stream_init = true; trigger(\"me::notifications_stream\"); }, 250); }); @@ -158,6 +165,40 @@ (icon (text "x")) (str (text "dialog:action.cancel")))))) +(dialog + ("id" "littleweb") + (div + ("class" "inner flex flex-col gap-2") + + (a + ("class" "button w-full lowered justify-start") + ("href" "/net") + (icon (text "globe")) + (str (text "littleweb:label.browser"))) + + (a + ("class" "button w-full lowered justify-start") + ("href" "/services") + (icon (text "panel-top")) + (str (text "littleweb:label.my_services"))) + + (a + ("class" "button w-full lowered justify-start") + ("href" "/domains") + (icon (text "panel-top")) + (str (text "littleweb:label.my_domains"))) + + (hr ("class" "margin")) + (div + ("class" "flex gap-2 justify-between") + (div null?) + (button + ("class" "lowered red") + ("type" "button") + ("onclick", "document.getElementById('littleweb').close()") + (icon (text "x")) + (str (text "dialog:action.cancel")))))) + (dialog ("id" "web_api_prompt") (div 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 75f9620..8475223 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -102,22 +102,33 @@ ("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 ("style" "display: contents") (text "{{ self::post(post=post, owner=owner, secondary=secondary, community=community, show_community=show_community, can_manage_post=can_manage_post, repost=repost, expect_repost=true) }}")) -(text "{%- endmacro %} {% macro post(post, owner, question=false, secondary=false, community=false, show_community=true, can_manage_post=false, repost=false, expect_repost=false, poll=false, dont_show_title=false) -%} {% if community and show_community and community.id != config.town_square or question %}") +(text "{%- endmacro %} {% macro post(post, owner, question=false, secondary=false, community=false, show_community=true, can_manage_post=false, repost=false, expect_repost=false, poll=false, dont_show_title=false, is_repost=false) -%} {% if community and show_community and community.id != config.town_square or question %}") (div ("class" "card-nest post_outer:{{ post.id }} post_outer") - (text "{% if question -%} {{ self::question(question=question[0], owner=question[1], profile=owner) }} {% else %}") + ("is_repost" "{{ is_repost }}") + (text "{% if question -%} {{ self::question(question=question[0], owner=question[1], asking_about=question[2], profile=owner) }} {% else %}") (div ("class" "card small") (a @@ -172,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") @@ -220,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 @@ -235,7 +252,7 @@ ; content (span ("id" "post_content:{{ post.id }}") (text "{{ post.content|markdown|safe }}")) - (text "{% if expect_repost -%} {% if repost -%} {{ self::post(post=repost[1], owner=repost[0], secondary=not secondary, community=false, show_community=false, can_manage_post=false) }} {% else %}") + (text "{% if expect_repost -%} {% if repost -%} {{ self::post(post=repost[1], owner=repost[0], secondary=not secondary, community=false, show_community=false, can_manage_post=false, is_repost=true) }} {% else %}") (div ("class" "card lowered red flex items-center gap-2") (text "{{ icon \"frown\" }}") @@ -314,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") @@ -328,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\" }}") @@ -350,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\" }}")) @@ -360,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])") @@ -381,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\" }}") @@ -413,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 -%}") @@ -426,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") @@ -527,7 +552,7 @@ ("width" "24") ("height" "24") ("viewBox" "0 0 24 24") - ("style" "fill: var(--color-green)") + ("style" "fill: var(--online)") (circle ("cx" "12") ("cy" "12") @@ -540,7 +565,7 @@ ("width" "24") ("height" "24") ("viewBox" "0 0 24 24") - ("style" "fill: var(--color-yellow)") + ("style" "fill: var(--idle)") (circle ("cx" "12") ("cy" "12") @@ -553,7 +578,7 @@ ("width" "24") ("height" "24") ("viewBox" "0 0 24 24") - ("style" "fill: hsl(0, 0%, 50%)") + ("style" "fill: var(--offline)") (circle ("cx" "12") ("cy" "12") @@ -610,7 +635,8 @@ (text "{%- endif %}") (div ("style" "display: none;") - (text "{{ self::theme_color(color=user.settings.theme_color_surface, css=\"color-surface\") }} {{ self::theme_color(color=user.settings.theme_color_text, css=\"color-text\") }} {{ self::theme_color(color=user.settings.theme_color_text_link, css=\"color-link\") }} {{ self::theme_color(color=user.settings.theme_color_lowered, css=\"color-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_text_lowered, css=\"color-text-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_super_lowered, css=\"color-super-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_raised, css=\"color-raised\") }} {{ self::theme_color(color=user.settings.theme_color_text_raised, css=\"color-text-raised\") }} {{ self::theme_color(color=user.settings.theme_color_super_raised, css=\"color-super-raised\") }} {{ self::theme_color(color=user.settings.theme_color_primary, css=\"color-primary\") }} {{ self::theme_color(color=user.settings.theme_color_text_primary, css=\"color-text-primary\") }} {{ self::theme_color(color=user.settings.theme_color_primary_lowered, css=\"color-primary-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_secondary, css=\"color-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_text_secondary, css=\"color-text-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_secondary_lowered, css=\"color-secondary-lowered\") }} {% if user.permissions|has_supporter -%}") + (text "{{ self::theme_color(color=user.settings.theme_color_surface, css=\"color-surface\") }} {{ self::theme_color(color=user.settings.theme_color_text, css=\"color-text\") }} {{ self::theme_color(color=user.settings.theme_color_text_link, css=\"color-link\") }} {{ self::theme_color(color=user.settings.theme_color_lowered, css=\"color-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_text_lowered, css=\"color-text-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_super_lowered, css=\"color-super-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_raised, css=\"color-raised\") }} {{ self::theme_color(color=user.settings.theme_color_text_raised, css=\"color-text-raised\") }} {{ self::theme_color(color=user.settings.theme_color_super_raised, css=\"color-super-raised\") }} {{ self::theme_color(color=user.settings.theme_color_primary, css=\"color-primary\") }} {{ self::theme_color(color=user.settings.theme_color_text_primary, css=\"color-text-primary\") }} {{ self::theme_color(color=user.settings.theme_color_primary_lowered, css=\"color-primary-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_secondary, css=\"color-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_text_secondary, css=\"color-text-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_secondary_lowered, css=\"color-secondary-lowered\") }} + {{ self::theme_color(color=user.settings.theme_color_online, css=\"online\") }} {{ self::theme_color(color=user.settings.theme_color_idle, css=\"idle\") }} {{ self::theme_color(color=user.settings.theme_color_offline, css=\"offline\") }} {% if user.permissions|has_supporter -%}") (style (text "{{ user.settings.theme_custom_css|remove_script_tags|safe }}")) (text "{%- endif %}")) @@ -622,10 +648,10 @@ --{{ 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 -%}") + (text "{% if owner.id == 0 or question.context.mask_owner -%}") (span (text "{% if profile and profile.settings.anonymous_avatar_url -%}") (img @@ -634,7 +660,7 @@ ("class" "avatar shadow") ("loading" "lazy") ("style" "--size: 52px")) - (text "{% else %} {{ self::avatar(username=owner.username, selector_type=\"username\", size=\"52px\") }} {%- endif %}")) + (text "{% else %} {{ self::avatar(username=\"anonymous\", selector_type=\"username\", size=\"52px\") }} {%- endif %}")) (text "{% else %}") (a ("href" "/@{{ owner.username }}") @@ -646,7 +672,7 @@ ("class" "flex items-center gap-2 flex-wrap") (span ("class" "name") - (text "{% if owner.id == 0 -%} {% if profile and profile.settings.anonymous_username -%}") + (text "{% if owner.id == 0 or question.context.mask_owner -%} {% if profile and profile.settings.anonymous_username -%}") (span ("class" "flex items-center gap-2") (b @@ -692,9 +718,13 @@ (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 %}") + (text "{% if is_helper and (owner.id == 0 or question.context.mask_owner) -%}") (details ("class" "card tiny lowered w-full") (summary @@ -703,12 +733,22 @@ (span (text "View IP"))) (pre (code (text "{{ question.ip }}")))) - (text "{% endif %}") + + (text "{% if question.context.mask_owner -%}") + (details + ("class" "card tiny lowered w-full") + (summary + ("class" "w-full flex gap-2 flex-wrap items-center") + (icon (text "venetian-mask")) + (span (text "Unmask"))) + + (text "{{ self::full_username(user=owner) }}")) + (text "{%- endif %} {%- endif %}") ; ... (div ("class" "flex gap-2 items-center justify-between")))) -(text "{%- endmacro %} {% macro create_question_form(receiver=\"0\", community=\"\", header=\"\", is_global=false, drawing_enabled=false) -%}") +(text "{%- endmacro %} {% macro create_question_form(receiver=\"0\", community=\"\", header=\"\", is_global=false, drawing_enabled=false, allow_anonymous=false) -%}") (div ("class" "card-nest") (div @@ -718,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 @@ -740,54 +781,78 @@ ("minlength" "2") ("maxlength" "4096"))) (div - ("class" "flex gap-2") - (button - ("class" "primary") - (text "{{ text \"communities:action.create\" }}")) + ("class" "flex w-full justify-between gap-2 flex-collapse") + (div + ("class" "flex gap-2") + (button + (text "{{ text \"communities:action.create\" }}")) - (text "{% if drawing_enabled -%}") - (button - ("class" "lowered") - ("ui_ident" "add_drawing") - ("onclick" "attach_drawing()") - ("type" "button") - (text "{{ text \"communities:action.draw\" }}")) + (text "{% if drawing_enabled -%}") + (button + ("class" "lowered") + ("ui_ident" "add_drawing") + ("onclick" "attach_drawing()") + ("type" "button") + (text "{{ text \"communities:action.draw\" }}")) - (button - ("class" "lowered red hidden") - ("ui_ident" "remove_drawing") - ("onclick" "remove_drawing()") - ("type" "button") - (text "{{ text \"communities:action.remove_drawing\" }}")) + (button + ("class" "lowered red hidden") + ("ui_ident" "remove_drawing") + ("onclick" "remove_drawing()") + ("type" "button") + (text "{{ text \"communities:action.remove_drawing\" }}")) - (script - (text "globalThis.attach_drawing = async () => { - globalThis.gerald = await trigger(\"carp::new\", [document.querySelector(\"[ui_ident=carp_canvas_field]\")]); - globalThis.gerald.create_canvas(); + (script + (text "globalThis.attach_drawing = async () => { + globalThis.gerald = await trigger(\"carp::new\", [document.querySelector(\"[ui_ident=carp_canvas_field]\")]); + globalThis.gerald.create_canvas(); - document.querySelector(\"[ui_ident=add_drawing]\").classList.add(\"hidden\"); - document.querySelector(\"[ui_ident=remove_drawing]\").classList.remove(\"hidden\"); - } - - globalThis.remove_drawing = async () => { - if ( - !(await trigger(\"atto::confirm\", [ - \"Are you sure you would like to do this?\", - ])) - ) { - return; + document.querySelector(\"[ui_ident=add_drawing]\").classList.add(\"hidden\"); + document.querySelector(\"[ui_ident=remove_drawing]\").classList.remove(\"hidden\"); } - document.querySelector(\"[ui_ident=carp_canvas_field]\").innerHTML = \"\"; - globalThis.gerald = null; + globalThis.remove_drawing = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } - document.querySelector(\"[ui_ident=add_drawing]\").classList.remove(\"hidden\"); - document.querySelector(\"[ui_ident=remove_drawing]\").classList.add(\"hidden\"); - }")) + document.querySelector(\"[ui_ident=carp_canvas_field]\").innerHTML = \"\"; + globalThis.gerald = null; + + document.querySelector(\"[ui_ident=add_drawing]\").classList.remove(\"hidden\"); + document.querySelector(\"[ui_ident=remove_drawing]\").classList.add(\"hidden\"); + }")) + (text "{%- endif %}")) + + (text "{% if not is_global and allow_anonymous and user -%}") + (div + ("class" "flex gap-2 items-center") + (input + ("type" "checkbox") + ("name" "mask_owner") + ("id" "mask_owner") + ("class" "w-content")) + + (label + ("for" "mask_owner") + (b (str (text "general:label.send_anonymously"))))) (text "{%- endif %}")))) (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\"]); @@ -809,6 +874,8 @@ receiver: \"{{ receiver }}\", community: \"{{ community }}\", is_global: \"{{ is_global }}\" == \"true\", + mask_owner: (e.target.mask_owner || { checked:false }).checked, + asking_about, }), ); @@ -837,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 @@ -864,6 +931,7 @@ ("class" "camo small") ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -977,6 +1045,7 @@ ("class" "camo small") ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -1079,14 +1148,12 @@ (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 not user.settings.disable_achievements -%}") (a ("href" "/achievements") (icon (text "award")) (str (text "general:link.achievements"))) + (text "{%- endif %}") (a ("href" "/settings") (text "{{ icon \"settings\" }}") @@ -1124,22 +1191,18 @@ (icon (text "code")) (str (text "general:link.source_code"))) - (a - ("href" "/reference/tetratto/index.html") - ("class" "button") - ("data-turbo" "false") + (button + ("onclick" "trigger('me::achievement_link', ['OpenReference', '/reference/tetratto/index.html'])") (icon (text "rabbit")) (str (text "general:link.reference"))) - (a - ("href" "{{ config.policies.terms_of_service }}") - ("class" "button") + (button + ("onclick" "trigger('me::achievement_link', ['OpenTos', '{{ config.policies.terms_of_service }}'])") (icon (text "heart-handshake")) (text "Terms of service")) - (a - ("href" "{{ config.policies.privacy }}") - ("class" "button") + (button + ("onclick" "trigger('me::achievement_link', ['OpenPrivacyPolicy', '{{ config.policies.privacy }}'])") (icon (text "cookie")) (text "Privacy policy")) (b ("class" "title") (str (text "general:label.account"))) @@ -1211,6 +1274,7 @@ ("class" "camo small square") ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -1394,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") @@ -1414,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 %}") @@ -1432,6 +1499,7 @@ ("title" "More options") ("onclick" "document.getElementById('post_options_dialog').showModal()") ("type" "button") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (label @@ -1474,6 +1542,7 @@ is_nsfw: false, content_warning: \"\", tags: [], + full_unlist: false, }; window.BLANK_INITIAL_SETTINGS = JSON.stringify( @@ -1510,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, @@ -1726,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 }}") @@ -1803,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 @@ -2017,6 +2090,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("style" "width: 32px") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -2043,6 +2117,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("style" "width: 32px") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -2134,6 +2209,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("style" "width: 32px") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -2213,6 +2289,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("style" "width: 32px") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -2256,3 +2333,121 @@ (text "{{ self::note_mover_dirs_listing(dir=subdir, dirs=dirs) }}") (text "{%- endif %} {% endfor %}")) (text "{%- endmacro %}") + +(text "{% macro become_supporter_button() -%}") +(p + (text "You're ") + (b + (text "not ")) + (text "currently a supporter! No + pressure, but it helps us do some pretty cool + things! As a supporter, you'll get:")) +(ul + ("style" "margin-bottom: var(--pad-4)") + (li + (text "Vanity badge on profile")) + (li + (text "No more supporter ads (duh)")) + (li + (text "Ability to upload gif avatars/banners")) + (li + (text "Be an admin/owner of up to 10 communities")) + (li + (text "Use custom CSS on your profile")) + (li + (text "Use community emojis outside of + their community")) + (li + (text "Upload and use gif emojis")) + (li + (text "Create infinite stack timelines")) + (li + (text "Upload images to posts")) + (li + (text "Save infinite post drafts")) + (li + (text "Ability to search through all posts")) + (li + (text "Create up to 10 stack blocks")) + (li + (text "Add unlimited users to stacks")) + (li + (text "Increased proxied image size")) + (li + (text "Create infinite journals")) + (li + (text "Create infinite notes in each journal")) + (li + (text "Publish up to 50 notes")) + (li + (text "Create infinite Littleweb sites")) + (li + (text "Create infinite Littleweb domains")) + + (text "{% if config.security.enable_invite_codes -%}") + (li + (text "Create up to 48 invite codes") + (sup (a ("href" "#footnote-1") (text "1")))) + (text "{%- endif %}")) +(a + ("href" "{{ config.stripe.payment_links.supporter }}?client_reference_id={{ user.id }}") + ("class" "button") + ("target" "_blank") + (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 "{% if config.security.enable_invite_codes -%}") +(span + ("class" "fade") + ("id" "footnote-1") + (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 new file mode 100644 index 0000000..95f35d8 --- /dev/null +++ b/crates/app/src/public/html/littleweb/browser.lisp @@ -0,0 +1,227 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "{{ config.name }}")) + +(text "{% endblock %} {% block body %}") +(div + ("id" "panel") + ("class" "flex flex-row gap-2") + (a + ("class" "button camo") + ("href" "/") + (icon (text "house"))) + + (button + ("class" "lowered") + ("onclick" "back()") + (icon (text "arrow-left"))) + + (button + ("class" "lowered") + ("onclick" "forward()") + (icon (text "arrow-right"))) + + (button + ("class" "lowered") + ("onclick" "reload()") + (icon (text "rotate-cw"))) + + (form + ("class" "w-full flex gap-1 flex-row") + ("onsubmit" "event.preventDefault(); littleweb_navigate(event.target.uri.getAttribute('true_value'))") + (input + ("type" "uri") + ("class" "w-full") + ("true_value" "") + ("name" "uri") + ("id" "uri")) + + (button ("class" "lowered small square") (icon (text "arrow-right")))) + + (text "{% if user -%}") + (div + ("class" "dropdown") + (button + ("class" "flex-row camo") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("style" "gap: var(--pad-1) !important") + (text "{{ components::avatar(username=user.username, size=\"24px\") }}") + (icon_class (text "chevron-down") (text "dropdown_arrow"))) + + (text "{{ components::user_menu() }}")) + (text "{%- endif %}")) + +(iframe + ("id" "browser_iframe") + ("frameborder" "0") + ("src" "{% if path -%} {{ config.lw_host }}/api/v1/net/{{ path }}?s={{ session }} {%- endif %}")) + +(style + ("data-turbo-temporary" "true") + (text ":root { + --panel-height: 45px; + } + + html, + body { + padding: 0; + margin: 0; + overflow: hidden; + } + + #panel { + width: 100dvw; + height: var(--panel-height); + padding: var(--pad-2); + } + + #panel input { + border: none; + background: var(--color-lowered); + transition: background 0.15s; + } + + #panel input:focus { + background: var(--color-super-lowered); + } + + @media screen and (max-width: 900px) { + #panel input:focus { + position: fixed; + width: calc(100dvw - (62px + var(--pad-2) * 2)) !important; + left: var(--pad-2); + z-index: 2; + } + } + + #panel button:not(.inner *), + #panel a.button:not(.inner *), + #panel input { + --h: 28.2px; + height: var(--h); + min-height: var(--h); + max-height: var(--h); + font-size: 16px; + } + + #panel button:not(.inner *), + #panel a.button:not(.inner *) { + padding: var(--pad-1) var(--pad-2); + } + + iframe { + width: 100dvw; + height: calc(100dvh - var(--panel-height)); + }")) + +(script + (text "globalThis.SECRET_SESSION = \"{{ session }}\"; + function littleweb_navigate(uri) { + if (!uri.includes(\".html\")) { + uri = `${uri}/index.html`; + } + + // ... + console.log(\"navigate\", 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) => { + console.log(\"web content loaded\"); + }); + + window.addEventListener(\"message\", (e) => { + if (typeof e.data !== \"string\") { + console.log(\"refuse message (bad type)\"); + return; + } + + const data = JSON.parse(e.data); + + if (!data.t) { + console.log(\"refuse message (not for tetratto)\"); + return; + } + + console.log(\"received message\"); + + 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://\", \"\")}`); + + 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]; + } + }); + + function back() { + post_message({ t: true, event: \"back\" }); + } + + function forward() { + post_message({ t: true, event: \"forward\" }); + } + + function reload() { + post_message({ t: true, event: \"reload\" }); + } + + function post_message(data) { + const origin = new URL(document.getElementById(\"browser_iframe\").src).origin; + document.getElementById(\"browser_iframe\").contentWindow.postMessage(JSON.stringify(data), origin); + } + + // handle dropdowns + window.addEventListener(\"blur\", () => { + trigger(\"atto::hooks::dropdown.close\"); + }); + + // url bar focus + document.getElementById(\"uri\").addEventListener(\"input\", (e) => { + e.target.setAttribute(\"true_value\", e.target.value); + }); + + let is_focused = false; + + document.getElementById(\"uri\").addEventListener(\"mouseenter\", (e) => { + e.target.value = e.target.getAttribute(\"true_value\").replace(\"/index.html\", \"\"); + }); + + document.getElementById(\"uri\").addEventListener(\"mouseleave\", (e) => { + if (is_focused) { + return; + } + + e.target.value = e.target.getAttribute(\"true_value\").replace(\"atto://\", \"\").split(\"/\")[0]; + }); + + document.getElementById(\"uri\").addEventListener(\"focus\", (e) => { + e.target.value = e.target.getAttribute(\"true_value\").replace(\"/index.html\", \"\"); + is_focused = true; + }); + + document.getElementById(\"uri\").addEventListener(\"blur\", (e) => { + e.target.value = e.target.getAttribute(\"true_value\").replace(\"atto://\", \"\").split(\"/\")[0]; + is_focused = false; + }); + + // 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 new file mode 100644 index 0000000..d4bc359 --- /dev/null +++ b/crates/app/src/public/html/littleweb/domain.lisp @@ -0,0 +1,278 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "My services - {{ config.name }}")) + +(text "{% endblock %} {% block body %} {{ macros::nav() }}") +(main + ("class" "flex flex-col gap-2") + (text "{% if user -%}") + (div + ("class" "pillmenu") + (a ("href" "/services") (str (text "littleweb:label.services"))) + (a ("href" "/domains") ("class" "active") (str (text "littleweb:label.domains")))) + + (div + ("class" "card-nest") + (div + ("class" "card small") + (b + (text "{{ domain.name }}.{{ domain.tld|lower }}"))) + (div + ("class" "flex flex-col gap-2 card") + (code + ("class" "w-content") + (a + ("href" "atto://{{ domain.name }}.{{ domain.tld|lower }}") + (text "atto://{{ domain.name }}.{{ domain.tld|lower }}"))) + (div + ("class" "flex gap-2 flex-wrap") + (button + ("class" "red lowered") + ("onclick" "delete_domain()") + (icon (text "trash")) + (str (text "general:action.delete")))))) + (text "{%- endif %}") + (div + ("class" "card-nest w-full") + (div + ("class" "card small flex flex-col gap-2") + (div + ("class" "flex items-center justify-between gap-2") + (div + ("class" "flex items-center gap-2") + (icon (text "panel-top")) + (span + (str (text "littleweb:label.domain_data")))) + + (div + ("class" "flex gap-2") + (button + ("class" "small lowered") + ("title" "Help") + ("onclick" "document.getElementById('domain_help').classList.toggle('hidden')") + (icon (text "circle-question-mark"))) + + (button + ("class" "small") + ("onclick" "document.getElementById('add_data').classList.toggle('hidden')") + (icon (text "plus")) + (str (text "littleweb:action.add"))))) + + (div + ("class" "card w-full lowered flex flex-col gap-2 hidden no_p_margin") + ("id" "domain_help") + (p (text "To link your domain to a site, go to the site and press \"Copy ID\".")) + (p (text "After you have the site's ID, click \"Add\" on this page, then paste the ID into the \"value\" field.")) + (p (text "If you've ever managed a real domain's DNS, this should be familiar.")))) + (div + ("class" "card flex flex-col gap-2") + ; add data + (form + ("id" "add_data") + ("class" "card hidden w-full lowered flex flex-col gap-2") + ("onsubmit" "add_data_from_form(event)") + (div + ("class" "flex gap-2 flex-collapse") + (div + ("class" "flex w-full flex-col gap-1") + (label + ("for" "name") + (str (text "littleweb:label.type"))) + (select + ("type" "text") + ("name" "type") + ("id" "type") + ("placeholder" "type") + ("required" "") + (option ("value" "Service") (text "Site ID")) + (option ("value" "Text") (text "Text")))) + (div + ("class" "flex w-full flex-col gap-1") + (label + ("for" "name") + (str (text "littleweb:label.name"))) + (input + ("type" "text") + ("name" "name") + ("id" "name") + ("placeholder" "name") + ("minlength" "1") + ("maxlength" "32")) + (span ("class" "fade") (text "Use \"@\" for root."))) + (div + ("class" "flex w-full flex-col gap-1") + (label + ("for" "value") + (str (text "littleweb:label.value"))) + (input + ("type" "text") + ("name" "value") + ("id" "value") + ("placeholder" "value") + ("required" "") + ("minlength" "2") + ("maxlength" "256")))) + (div + ("class" "flex w-full justify-between") + (div) + (button + (icon (text "check")) + (str (text "general:action.save"))))) + ; data + (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"))) + (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 %}"))))))) + +(script ("id" "domain_data") ("type" "application/json") (text "{{ domain.data|json_encode()|safe }}")) +(script + (text "globalThis.DOMAIN_DATA = JSON.parse(document.getElementById(\"domain_data\").innerText); + async function save_data() { + await trigger(\"atto::debounce\", [\"domains::update_data\"]); + fetch(\"/api/v1/domains/{{ domain.id }}/data\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + data: DOMAIN_DATA, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function add_data_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"domains::add_data\"]); + + const x = {}; + x[e.target.type.selectedOptions[0].value] = e.target.value.value; + + if (e.target.name.value === \"\") { + e.target.name.value = \"@\"; + } + + const name = e.target.name.value.replace(\" \", \"_\"); + if (DOMAIN_DATA.find((x) => x[0] === name)) { + return; + } + + DOMAIN_DATA.push([name, x]); + await save_data(); + e.target.reset(); + } + + async function delete_data(name) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"domains::delete_data\"]); + + delete DOMAIN_DATA.find((x) => x[0] === name); + await save_data(); + } + + async function delete_domain() { + await trigger(\"atto::debounce\", [\"domains::delete\"]); + + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/domains/{{ domain.id }}\", { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function rename_data(selector) { + await trigger(\"atto::debounce\", [\"domains::rename_data\"]); + + let name = await trigger(\"atto::prompt\", [\"New name:\"]); + + if (!name) { + return; + } + + DOMAIN_DATA.find((x) => x[0] === selector)[0] = name.replaceAll(\" \", \"_\"); + await save_data(); + + setTimeout(() => { + window.location.reload(); + }, 150); + } + + async function remove_data(name) { + await trigger(\"atto::debounce\", [\"domains::remove_data\"]); + + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + let i = 0; + DOMAIN_DATA.find((x) => { + i += 1; + return x[0] === name; + }); + + DOMAIN_DATA.splice(i - 1, 1); + await save_data(); + }")) + +(text "{% endblock %}") diff --git a/crates/app/src/public/html/littleweb/domains.lisp b/crates/app/src/public/html/littleweb/domains.lisp new file mode 100644 index 0000000..c79ab3e --- /dev/null +++ b/crates/app/src/public/html/littleweb/domains.lisp @@ -0,0 +1,134 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "My domains - {{ config.name }}")) + +(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") + (a ("href" "/services") (str (text "littleweb:label.services"))) + (a ("href" "/domains") ("class" "active") (str (text "littleweb:label.domains")))) + + (div + ("class" "card-nest") + (div + ("class" "card small") + (b + (str (text "littleweb:label.create_new_domain")))) + (form + ("class" "card flex flex-col gap-2") + ("onsubmit" "create_domain_from_form(event)") + (div + ("class" "flex flex-col gap-1") + (label + ("for" "name") + (text "{{ text \"communities:label.name\" }}")) + (input + ("type" "text") + ("name" "name") + ("id" "name") + ("placeholder" "name") + ("required" "") + ("minlength" "2") + ("maxlength" "32"))) + (div + ("class" "flex flex-col gap-1") + (label + ("for" "tld") + (str (text "littleweb:label.tld"))) + (select + ("type" "text") + ("name" "tld") + ("id" "tld") + ("placeholder" "tld") + ("required" "") + (text "{% for tld in tlds -%}") + (option ("value" "{{ tld }}") (text ".{{ tld|lower }}")) + (text "{%- endfor %}"))) + (button + (text "{{ text \"communities:action.create\" }}")) + + (details + (summary + (icon (text "circle-alert")) + (text "Disclaimer")) + + (div + ("class" "card lowered no_p_margin") + (p (text "Domains are registered into {{ config.name }}'s closed web.")) + (p (text "This means that domains are only accessible through {{ config.name }}, as well as other supporting sites.")) + (p (text "If you would prefer a public-facing domain, those cost money and cannot be bought from {{ config.name }}.")) + (p (text "All domains have first-class support on {{ config.name }}, meaning all links to them will work properly on this site.")))))) + (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 "panel-top")) + (span + (str (text "littleweb:label.my_domains"))))) + (div + ("class" "card flex flex-col gap-2") + (text "{% for item in list %}") + (a + ("href" "/domains/{{ item.id }}") + ("class" "card secondary flex flex-col gap-2") + (div + ("class" "flex items-center gap-2") + (icon (text "globe")) + (b + (text "{{ item.name }}.{{ item.tld|lower }}"))) + (span + (text "Created ") + (span + ("class" "date") + (text "{{ item.created }}")) + (text "; {{ item.data|length }} entries"))) + (text "{% endfor %}")))) + +(script + (text "async function create_domain_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"domains::create\"]); + + fetch(\"/api/v1/domains\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + name: e.target.name.value, + tld: e.target.tld.selectedOptions[0].value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + setTimeout(() => { + window.location.href = `/domains/${res.payload}`; + }, 100); + } + }); + }")) + +(text "{% endblock %}") diff --git a/crates/app/src/public/html/littleweb/service.lisp b/crates/app/src/public/html/littleweb/service.lisp new file mode 100644 index 0000000..7cd9597 --- /dev/null +++ b/crates/app/src/public/html/littleweb/service.lisp @@ -0,0 +1,373 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "My services - {{ config.name }}")) + +(text "{% endblock %} {% block body %} {{ macros::nav() }}") +(main + ("class" "flex flex-col gap-2") + (text "{% if user -%}") + (div + ("class" "pillmenu") + (a ("href" "/services") ("class" "active") (str (text "littleweb:label.services"))) + (a ("href" "/domains") (str (text "littleweb:label.domains")))) + + (div + ("class" "card-nest") + (div + ("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") + (text "{% if file and file.children|length == 0 -%}") + (button + ("onclick" "update_content()") + (icon (text "check")) + (str (text "general:action.save"))) + (text "{%- endif %}") + + (button + ("class" "lowered") + ("onclick" "update_name()") + (icon (text "pencil")) + (str (text "littleweb:action.edit_site_name"))) + + (button + ("class" "lowered") + ("onclick" "trigger('atto::copy_text', ['{{ service.id }}'])") + (icon (text "copy")) + (str (text "general:action.copy_id"))) + + (button + ("class" "red lowered") + ("onclick" "delete_service()") + (icon (text "trash")) + (str (text "general:action.delete"))))) + (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 "folder-open")) + (span (text "{% if path -%} {{ path }} {%- else -%} / {%- endif %}"))) + + (div + ("class" "flex items-center gap-2") + (button + ("class" "lowered small") + ("onclick" "go_up()") + (icon (text "arrow-up"))) + + (text "{% if not file or file.content|length == 0 -%}") + (button + ("class" "lowered small") + ("onclick" "create_file()") + (icon (text "plus")) + (str (text "communities:action.create"))) + (text "{%- endif %}"))) + (div + ("class" "card flex flex-col gap-2") + (text "{% if not file or file.children|length > 0 -%}") + ; directory browser + (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 %}") + + (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"))) + (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 %}")))) + (text "{% else %}") + ; file editor + (div ("id" "editor_container") ("class" "w-full") ("style" "height: 600px")) + (text "{%- endif %}")))) + +(script ("id" "all_service_files") ("type" "application/json") (text "{{ service.files|json_encode()|remove_script_tags|safe }}")) +(script ("id" "service_files") ("type" "application/json") (text "{{ files|json_encode()|remove_script_tags|safe }}")) +(script ("id" "id_path") ("type" "application/json") (text "{{ id_path|json_encode()|remove_script_tags|safe }}")) + +(script + (text "globalThis.SERVICE_FILES = JSON.parse(document.getElementById(\"service_files\").innerText); + globalThis.EXTENSION_MIMES = { + \"html\": \"text/html\", + \"js\": \"text/javascript\", + \"css\": \"text/css\", + \"json\": \"application/json\", + \"txt\": \"text/plain\", + } + + globalThis.MIME_MODES = { + \"Html\": \"html\", + \"Js\": \"javascript\", + \"Css\": \"css\", + \"Json\": \"json\", + \"Plain\": \"txt\", + } + + function go_up() { + const x = JSON.parse(document.getElementById(\"id_path\").innerText); + const y = JSON.parse(document.getElementById(\"all_service_files\").innerText); + + x.pop(); + let path = \"\"; + + for (id of x) { + path += `/${y.find((x) => x.id == id).name}`; + } + + window.location.href = `?path=${path}`; + } + + async function update_name() { + await trigger(\"atto::debounce\", [\"services::update_name\"]); + + const name = await trigger(\"atto::prompt\", [\"New name:\"]); + + if (!name) { + return; + } + + fetch(\"/api/v1/services/{{ service.id }}/name\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + name: e.target.name.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function delete_service() { + await trigger(\"atto::debounce\", [\"services::delete\"]); + + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/services/{{ service.id }}\", { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function update_content() { + await trigger(\"atto::debounce\", [\"services::update_content\"]); + const content = globalThis.editor.getValue(); + fetch(\"/api/v1/services/{{ service.id }}/content\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + content, + id_path: JSON.parse(document.getElementById(\"id_path\").innerText), + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function update_files() { + await trigger(\"atto::debounce\", [\"services::update_files\"]); + fetch(\"/api/v1/services/{{ service.id }}/files\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + files: SERVICE_FILES, + id_path: JSON.parse(document.getElementById(\"id_path\").innerText), + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function create_file() { + await trigger(\"atto::debounce\", [\"services::create_file\"]); + + let name = await trigger(\"atto::prompt\", [\"Name:\"]); + + if (!name) { + return; + } + + const s = name.split(\".\"); + SERVICE_FILES.push({ + id: window.crypto.randomUUID(), + name, + mime: EXTENSION_MIMES[s[s.length - 1]] || EXTENSION_MIMES[\"txt\"], + children: [], + content: \"\", + }); + + await update_files(); + + setTimeout(() => { + window.location.reload(); + }, 150); + } + + async function rename_file(id) { + await trigger(\"atto::debounce\", [\"services::rename_file\"]); + + let name = await trigger(\"atto::prompt\", [\"New name:\"]); + + if (!name) { + return; + } + + const file_ref = SERVICE_FILES.find((x) => x.id === id); + file_ref.name = name; + + const s = name.split(\".\"); + file_ref.mime = EXTENSION_MIMES[s[s.length - 1]] || EXTENSION_MIMES[\"txt\"]; + + await update_files(); + } + + async function remove_file(id) { + await trigger(\"atto::debounce\", [\"services::remove_file\"]); + + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + let i = 0; + SERVICE_FILES.find((x) => { + i += 1; + return x.id === id; + }); + + SERVICE_FILES.splice(i - 1, 1); + await update_files(); + }")) + +(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\" } }); + + require([\"vs/editor/editor.main\"], () => { + const shadow = document.getElementById(\"editor_container\").attachShadow({ + mode: \"closed\", + }); + + const inner = document.createElement(\"div\"); + inner.style.width = window.getComputedStyle(document.getElementById(\"editor_container\")).width; + inner.style.height = window.getComputedStyle(document.getElementById(\"editor_container\")).height; + shadow.appendChild(inner); + + const style = document.createElement(\"style\"); + 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 %}") +(text "{% endblock %}") diff --git a/crates/app/src/public/html/littleweb/services.lisp b/crates/app/src/public/html/littleweb/services.lisp new file mode 100644 index 0000000..261b006 --- /dev/null +++ b/crates/app/src/public/html/littleweb/services.lisp @@ -0,0 +1,110 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "My services - {{ config.name }}")) + +(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") + (a ("href" "/services") ("class" "active") (str (text "littleweb:label.services"))) + (a ("href" "/domains") (str (text "littleweb:label.domains")))) + + (div + ("class" "card-nest") + (div + ("class" "card small") + (b + (str (text "littleweb:label.create_new")))) + (form + ("class" "card flex flex-col gap-2") + ("onsubmit" "create_service_from_form(event)") + (div + ("class" "flex flex-col gap-1") + (label + ("for" "name") + (text "{{ text \"communities:label.name\" }}")) + (input + ("type" "text") + ("name" "name") + ("id" "name") + ("placeholder" "name") + ("required" "") + ("minlength" "2") + ("maxlength" "32"))) + (button + (text "{{ text \"communities:action.create\" }}")))) + (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 "panel-top")) + (span + (str (text "littleweb:label.my_services"))))) + (div + ("class" "card flex flex-col gap-2") + (text "{% for item in list %}") + (a + ("href" "/services/{{ item.id }}") + ("class" "card secondary flex flex-col gap-2") + (div + ("class" "flex items-center gap-2") + (icon (text "globe")) + (b + (text "{{ item.name }}"))) + (span + (text "Created ") + (span + ("class" "date") + (text "{{ item.created }}")) + (text "; Updated ") + (span + ("class" "date") + (text "{{ item.revision }}")))) + (text "{% endfor %}")))) + +(script + (text "async function create_service_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"services::create\"]); + + fetch(\"/api/v1/services\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + name: e.target.name.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + setTimeout(() => { + window.location.href = `/services/${res.payload}`; + }, 100); + } + }); + }")) + +(text "{% endblock %}") diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index b2e8863..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,16 +59,54 @@ ("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") (button ("class" "flex-row title") ("onclick" "trigger('atto::hooks::dropdown', [event])") - ("exlude" "dropdown") + ("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") @@ -252,10 +284,17 @@ ("class" "pillmenu") (text "{% if is_self or is_helper or not profile.settings.hide_extra_post_tabs -%}") (a - ("href" "/@{{ profile.username }}") + ("href" "/@{{ profile.username }}?f=true") ("class" "{% if selected == 'posts' -%}active{%- endif %}") (str (text "auth:label.posts"))) + (text "{% if profile.settings.enable_questions -%}") + (a + ("href" "/@{{ profile.username }}?r=true") + ("class" "{% if selected == 'responses' -%}active{%- endif %}") + (str (text "auth:label.responses"))) + (text "{%- endif %}") + (a ("href" "/@{{ profile.username }}/replies") ("class" "{% if selected == 'replies' -%}active{%- endif %}") @@ -311,8 +350,9 @@ (span (text "{{ text \"settings:tab.theme\" }}"))) (a + ("href" "#") ("data-tab-button" "sessions") - ("href" "#/sessions") + ("onclick" "trigger('me::achievement_link', ['OpenSessionSettings', '#/sessions'])") (text "{{ icon \"cookie\" }}") (span (text "{{ text \"settings:tab.sessions\" }}"))) @@ -323,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 4b21b5d..93f895e 100644 --- a/crates/app/src/public/html/misc/achievements.lisp +++ b/crates/app/src/public/html/misc/achievements.lisp @@ -12,9 +12,12 @@ (icon (text "coffee")) (span (text "Welcome to {{ config.name }}!"))) (div - ("class" "card no_p_margin") + ("class" "card no_p_margin flex flex-col gap-2") (p (text "To help you move in, you'll be rewarded with small achievements for completing simple tasks on {{ config.name }}!")) - (p (text "You'll find out what each achievement is when you get it, so look around!")))) + (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" "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 e64ec63..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(() => { @@ -168,11 +207,26 @@ \"{{ profile.is_verified }}\", \"checkbox\", ], + [ + [\"awaiting_purchase\", \"Awaiting purchase\"], + \"{{ 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, { @@ -181,9 +235,22 @@ is_verified: value, }); }, + awaiting_purchase: (value) => { + profile_request(false, \"awaiting_purchase\", { + 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); @@ -216,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 @@ -234,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( @@ -281,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 7718eea..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") @@ -219,12 +227,24 @@ (text "{{ icon \"user-minus\" }}") (span (text "{{ text \"auth:action.unfollow\" }}"))) - (button - ("onclick" "toggle_block_user()") - ("class" "lowered red") - (text "{{ icon \"shield\" }}") - (span - (text "{{ text \"auth:action.block\" }}"))) + (div + ("class" "dropdown") + (button + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("class" "lowered red") + (icon_class (text "chevron-down") (text "dropdown_arrow")) + (str (text "auth:action.block"))) + (div + ("class" "inner left") + (button + ("onclick" "toggle_block_user()") + (icon (text "shield")) + (str (text "auth:action.block"))) + (button + ("onclick" "ip_block_user()") + (icon (text "wifi")) + (str (text "auth:action.ip_block"))))) (text "{% else %}") (button ("onclick" "toggle_block_user()") @@ -278,7 +298,7 @@ ]); fetch( - \"/api/v1/auth/user/{{ profile.id }}/follow\", + \"/api/v1/auth/user/{{ profile.id }}/follow/toggle\", { method: \"POST\", }, @@ -342,6 +362,30 @@ res.message, ]); }); + }; + + globalThis.ip_block_user = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch( + \"/api/v1/auth/user/{{ profile.id }}/block_ip\", + { + method: \"POST\", + }, + ) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); };")))) (text "{%- endif %} {% if not profile.settings.private_communities or is_self or is_helper %}") (div diff --git a/crates/app/src/public/html/profile/blocked.lisp b/crates/app/src/public/html/profile/blocked.lisp index b1c59c1..660be0d 100644 --- a/crates/app/src/public/html/profile/blocked.lisp +++ b/crates/app/src/public/html/profile/blocked.lisp @@ -24,12 +24,24 @@ (div ("class" "card w-full secondary flex gap-2") (text "{% if user -%} {% if not is_blocking -%}") - (button - ("onclick" "toggle_block_user()") - ("class" "lowered red") - (text "{{ icon \"shield\" }}") - (span - (text "{{ text \"auth:action.block\" }}"))) + (div + ("class" "dropdown") + (button + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("class" "lowered red") + (icon_class (text "chevron-down") (text "dropdown_arrow")) + (str (text "auth:action.block"))) + (div + ("class" "inner left") + (button + ("onclick" "toggle_block_user()") + (icon (text "shield")) + (str (text "auth:action.block"))) + (button + ("onclick" "ip_block_user()") + (icon (text "wifi")) + (str (text "auth:action.ip_block"))))) (text "{% else %}") (button ("onclick" "toggle_block_user()") @@ -58,6 +70,30 @@ res.message, ]); }); + }; + + globalThis.ip_block_user = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch( + \"/api/v1/auth/user/{{ profile.id }}/block_ip\", + { + method: \"POST\", + }, + ) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); };")) (text "{%- endif %}") (a diff --git a/crates/app/src/public/html/profile/media.lisp b/crates/app/src/public/html/profile/media.lisp index a3888dc..b4bf80c 100644 --- a/crates/app/src/public/html/profile/media.lisp +++ b/crates/app/src/public/html/profile/media.lisp @@ -1,7 +1,7 @@ (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}") (div ("style" "display: contents") - (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}")) + (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}")) (text "{%- endif %} {{ macros::profile_nav(selected=\"media\") }}") (div diff --git a/crates/app/src/public/html/profile/outbox.lisp b/crates/app/src/public/html/profile/outbox.lisp index d77d314..7dbcabb 100644 --- a/crates/app/src/public/html/profile/outbox.lisp +++ b/crates/app/src/public/html/profile/outbox.lisp @@ -1,7 +1,7 @@ (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}") (div ("style" "display: contents") - (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}")) + (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}")) (text "{%- endif %} {{ macros::profile_nav(selected=\"outbox\") }}") (div diff --git a/crates/app/src/public/html/profile/posts.lisp b/crates/app/src/public/html/profile/posts.lisp index 6120fea..183aeca 100644 --- a/crates/app/src/public/html/profile/posts.lisp +++ b/crates/app/src/public/html/profile/posts.lisp @@ -1,7 +1,7 @@ (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}") (div ("style" "display: contents") - (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}")) + (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}")) (text "{%- endif %} {% if not tag and pinned|length != 0 -%}") (div diff --git a/crates/app/src/public/html/profile/private.lisp b/crates/app/src/public/html/profile/private.lisp index f9963f6..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") @@ -47,12 +52,24 @@ (span (text "{{ text \"auth:action.unfollow\" }}"))) (text "{%- endif %} {% if not is_blocking -%}") - (button - ("onclick" "toggle_block_user()") - ("class" "lowered red") - (text "{{ icon \"shield\" }}") - (span - (text "{{ text \"auth:action.block\" }}"))) + (div + ("class" "dropdown") + (button + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("class" "lowered red") + (icon_class (text "chevron-down") (text "dropdown_arrow")) + (str (text "auth:action.block"))) + (div + ("class" "inner left") + (button + ("onclick" "toggle_block_user()") + (icon (text "shield")) + (str (text "auth:action.block"))) + (button + ("onclick" "ip_block_user()") + (icon (text "wifi")) + (str (text "auth:action.ip_block"))))) (text "{% else %}") (button ("onclick" "toggle_block_user()") @@ -64,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()) @@ -151,6 +168,30 @@ res.message, ]); }); + }; + + globalThis.ip_block_user = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch( + \"/api/v1/auth/user/{{ profile.id }}/block_ip\", + { + method: \"POST\", + }, + ) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); };")) (text "{%- endif %}") (a diff --git a/crates/app/src/public/html/profile/replies.lisp b/crates/app/src/public/html/profile/replies.lisp index 4afe348..5f70d68 100644 --- a/crates/app/src/public/html/profile/replies.lisp +++ b/crates/app/src/public/html/profile/replies.lisp @@ -1,7 +1,7 @@ (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}") (div ("style" "display: contents") - (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}")) + (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}")) (text "{%- endif %} {{ macros::profile_nav(selected=\"replies\") }}") (div diff --git a/crates/app/src/public/html/profile/responses.lisp b/crates/app/src/public/html/profile/responses.lisp new file mode 100644 index 0000000..868f959 --- /dev/null +++ b/crates/app/src/public/html/profile/responses.lisp @@ -0,0 +1,55 @@ +(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}") +(div + ("style" "display: contents") + (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}")) + +(text "{%- endif %} {% if not tag and pinned|length != 0 -%}") +(div + ("class" "card-nest") + (div + ("class" "card small flex gap-2 items-center") + (text "{{ icon \"pin\" }}") + (span + (text "{{ text \"communities:label.pinned\" }}"))) + (div + ("class" "card flex flex-col gap-4") + (text "{% for post in pinned %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self, poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}"))) + +(text "{%- endif %} {{ macros::profile_nav(selected=\"responses\") }}") +(div + ("class" "card-nest") + (div + ("class" "card small flex gap-2 justify-between items-center") + (div + ("class" "flex gap-2 items-center") + (text "{% if not tag -%} {{ icon \"clock\" }}") + (span + (text "{{ text \"auth:label.recent_posts\" }}")) + (text "{% else %} {{ icon \"tag\" }}") + (span + (text "{{ text \"auth:label.recent_with_tag\" }}: ") + (b + (text "{{ tag }}"))) + (text "{%- endif %}")) + (text "{% if user -%}") + (a + ("href" "/search?profile={{ profile.id }}") + ("class" "button lowered small") + (text "{{ icon \"search\" }}") + (span + (text "{{ text \"general:link.search\" }}"))) + (text "{%- endif %}")) + (div + ("class" "card w-full flex flex-col gap-2") + ("ui_ident" "io_data_load") + (div ("ui_ident" "io_data_marker")))) + +(text "{% set paged = user and user.settings.paged_timelines %}") +(script + (text "setTimeout(async () => { + await trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&responses_only=true&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]); + (await ns(\"ui\")).IO_DATA_DISABLE_RELOAD = true; + console.log(\"created profile timeline\"); + }, 1000);")) + +(text "{% endblock %}") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index f2280e6..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") @@ -446,6 +608,30 @@ ("class" "button lowered small") (icon (text "external-link")) (span (str (text "requests:action.view_profile")))))) + (text "{% endfor %}"))) + + ; ip blocks + (div + ("class" "card-nest") + (div + ("class" "card flex items-center gap-2 small") + (text "{{ icon \"wifi\" }}") + (span + (text "{{ text \"settings:label.ips\" }}"))) + (div + ("class" "card flex flex-col gap-2") + (text "{% for ip in ipblocks %}") + (div + ("class" "card secondary flex flex-wrap gap-2 items-center justify-between") + (span + (text "Block from: ") (span ("class" "date") (text "{{ ip.created }}"))) + (div + ("class" "flex gap-2") + (button + ("onclick" "trigger('me::remove_ip_block', ['{{ ip.id }}'])") + ("class" "lowered small red") + (icon (text "x")) + (span (str (text "auth:action.unblock")))))) (text "{% endfor %}"))))) (div ("class" "w-full flex flex-col gap-2 hidden") @@ -468,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) => { @@ -515,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 -%}") @@ -619,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") @@ -628,92 +876,55 @@ (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 %}") - (p - (text "You're ") - (b - (text "not ")) - (text "currently a supporter! No - pressure, but it helps us do some pretty cool - things! As a supporter, you'll get:")) - (ul - ("style" "margin-bottom: var(--pad-4)") - (li - (text "Vanity badge on profile")) - (li - (text "No more supporter ads (duh)")) - (li - (text "Ability to upload gif avatars/banners")) - (li - (text "Be an admin/owner of up to 10 communities")) - (li - (text "Use custom CSS on your profile")) - (li - (text "Use community emojis outside of - their community")) - (li - (text "Upload and use gif emojis")) - (li - (text "Create infinite stack timelines")) - (li - (text "Upload images to posts")) - (li - (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 - (text "Add unlimited users to stacks")) - (li - (text "Increased proxied image size")) - (li - (text "Create infinite journals")) - (li - (text "Create infinite notes in each journal")) - (li - (text "Publish up to 50 notes")) - - (text "{% if config.security.enable_invite_codes -%}") - (li - (text "Create up to 48 invite codes")) - (text "{%- endif %}")) - (a - ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}") - ("class" "button") - ("target" "_blank") - (text "Become a 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 "{{ 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") + ("onsubmit" "update_invite_code(event)") + (p (text "Your account is currently activated without an invite code. If you stop paying for supporter, your account will be locked again until you renew. You can provide an invite code to avoid this if you're planning on cancelling.")) + + (div + ("class" "flex flex-col gap-1") + (label + ("for" "invite_code") + (b + (text "Invite code"))) + (input + ("type" "text") + ("placeholder" "invite code") + ("name" "invite_code") + ("required" "") + ("id" "invite_code"))) + + (button + (text "Submit"))) + (text "{%- endif %}") (text "{%- endif %}"))))) (div ("class" "w-full hidden flex flex-col gap-2") @@ -743,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") @@ -771,11 +981,48 @@ ("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif") ("class" "w-content")) (button - ("class" "primary") (text "{{ icon \"check\" }}"))) (span ("class" "fade") - (text "Use an image of 1100x350px for the best results."))))) + (text "Use an image of 1100x350px for the best results.")))) + (div + ("class" "card-nest") + ("ui_ident" "default_profile_page") + (div + ("class" "card small") + (b + (text "Default profile tab"))) + (div + ("class" "card") + (select + ("onchange" "window.SETTING_SET_FUNCTIONS[0]('default_profile_tab', event.target.selectedOptions[0].value)") + (option + ("value" "Posts") + ("selected" "{% if profile.settings.default_profile_tab == 'Posts' -%}true{% else %}false{%- endif %}") + (text "Posts")) + (option + ("value" "Responses") + ("selected" "{% if profile.settings.default_profile_tab == 'Responses' -%}true{% else %}false{%- endif %}") + (text "Responses"))) + (span + ("class" "fade") + (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") @@ -851,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 @@ -1174,6 +1420,11 @@ globalThis.delete_account = async (e) => { e.preventDefault(); + // {% if user.permissions|has_supporter %} + alert(\"Please cancel your membership before deleting your account. You'll have to wait until the next cycle to delete your account after, or you can request support if it is urgent.\"); + return; + // {% endif %} + if ( !(await trigger(\"atto::confirm\", [ \"Are you sure you would like to do this?\", @@ -1357,6 +1608,112 @@ }); }; + globalThis.update_invite_code = async (e) => { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"invite_codes::try\"]); + fetch(\"/api/v1/auth/user/me/invite_code\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + invite_code: e.target.invite_code.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.reload(); + } + }); + } + + 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 = @@ -1375,6 +1732,8 @@ \"supporter_ad\", \"change_avatar\", \"change_banner\", + \"default_profile_page\", + \"show_presets\", ]); ui.refresh_container(theme_settings, [ \"supporter_ad\", @@ -1397,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\"], @@ -1490,11 +1858,45 @@ \"{{ 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 }}\", \"checkbox\", ], + [ + [ + \"hide_associated_blocked_users\", + \"Hide users that you've blocked on your other accounts from timelines\", + ], + \"{{ 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\"], [ [ @@ -1553,6 +1955,11 @@ \"{{ profile.settings.disable_gpa_fun }}\", \"checkbox\", ], + [ + [\"disable_achievements\", \"Disable achievements\"], + \"{{ profile.settings.disable_achievements }}\", + \"checkbox\", + ], ], settings, ); @@ -1729,6 +2136,35 @@ description: \"Hover state for secondary buttons.\", }, ], + // online indicator + [[], \"\", \"divider\"], + [ + [\"theme_color_online\", \"Online indicator (online)\"], + \"{{ profile.settings.theme_color_online }}\", + \"color\", + { + description: + \"The green dot next to the name of online users.\", + }, + ], + [ + [\"theme_color_idle\", \"Online indicator (idle)\"], + \"{{ profile.settings.theme_color_idle }}\", + \"color\", + { + description: + \"The yellow dot next to the name of online users.\", + }, + ], + [ + [\"theme_color_offline\", \"Online indicator (offline)\"], + \"{{ profile.settings.theme_color_offline }}\", + \"color\", + { + description: + \"The grey next to the name of online users.\", + }, + ], ]; if (can_use_custom_css) { diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp index a4288b3..5cf7da9 100644 --- a/crates/app/src/public/html/root.lisp +++ b/crates/app/src/public/html/root.lisp @@ -35,10 +35,12 @@ globalThis.no_policy = false; globalThis.BUILD_CODE = \"{{ random_cache_breaker }}\"; + globalThis.TETRATTO_LINK_HANDLER_CTX = \"net\"; ") (script ("src" "/js/loader.js?v=tetratto-{{ random_cache_breaker }}" )) (script ("src" "/js/atto.js?v=tetratto-{{ random_cache_breaker }}" )) + (script ("defer" "true") ("src" "/js/proto_links.js?v=tetratto-{{ random_cache_breaker }}" )) (meta ("name" "theme-color") ("content" "{{ config.color }}")) (meta ("name" "description") ("content" "{{ config.description }}")) @@ -68,11 +70,130 @@ (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 "{% else %} {% block body %}{% endblock %} {%- endif %}") + (text "{% elif user and user.awaiting_purchase %}") + ; account waiting for payment message + (article + (main + (div + ("class" "card-nest") + (div + ("class" "card small flex items-center gap-2 red") + (icon (text "frown")) + (str (text "general:label.must_activate_account"))) + + (div + ("class" "card no_p_margin flex flex-col gap-2") + (p (text "Since you didn't provide an invite code, you'll need to activate your account to use it.")) + (p (text "Supporter is a recurring membership. If you cancel it, your account will be locked again unless you renew your subscription or provide an invite code.")) + (div + ("class" "card w-full lowered flex flex-col gap-2") + (text "{{ components::become_supporter_button() }}")) + (p (text "Alternatively, you can provide an invite code to activate your account.")) + (form + ("class" "card w-full lowered flex flex-col gap-2") + ("onsubmit" "update_invite_code(event)") + (div + ("class" "flex flex-col gap-1") + (label + ("for" "invite_code") + (b + (text "Invite code"))) + (input + ("type" "text") + ("placeholder" "invite code") + ("name" "invite_code") + ("required" "") + ("id" "invite_code"))) + + (button + (text "Submit"))) + + (script + (text "async function update_invite_code(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"invite_codes::try\"]); + fetch(\"/api/v1/auth/user/me/invite_code\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + invite_code: e.target.invite_code.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.reload(); + } + }); + }")))))) + (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 %}") + (text "{%- endif %}") (text "")) (text "{% include \"body.html\" %}"))) 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/all.lisp b/crates/app/src/public/html/timelines/all.lisp index 7cced78..d739b2e 100644 --- a/crates/app/src/public/html/timelines/all.lisp +++ b/crates/app/src/public/html/timelines/all.lisp @@ -36,7 +36,7 @@ (text "{% set paged = user and user.settings.paged_timelines %}") (script (text "setTimeout(() => { - trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]); + trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&before=$1$&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]); });")) (text "{% endblock %}") 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/html/timelines/swiss_army.lisp b/crates/app/src/public/html/timelines/swiss_army.lisp index 23243ce..02edda2 100644 --- a/crates/app/src/public/html/timelines/swiss_army.lisp +++ b/crates/app/src/public/html/timelines/swiss_army.lisp @@ -1,17 +1,15 @@ (text "{%- import \"components.html\" as components -%} {%- import \"macros.html\" as macros -%}") (text "{% for post in list %} - {% if post[2].read_access == \"Everybody\" -%} - {% if post[0].context.repost and post[0].context.repost.reposting -%} - {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }} - {% else %} - {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} - {%- endif %} + {% if post[0].context.repost and post[0].context.repost.reposting -%} + {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }} + {% else %} + {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} {%- endif %} {% endfor %}") (datalist ("ui_ident" "list_posts_{{ page }}") (text "{% for post in list -%}") - (option ("value" "{{ post[0].id }}")) + (option ("value" "{{ post[0].id }}") ("data-created" "{{ post[0].created }}")) (text "{%- endfor %}")) (text "{% if list|length == 0 -%}") (div 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 350b7a3..9c556cf 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -156,14 +156,12 @@ 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"; } }); - self.define("clean_poll_date_codes", ({ $ }) => { + self.define("clean_poll_date_codes", async ({ $ }) => { for (const element of Array.from( document.querySelectorAll(".poll_date"), )) { @@ -183,7 +181,7 @@ media_theme_pref(); element.setAttribute("title", then.toLocaleString()); const pretty = - $.rel_date(then) + (await $.rel_date(then)) .replaceAll(" minutes ago", "m") .replaceAll(" minute ago", "m") .replaceAll(" hours ago", "h") @@ -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"; } }); @@ -409,39 +405,45 @@ media_theme_pref(); } }); - self.define("hooks::long", (_, element, full_text) => { + self.define("hooks::long", ({ $ }, element, full_text) => { element.classList.remove("hook:long.hidden_text"); element.innerHTML = full_text; + + $.clean_date_codes(); + $.clean_poll_date_codes(); + $.link_filter(); }); 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", (_) => { @@ -505,7 +507,7 @@ media_theme_pref(); return now - last_seen <= maximum_time_to_be_considered_idle; }); - self.define("hooks::online_indicator", ({ $ }) => { + self.define("hooks::online_indicator", async ({ $ }) => { for (const element of Array.from( document.querySelectorAll("[hook=online_indicator]") || [], )) { @@ -513,8 +515,8 @@ media_theme_pref(); element.getAttribute("hook-arg:last_seen"), ); - const is_online = $.last_seen_just_now(last_seen); - const is_idle = $.last_seen_recently(last_seen); + const is_online = await $.last_seen_just_now(last_seen); + const is_idle = await $.last_seen_recently(last_seen); const offline = element.querySelector("[hook_ui_ident=offline]"); const online = element.querySelector("[hook_ui_ident=online]"); @@ -687,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( @@ -851,7 +853,8 @@ media_theme_pref(); anchor.href.startsWith("https://tetratto.com") || anchor.href.startsWith("https://buy.stripe.com") || anchor.href.startsWith("https://billing.stripe.com") || - anchor.href.startsWith("https://last.fm") + anchor.href.startsWith("https://last.fm") || + anchor.href.startsWith("atto://") ) { continue; } @@ -917,18 +920,18 @@ media_theme_pref(); if (option.input_element_type === "checkbox") { into_element.innerHTML += `
    - + - -
    `; + + `; return; } @@ -1064,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 = []; @@ -1094,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); @@ -1102,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); @@ -1115,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]} - +
    `; } @@ -1134,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"); }); @@ -1208,6 +1224,7 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} self.IO_HAS_LOADED_AT_LEAST_ONCE = false; self.IO_DATA_DISCONNECTED = false; self.IO_DATA_DISABLE_RELOAD = false; + self.IO_DATA_LOAD_BEFORE = 0; if (!paginated_mode) { self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER); @@ -1252,7 +1269,9 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} // ... const text = await ( - await fetch(`${self.IO_DATA_TMPL}${self.IO_DATA_PAGE}`) + await fetch( + `${self.IO_DATA_TMPL.replace("&before=$1$", `&before=${self.IO_DATA_LOAD_BEFORE}`)}${self.IO_DATA_PAGE}`, + ) ).text(); self.IO_DATA_WAITING = false; @@ -1270,11 +1289,22 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} } if ( - text.includes(`!`) + text.includes( + `!`, + ) || + document.documentElement.innerHTML.includes("observer_disconnect") ) { console.log("io_data_end; disconnect"); self.IO_DATA_OBSERVER.disconnect(); - self.IO_DATA_ELEMENT.innerHTML += text; + + if ( + !document.documentElement.innerHTML.includes( + "observer_disconnect", + ) + ) { + self.IO_DATA_ELEMENT.innerHTML += text; + } + self.IO_DATA_DISCONNECTED = true; return; } @@ -1287,30 +1317,6 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} self.IO_DATA_ELEMENT.children.length - 1 ].after(self.IO_DATA_MARKER); - // remove posts we've already seen - function remove_elements(id, outer = false) { - let idx = 0; - for (const element of Array.from( - document.querySelectorAll( - `.post${outer ? "_outer" : ""}\\:${id}`, - ), - )) { - if (idx === 0) { - idx += 1; - continue; - } - - // everything that isn't the first element should be removed - element.remove(); - console.log("removed duplicate post"); - } - } - - for (const id of self.IO_DATA_SEEN_IDS) { - remove_elements(id, false); - remove_elements(id, true); // scoop up questions - } - // push ids for (const opt of Array.from( document.querySelectorAll( @@ -1322,6 +1328,8 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} if (!self.IO_DATA_SEEN_IDS[v]) { self.IO_DATA_SEEN_IDS.push(v); } + + self.IO_DATA_LOAD_BEFORE = opt.getAttribute("data-created"); } }, 150); @@ -1337,6 +1345,8 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} atto["hooks::online_indicator"](); atto["hooks::verify_emoji"](); atto["hooks::check_reactions"](); + + fix_atto_links(); }); })(); @@ -1378,7 +1388,8 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} JSON.stringify(accepted_warnings), ); - setTimeout(() => { + setTimeout(async () => { + await trigger("me::achievement", ["AcceptProfileWarning"]); window.history.back(); }, 100); }); diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index 31290c9..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"); @@ -342,6 +346,36 @@ }, ); + self.define("achievement", (_, name) => { + return new Promise((resolve) => { + fetch("/api/v1/auth/user/me/achievement", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name, + }), + }) + .then((res) => res.json()) + .then((res) => { + if (!res.ok) { + trigger("atto::toast", [ + res.ok ? "success" : "error", + res.message, + ]); + } + + resolve(); + }); + }); + }); + + self.define("achievement_link", async (_, name, href) => { + await self.achievement(name); + Turbo.visit(href); + }); + self.define("report", (_, asset, asset_type) => { window.open( `/mod_panel/file_report?asset=${asset}&asset_type=${asset_type}`, @@ -402,8 +436,30 @@ }); }); + self.define("remove_ip_block", async (_, id) => { + if ( + !(await trigger("atto::confirm", [ + "Are you sure you want to do this?", + ])) + ) { + return; + } + + fetch(`/api/v1/auth/ip/${id}/unblock_ip`, { + method: "POST", + }) + .then((res) => res.json()) + .then((res) => { + trigger("atto::toast", [ + res.ok ? "success" : "error", + res.message, + ]); + }); + }); + self.define("notifications_stream", ({ _, streams }) => { const element = document.getElementById("notifications_span"); + let current = Number.parseInt(element.innerText || "0"); streams.subscribe("notifs"); streams.event("notifs", "message", (data) => { @@ -414,13 +470,12 @@ const inner_data = JSON.parse(data.data); if (data.method.Packet.Crud === "Create") { - const current = Number.parseInt(element.innerText || "0"); - if (current <= 0) { element.classList.remove("hidden"); } - element.innerText = current + 1; + current += 1; + element.innerText = current; // check if we're already connected const connected = @@ -456,16 +511,19 @@ console.info("notification created"); } } else if (data.method.Packet.Crud === "Delete") { - const current = Number.parseInt(element.innerText || "0"); - if (current - 1 <= 0) { element.classList.add("hidden"); } - element.innerText = current - 1; + current -= 1; + element.innerText = current; } else { console.warn("correct packet type but with wrong data"); } + + if (element.innerText !== current) { + element.innerText = current; + } }); }); @@ -979,7 +1037,13 @@ self.define( "timestamp", - ({ $ }, updated_, progress_ms_, duration_ms_, display = "full") => { + async ( + { $ }, + updated_, + progress_ms_, + duration_ms_, + display = "full", + ) => { if (duration_ms_ === "0") { return; } @@ -1003,7 +1067,7 @@ } if (display === "full") { - return `${$.ms_time_text(progress_ms)}/${$.ms_time_text(duration_ms)} (${Math.floor((progress_ms / duration_ms) * 100)}%)`; + return `${await $.ms_time_text(progress_ms)}/${await $.ms_time_text(duration_ms)} (${Math.floor((progress_ms / duration_ms) * 100)}%)`; } if (display === "left") { @@ -1141,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 new file mode 100644 index 0000000..9c8d9fd --- /dev/null +++ b/crates/app/src/public/js/proto_links.js @@ -0,0 +1,140 @@ +if (!globalThis.TETRATTO_LINK_HANDLER_CTX) { + globalThis.TETRATTO_LINK_HANDLER_CTX = "embed"; +} + +// create little link preview box +function create_link_preview() { + globalThis.TETRATTO_LINK_PREVIEW = document.createElement("div"); + globalThis.TETRATTO_LINK_PREVIEW.style.position = "fixed"; + globalThis.TETRATTO_LINK_PREVIEW.style.bottom = "0.5rem"; + globalThis.TETRATTO_LINK_PREVIEW.style.left = "0.5rem"; + globalThis.TETRATTO_LINK_PREVIEW.style.background = "#323232"; + globalThis.TETRATTO_LINK_PREVIEW.style.color = "#ffffff"; + globalThis.TETRATTO_LINK_PREVIEW.style.borderRadius = "4px"; + globalThis.TETRATTO_LINK_PREVIEW.style.padding = "0.25rem 0.5rem"; + globalThis.TETRATTO_LINK_PREVIEW.style.display = "none"; + globalThis.TETRATTO_LINK_PREVIEW.id = "tetratto_link_preview"; + globalThis.TETRATTO_LINK_PREVIEW.setAttribute( + "data-turbo-permanent", + "true", + ); + document.body.appendChild(globalThis.TETRATTO_LINK_PREVIEW); +} + +/// Clean up all "atto://" links on the page. +function fix_atto_links() { + setTimeout(() => { + if (!document.getElementById("tetratto_link_preview")) { + create_link_preview(); + } + }, 500); + + if (TETRATTO_LINK_HANDLER_CTX === "embed") { + // relative links for embeds + const path = window.location.pathname + .replace("atto://", "") + .slice("/api/v1/net/".length); + + function fix_element( + selector = "a", + property = "href", + relative = true, + ) { + for (const y of Array.from(document.querySelectorAll(selector))) { + if (!y[property].startsWith(window.location.origin)) { + continue; + } + + 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://${x}`; + } else { + y[property] = + `/api/v1/net/${path.replace("atto://", "").split("/")[0]}${x}?s=${globalThis.SECRET_SESSION}`; + } + } + } + + fix_element("a", "href", true); + fix_element("img", "src", false); + + // send message + window.top.postMessage( + JSON.stringify({ + t: true, + event: "change_url", + target: window.location.href, + }), + "*", + ); + + // handle messages + window.addEventListener("message", (e) => { + if (typeof e.data !== "string") { + console.log("refuse message (bad type)"); + return; + } + + const data = JSON.parse(e.data); + + if (!data.t) { + console.log("refuse message (not for tetratto)"); + return; + } + + console.log("received message"); + + if (data.event === "back") { + window.history.back(); + } else if (data.event === "forward") { + window.history.forward(); + } else if (data.event === "reload") { + window.location.reload(); + } + }); + } + + for (const anchor of Array.from(document.querySelectorAll("a"))) { + if ( + !anchor.href.startsWith("atto://") || + anchor.getAttribute("data-checked") === "true" + ) { + continue; + } + + 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}?s=${globalThis.SECRET_SESSION}`; + } + }); + + anchor.addEventListener("mouseenter", () => { + TETRATTO_LINK_PREVIEW.innerText = href; + TETRATTO_LINK_PREVIEW.style.display = "block"; + }); + + anchor.addEventListener("mouseleave", () => { + TETRATTO_LINK_PREVIEW.style.display = "none"; + }); + + anchor.removeAttribute("href"); + anchor.style.cursor = "pointer"; + anchor.setAttribute("data-checked", "true"); + } +} + +fix_atto_links(); +create_link_preview(); diff --git a/crates/app/src/public/js/streams.js b/crates/app/src/public/js/streams.js index 8b9954d..7c5adf7 100644 --- a/crates/app/src/public/js/streams.js +++ b/crates/app/src/public/js/streams.js @@ -43,6 +43,12 @@ }; socket.addEventListener("message", async (event) => { + const sock = await $.sock(stream); + + if (!sock) { + return; + } + if (event.data === "Ping") { return socket.send("Pong"); } @@ -54,7 +60,7 @@ return console.info(`${stream} ${data.data}`); } - return (await $.sock(stream)).events.message(data); + return sock.events.message(data); }); return $.STREAMS[stream]; 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 6ef6fcd..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,36 +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 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 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.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 => { @@ -158,22 +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 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) + .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(), @@ -186,6 +335,133 @@ pub async fn stripe_webhook( return Json(e.into()); } } + EventType::InvoicePaymentFailed => { + // payment failed + let invoice = match req.data.object { + EventObject::Invoice(i) => i, + _ => unreachable!("cannot be this"), + }; + + 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()), + }; + + // 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: (), + }); + } + + tracing::info!( + "unsubscribe (pay fail) {} (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) + .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(), + "No worries! Your subscription is still active and will be retried. Your supporter status will resume when you have a successful payment." + .to_string(), + user.id, + )) + .await + { + return Json(e.into()); + } + } _ => return Json(Error::Unknown.into()), } @@ -195,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 a332dd8..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; @@ -54,7 +54,7 @@ pub async fn register_request( // check for ip ban if data - .get_ipban_by_addr(RemoteAddr::from(real_ip.as_str())) + .get_ipban_by_addr(&RemoteAddr::from(real_ip.as_str())) .await .is_ok() { @@ -88,41 +88,46 @@ pub async fn register_request( // check invite code if data.0.0.security.enable_invite_codes { - if props.invite_code.is_empty() { - return ( - None, - Json(Error::MiscError("Missing invite code".to_string()).into()), - ); + if !props.purchase { + if props.invite_code.is_empty() { + return ( + None, + Json(Error::MiscError("Missing invite code".to_string()).into()), + ); + } + + let invite_code = match data.get_invite_code_by_code(&props.invite_code).await { + Ok(c) => c, + Err(e) => return (None, Json(e.into())), + }; + + if invite_code.is_used { + return ( + None, + Json(Error::MiscError("This code has already been used".to_string()).into()), + ); + } + + // let owner = match data.get_user_by_id(invite_code.owner).await { + // Ok(u) => u, + // Err(e) => return (None, Json(e.into())), + // }; + + // if !owner.permissions.check(FinePermission::SUPPORTER) { + // return ( + // None, + // Json( + // Error::MiscError("Invite code owner must be an active supporter".to_string()) + // .into(), + // ), + // ); + // } + + user.invite_code = invite_code.id; + } else { + // this account is being purchased + user.awaiting_purchase = true; } - - let invite_code = match data.get_invite_code_by_code(&props.invite_code).await { - Ok(c) => c, - Err(e) => return (None, Json(e.into())), - }; - - if invite_code.is_used { - return ( - None, - Json(Error::MiscError("This code has already been used".to_string()).into()), - ); - } - - // let owner = match data.get_user_by_id(invite_code.owner).await { - // Ok(u) => u, - // Err(e) => return (None, Json(e.into())), - // }; - - // if !owner.permissions.check(FinePermission::SUPPORTER) { - // return ( - // None, - // Json( - // Error::MiscError("Invite code owner must be an active supporter".to_string()) - // .into(), - // ), - // ); - // } - - user.invite_code = invite_code.id; } // push initial token @@ -133,7 +138,7 @@ pub async fn register_request( match data.create_user(user).await { Ok(_) => { // mark invite as used - if data.0.0.security.enable_invite_codes { + if data.0.0.security.enable_invite_codes && !props.purchase { let invite_code = match data.get_invite_code_by_code(&props.invite_code).await { Ok(c) => c, Err(e) => return (None, Json(e.into())), @@ -189,7 +194,7 @@ pub async fn login_request( // check for ip ban if data - .get_ipban_by_addr(RemoteAddr::from(real_ip.as_str())) + .get_ipban_by_addr(&RemoteAddr::from(real_ip.as_str())) .await .is_ok() { diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index 0d2bc49..aec31ef 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -1,10 +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, DeleteUser, DisableTotp, RefreshGrantToken, UpdateSecondaryUserRole, - UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, UpdateUserUsername, + AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken, + UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserBanReason, + UpdateUserInviteCode, UpdateUserIsDeactivated, UpdateUserIsVerified, UpdateUserPassword, + UpdateUserRole, UpdateUserUsername, }, State, }; @@ -16,12 +18,12 @@ 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, model::{ - auth::{AchievementName, InviteCode, Token, UserSettings}, + auth::{AchievementName, InviteCode, Token, UserSettings, SELF_SERVE_ACHIEVEMENTS}, moderation::AuditLogEntry, oauth, permissions::FinePermission, @@ -153,7 +155,7 @@ pub async fn update_user_settings_request( // award achievement if let Err(e) = data - .add_achievement(&mut user, AchievementName::EditSettings.into()) + .add_achievement(&mut user, AchievementName::EditSettings.into(), true) .await { return Json(e.into()); @@ -342,6 +344,62 @@ pub async fn update_user_is_verified_request( } } +/// Update the verification status of the given user. +/// +/// Does not support third-party grants. +pub async fn update_user_awaiting_purchase_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_awaiting_purchased_status(id, req.awaiting_purchase, user, true) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Awaiting purchase status updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +/// 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. @@ -395,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; @@ -422,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()), }; @@ -432,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}`"), @@ -443,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()), } } @@ -464,11 +570,20 @@ pub async fn enable_totp_request( Extension(data): Extension, ) -> impl IntoResponse { let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data) { + let mut user = match get_user_from_token!(jar, data) { Some(ua) => ua, None => return Json(Error::NotAllowed.into()), }; + // award achievement + if let Err(e) = data + .add_achievement(&mut user, AchievementName::Enable2fa.into(), true) + .await + { + return Json(e.into()); + } + + // ... match data.enable_totp(id, user).await { Ok(x) => Json(ApiReturn { ok: true, @@ -911,3 +1026,83 @@ pub async fn generate_invite_codes_request( payload: Some((out_string, errors_string)), }) } + +/// Award an achievement to the current user. +/// Only works with specific "self-serve" achievements. +pub async fn self_serve_achievement_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if !SELF_SERVE_ACHIEVEMENTS.contains(&req.name) { + return Json(Error::MiscError("Cannot grant this achievement manually".to_string()).into()); + } + + // award achievement + match data.add_achievement(&mut user, req.name.into(), true).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Achievement granted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +/// Update the verification status of the given user. +/// +/// Does not support third-party grants. +pub async fn update_user_invite_code_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) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if req.invite_code.is_empty() { + return Json(Error::MiscError("Missing invite code".to_string()).into()); + } + + let invite_code = match data.get_invite_code_by_code(&req.invite_code).await { + Ok(c) => c, + Err(e) => return Json(e.into()), + }; + + if invite_code.is_used { + return Json(Error::MiscError("This code has already been used".to_string()).into()); + } + + if let Err(e) = data.update_invite_code_is_used(invite_code.id, true).await { + return Json(e.into()); + } + + match data + .update_user_invite_code(user.id, invite_code.id as i64) + .await + { + Ok(_) => { + match data + .update_user_awaiting_purchased_status(user.id, false, user, false) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Invite code updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } + } + 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 53aff80..84e20c8 100644 --- a/crates/app/src/routes/api/v1/auth/social.rs +++ b/crates/app/src/routes/api/v1/auth/social.rs @@ -9,14 +9,15 @@ 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}, oauth, }; /// 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, @@ -61,7 +62,7 @@ pub async fn follow_request( // award achievement if let Err(e) = data - .add_achievement(&mut user, AchievementName::FollowUser.into()) + .add_achievement(&mut user, AchievementName::FollowUser.into(), true) .await { return Json(e.into()); @@ -153,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, @@ -228,7 +319,10 @@ pub async fn ip_block_request( None => return Json(Error::NotAllowed.into()), }; - if let Ok(ipblock) = data.get_ipblock_by_initiator_receiver(user.id, &ip).await { + if let Ok(ipblock) = data + .get_ipblock_by_initiator_receiver(user.id, &RemoteAddr::from(ip.as_str())) + .await + { // delete match data.delete_ipblock(ipblock.id, user).await { Ok(_) => Json(ApiReturn { @@ -274,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()), }, @@ -306,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()), }, @@ -314,3 +414,64 @@ pub async fn following_request( Err(e) => Json(e.into()), } } + +pub async fn ip_block_profile_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::UserCreateIpBlock) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + // get other user + let other_user = match data.get_user_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + for (ip, _, _) in other_user.tokens { + // check for an existing ip block + if data + .get_ipblock_by_initiator_receiver(user.id, &RemoteAddr::from(ip.as_str())) + .await + .is_ok() + { + continue; + } + + // create ip block + if let Err(e) = data.create_ipblock(IpBlock::new(user.id, ip)).await { + return Json(e.into()); + } + } + + Json(ApiReturn { + ok: true, + message: "IP(s) blocked".to_string(), + payload: (), + }) +} + +pub async fn remove_ip_block_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::UserManageBlocks) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_ipblock(id, user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "IP unblocked".to_string(), + payload: (), + }), + Err(e) => 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 e2c72f1..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}, @@ -292,11 +292,10 @@ pub async fn create_membership( }; match data - .create_membership(CommunityMembership::new( - user.id, - id, - CommunityPermission::default(), - )) + .create_membership( + CommunityMembership::new(user.id, id, CommunityPermission::default()), + &user, + ) .await { Ok(m) => Json(ApiReturn { diff --git a/crates/app/src/routes/api/v1/communities/drafts.rs b/crates/app/src/routes/api/v1/communities/drafts.rs index a6de4c9..559e4b3 100644 --- a/crates/app/src/routes/api/v1/communities/drafts.rs +++ b/crates/app/src/routes/api/v1/communities/drafts.rs @@ -3,8 +3,8 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; -use tetratto_core::model::{communities::PostDraft, oauth, ApiReturn, Error}; +use crate::cookie::CookieJar; +use tetratto_core::model::{auth::AchievementName, communities::PostDraft, oauth, ApiReturn, Error}; use crate::{ get_user_from_token, routes::{ @@ -20,11 +20,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::UserCreateDrafts) { + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateDrafts) { Some(ua) => ua, None => return Json(Error::NotAllowed.into()), }; + // award achievement + if let Err(e) = data + .add_achievement(&mut user, AchievementName::CreateDraft.into(), true) + .await + { + return Json(e.into()); + } + + // ... match data .create_draft(PostDraft::new(req.content, user.id)) .await 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 fee1ba8..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, @@ -67,7 +67,7 @@ pub async fn create_request( // check for ip ban if data - .get_ipban_by_addr(RemoteAddr::from(real_ip.as_str())) + .get_ipban_by_addr(&RemoteAddr::from(real_ip.as_str())) .await .is_ok() { @@ -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 => { @@ -181,7 +182,7 @@ pub async fn create_request( // achievements if let Err(e) = data - .add_achievement(&mut user, AchievementName::CreatePost.into()) + .add_achievement(&mut user, AchievementName::CreatePost.into(), true) .await { return Json(e.into()); @@ -189,7 +190,7 @@ pub async fn create_request( if user.post_count >= 49 { if let Err(e) = data - .add_achievement(&mut user, AchievementName::Create50Posts.into()) + .add_achievement(&mut user, AchievementName::Create50Posts.into(), true) .await { return Json(e.into()); @@ -198,7 +199,7 @@ pub async fn create_request( if user.post_count >= 99 { if let Err(e) = data - .add_achievement(&mut user, AchievementName::Create100Posts.into()) + .add_achievement(&mut user, AchievementName::Create100Posts.into(), true) .await { return Json(e.into()); @@ -207,7 +208,7 @@ pub async fn create_request( if user.post_count >= 999 { if let Err(e) = data - .add_achievement(&mut user, AchievementName::Create1000Posts.into()) + .add_achievement(&mut user, AchievementName::Create1000Posts.into(), true) .await { return Json(e.into()); @@ -341,11 +342,20 @@ pub async fn update_content_request( Json(req): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserEditPosts) { + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserEditPosts) { Some(ua) => ua, None => return Json(Error::NotAllowed.into()), }; + // award achievement + if let Err(e) = data + .add_achievement(&mut user, AchievementName::EditPost.into(), true) + .await + { + return Json(e.into()); + } + + // ... match data.update_post_content(id, user, req.content).await { Ok(_) => Json(ApiReturn { ok: true, @@ -714,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) => { @@ -829,7 +839,7 @@ pub async fn all_request( }; match data - .get_latest_posts(12, props.page, &Some(user.clone())) + .get_latest_posts(12, props.page, &Some(user.clone()), props.before) .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 ec24c08..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}, @@ -43,7 +43,7 @@ pub async fn create_request( // check for ip ban if data - .get_ipban_by_addr(RemoteAddr::from(real_ip.as_str())) + .get_ipban_by_addr(&RemoteAddr::from(real_ip.as_str())) .await .is_ok() { @@ -55,7 +55,7 @@ pub async fn create_request( let mut user = user.clone(); if let Err(e) = data - .add_achievement(&mut user, AchievementName::CreateQuestion.into()) + .add_achievement(&mut user, AchievementName::CreateQuestion.into(), true) .await { return Json(e.into()); @@ -63,7 +63,7 @@ pub async fn create_request( if drawings.len() > 0 { if let Err(e) = data - .add_achievement(&mut user, AchievementName::CreateDrawing.into()) + .add_achievement(&mut user, AchievementName::CreateDrawing.into(), true) .await { return Json(e.into()); @@ -92,6 +92,17 @@ pub async fn create_request( } } + if req.mask_owner && !req.is_global { + 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 @@ -145,7 +156,7 @@ pub async fn ip_block_request( // check for an existing ip block if data - .get_ipblock_by_initiator_receiver(user.id, &question.ip) + .get_ipblock_by_initiator_receiver(user.id, &RemoteAddr::from(question.ip.as_str())) .await .is_ok() { diff --git a/crates/app/src/routes/api/v1/domains.rs b/crates/app/src/routes/api/v1/domains.rs new file mode 100644 index 0000000..1e57049 --- /dev/null +++ b/crates/app/src/routes/api/v1/domains.rs @@ -0,0 +1,221 @@ +use crate::{ + get_user_from_token, + routes::api::v1::{CreateDomain, UpdateDomainData}, + State, +}; +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, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + match data.get_domain_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) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadDomains) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.get_domains_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 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 + { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Domain created".to_string(), + payload: x.id.to_string(), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_data_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::UserManageDomains) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_domain_data(id, &user, req.data).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Domain 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::UserManageDomains) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_domain(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Domain deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +#[derive(Deserialize)] +pub struct GetFileQuery { + #[serde(default, alias = "s")] + pub session: String, +} + +pub async fn get_file_request( + 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, + Err(e) => { + return Err((StatusCode::BAD_REQUEST, e.to_string())); + } + }; + + // resolve service + let service = match domain.service(&subdomain) { + Some(id) => match data.get_service_by_id(id).await { + Ok(x) => x, + Err(e) => { + return Err((StatusCode::BAD_REQUEST, e.to_string())); + } + }, + None => { + return Err(( + StatusCode::NOT_FOUND, + Error::GeneralNotFound("service".to_string()).to_string(), + )); + } + }; + + // resolve file + match service.file(&path) { + Some((f, _)) => Ok(( + [("Content-Type".to_string(), f.mime.to_string())], + if f.mime == ServiceFsMime::Html { + 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(( + StatusCode::NOT_FOUND, + Error::GeneralNotFound("file".to_string()).to_string(), + )); + } + } +} diff --git a/crates/app/src/routes/api/v1/journals.rs b/crates/app/src/routes/api/v1/journals.rs index 95ae3da..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, @@ -110,7 +110,7 @@ pub async fn create_request( Ok(x) => { // award achievement if let Err(e) = data - .add_achievement(&mut user, AchievementName::CreateJournal.into()) + .add_achievement(&mut user, AchievementName::CreateJournal.into(), true) .await { return Json(e.into()); diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 9f850af..8a5d95a 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -1,13 +1,17 @@ +pub mod app_data; pub mod apps; pub mod auth; pub mod channels; pub mod communities; +pub mod domains; pub mod journals; pub mod notes; pub mod notifications; +pub mod products; pub mod reactions; pub mod reports; pub mod requests; +pub mod services; pub mod stacks; pub mod uploads; pub mod util; @@ -18,15 +22,18 @@ use axum::{ }; use serde::Deserialize; use tetratto_core::model::{ - apps::AppQuota, + apps::{AppDataSelectMode, AppDataSelectQuery, AppQuota, DeveloperPassStorageQuota}, + auth::AchievementName, communities::{ CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess, PollOption, PostContext, }, communities_permissions::CommunityPermission, journals::JournalPrivacyPermission, + littleweb::{DomainData, DomainTld, ServiceFsEntry}, oauth::AppScope, permissions::{FinePermission, SecondaryPermission}, + products::{ProductPrice, ProductType}, reactions::AssetType, stacks::{StackMode, StackPrivacy, StackSort}, }; @@ -279,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), @@ -287,7 +298,19 @@ 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", + post(auth::social::ip_block_profile_request), + ) + .route( + "/auth/ip/{id}/unblock_ip", + post(auth::social::remove_ip_block_request), + ) .route( "/auth/user/{id}/settings", post(auth::profile::update_user_settings_request), @@ -300,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), @@ -320,6 +347,14 @@ pub fn routes() -> Router { "/auth/user/{id}/verified", post(auth::profile::update_user_is_verified_request), ) + .route( + "/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), @@ -379,8 +414,17 @@ pub fn routes() -> Router { "/auth/user/{id}/grants/{app}/refresh", post(auth::profile::refresh_grant_request), ) + .route( + "/auth/user/me/achievement", + post(auth::profile::self_serve_achievement_request), + ) + .route( + "/auth/user/me/invite_code", + post(auth::profile::update_user_invite_code_request), + ) // 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)) @@ -388,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)) @@ -439,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 @@ -488,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( @@ -511,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}", @@ -599,6 +676,40 @@ pub fn routes() -> Router { // uploads .route("/uploads/{id}", get(uploads::get_request)) .route("/uploads/{id}", delete(uploads::delete_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)) + .route("/services/{id}", get(services::get_request)) + .route("/services/{id}", delete(services::delete_request)) + .route("/services/{id}/name", post(services::update_name_request)) + .route("/services/{id}/files", post(services::update_files_request)) + .route( + "/services/{id}/content", + post(services::update_content_request), + ) + // domains + .route("/domains", get(domains::list_request)) + .route("/domains", post(domains::create_request)) + .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 { + Router::new().route("/net/{*addr}", get(domains::get_file_request)) } #[derive(Deserialize)] @@ -617,6 +728,12 @@ pub struct RegisterProps { pub captcha_response: String, #[serde(default)] pub invite_code: String, + /// If this is true, invite_code should be empty. + /// + /// If invite codes are enabled, but purchase is false, the invite_code MUST + /// be checked and MUST be valid. + #[serde(default)] + pub purchase: bool, } #[derive(Deserialize)] @@ -724,6 +841,16 @@ pub struct UpdateUserIsVerified { pub is_verified: bool, } +#[derive(Deserialize)] +pub struct UpdateUserAwaitingPurchase { + pub awaiting_purchase: bool, +} + +#[derive(Deserialize)] +pub struct UpdateUserIsDeactivated { + pub is_deactivated: bool, +} + #[derive(Deserialize)] pub struct UpdateNotificationRead { pub read: bool, @@ -749,6 +876,16 @@ pub struct UpdateSecondaryUserRole { pub role: SecondaryPermission, } +#[derive(Deserialize)] +pub struct UpdateUserBanReason { + pub reason: String, +} + +#[derive(Deserialize)] +pub struct UpdateUserInviteCode { + pub invite_code: String, +} + #[derive(Deserialize)] pub struct DeleteUser { pub password: String, @@ -777,6 +914,10 @@ pub struct CreateQuestion { pub receiver: String, #[serde(default)] pub community: String, + #[serde(default)] + pub mask_owner: bool, + #[serde(default)] + pub asking_about: String, } #[derive(Deserialize)] @@ -871,6 +1012,7 @@ pub struct UpdatePostIsOpen { pub struct CreateApp { pub title: String, pub homepage: String, + #[serde(default)] pub redirect: String, } @@ -894,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, @@ -968,7 +1115,96 @@ pub struct AddJournalDir { pub struct RemoveJournalDir { pub dir: String, } + #[derive(Deserialize)] pub struct UpdateNoteTags { pub tags: Vec, } + +#[derive(Deserialize)] +pub struct AwardAchievement { + pub name: AchievementName, +} + +#[derive(Deserialize)] +pub struct CreateService { + pub name: String, +} + +#[derive(Deserialize)] +pub struct UpdateServiceName { + pub name: String, +} + +#[derive(Deserialize)] +pub struct UpdateServiceFiles { + pub files: Vec, + pub id_path: Vec, +} + +#[derive(Deserialize)] +pub struct UpdateServiceFileContent { + pub content: String, + pub id_path: Vec, +} + +#[derive(Deserialize)] +pub struct CreateDomain { + pub name: String, + pub tld: DomainTld, +} + +#[derive(Deserialize)] +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 b6bc986..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, @@ -16,6 +16,7 @@ use crate::{ use tetratto_core::{ database::NAME_REGEX, model::{ + auth::AchievementName, journals::{JournalPrivacyPermission, Note}, oauth, permissions::FinePermission, @@ -190,11 +191,20 @@ pub async fn update_content_request( Json(props): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { Some(ua) => ua, None => return Json(Error::NotAllowed.into()), }; + // award achievement + if let Err(e) = data + .add_achievement(&mut user, AchievementName::EditNote.into(), true) + .await + { + return Json(e.into()); + } + + // ... match data.update_note_content(id, &user, &props.content).await { Ok(_) => { if let Err(e) = data @@ -257,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 b3efe52..b8589e4 100644 --- a/crates/app/src/routes/api/v1/reactions.rs +++ b/crates/app/src/routes/api/v1/reactions.rs @@ -1,7 +1,12 @@ use crate::{State, get_user_from_token, routes::api::v1::CreateReaction}; -use axum::{Extension, Json, extract::Path, response::IntoResponse}; -use axum_extra::extract::CookieJar; -use tetratto_core::model::{oauth, ApiReturn, Error, reactions::Reaction}; +use axum::{ + extract::Path, + http::{HeaderMap, HeaderValue}, + response::IntoResponse, + Extension, Json, +}; +use crate::cookie::CookieJar; +use tetratto_core::model::{addr::RemoteAddr, oauth, reactions::Reaction, ApiReturn, Error}; pub async fn get_request( jar: CookieJar, @@ -26,6 +31,7 @@ pub async fn get_request( pub async fn create_request( jar: CookieJar, + headers: HeaderMap, Extension(data): Extension, Json(req): Json, ) -> impl IntoResponse { @@ -40,6 +46,20 @@ pub async fn create_request( Err(e) => return Json(Error::MiscError(e.to_string()).into()), }; + // get real ip + let real_ip = headers + .get(data.0.0.security.real_ip_header.to_owned()) + .unwrap_or(&HeaderValue::from_static("")) + .to_str() + .unwrap_or("") + .to_string(); + + // check for ip ban + let addr = RemoteAddr::from(real_ip.as_str()); + if data.get_ipban_by_addr(&addr).await.is_ok() { + return Json(Error::NotAllowed.into()); + } + // check for existing reaction if let Ok(r) = data.get_reaction_by_owner_asset(user.id, asset_id).await { match data.delete_reaction(r.id, &user).await { @@ -63,6 +83,7 @@ pub async fn create_request( .create_reaction( Reaction::new(user.id, asset_id, req.asset_type, req.is_like), &user, + &addr, ) .await { 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 new file mode 100644 index 0000000..556924a --- /dev/null +++ b/crates/app/src/routes/api/v1/services.rs @@ -0,0 +1,194 @@ +use crate::{ + get_user_from_token, + routes::api::v1::{ + CreateService, UpdateServiceFileContent, UpdateServiceFiles, UpdateServiceName, + }, + State, +}; +use axum::{extract::Path, response::IntoResponse, Extension, Json}; +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, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + match data.get_service_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) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadServices) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.get_services_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 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, + message: "Service 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::UserManageServices) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_service_name(id, &user, &req.name).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Service updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_files_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::UserManageServices) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let mut service = match data.get_service_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if req.id_path.is_empty() { + service.files = req.files; + } else { + match service.file_mut(req.id_path) { + Some(f) => f.children = req.files, + None => return Json(Error::GeneralNotFound("file".to_string()).into()), + } + } + + match data.update_service_files(id, &user, service.files).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Service updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_content_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::UserManageServices) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let mut service = match data.get_service_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + // update + let file = match service.file_mut(req.id_path) { + Some(f) => f, + None => return Json(Error::GeneralNotFound("file".to_string()).into()), + }; + + file.content = req.content; + + // ... + match data.update_service_files(id, &user, service.files).await { + 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()), + } +} + +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::UserManageServices) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_service(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Service deleted".to_string(), + payload: (), + }), + 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 4a450c5..f18ede0 100644 --- a/crates/app/src/routes/assets.rs +++ b/crates/app/src/routes/assets.rs @@ -19,3 +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!(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 d67dc0c..cde54f5 100644 --- a/crates/app/src/routes/mod.rs +++ b/crates/app/src/routes/mod.rs @@ -20,6 +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/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)), @@ -42,3 +44,14 @@ pub fn routes(config: &Config) -> Router { // pages .merge(pages::routes()) } + +/// These routes are only used when you provide the `LITTLEWEB` environment variable. +/// +/// These routes are NOT for editing. These routes are only for viewing littleweb sites. +pub fn lw_routes() -> Router { + Router::new() + // api + .nest("/api/v1", api::v1::lw_routes()) + // pages + .merge(pages::lw_routes()) +} 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 e11b685..6f5524f 100644 --- a/crates/app/src/routes/pages/communities.rs +++ b/crates/app/src/routes/pages/communities.rs @@ -1,16 +1,20 @@ +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, }; use axum::{ - Extension, extract::{Path, Query}, + http::{HeaderMap, HeaderValue}, response::{Html, IntoResponse}, + Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use serde::Deserialize; use tera::Context; use tetratto_core::model::{ + addr::RemoteAddr, auth::User, communities::Community, communities_permissions::CommunityPermission, @@ -120,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 { @@ -642,6 +654,7 @@ pub async fn settings_request( /// `/post/{id}` pub async fn post_request( jar: CookieJar, + headers: HeaderMap, Path(id): Path, Query(props): Query, Extension(data): Extension, @@ -751,11 +764,55 @@ pub async fn post_request( check_user_blocked_or_private!(user, owner, data, jar); } + // get real ip + let real_ip = headers + .get(data.0.0.0.security.real_ip_header.to_owned()) + .unwrap_or(&HeaderValue::from_static("")) + .to_str() + .unwrap_or("") + .to_string(); + + // check for ip ban + let addr = RemoteAddr::from(real_ip.as_str()); + if data.0.get_ipban_by_addr(&addr).await.is_ok() { + return Err(Html( + render_error( + Error::GeneralNotFound("post".to_string()), + &jar, + &data, + &user, + ) + .await, + )); + } + + // check for ip block + if data + .0 + .get_ipblock_by_initiator_receiver(post.owner, &addr) + .await + .is_ok() + { + return Err(Html( + render_error( + Error::GeneralNotFound("post".to_string()), + &jar, + &data, + &user, + ) + .await, + )); + } + // check repost 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)), }; @@ -875,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)), }; @@ -1026,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 0c35e04..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, @@ -362,5 +362,11 @@ pub async fn global_view_request( context.insert("global_mode", &true); // return - Ok(Html(data.1.render("journals/app.html", &context).unwrap())) + 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' 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 new file mode 100644 index 0000000..18233ff --- /dev/null +++ b/crates/app/src/routes/pages/littleweb.rs @@ -0,0 +1,293 @@ +use super::render_error; +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 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) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).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)), + }; + + 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( + data.1.render("littleweb/services.html", &context).unwrap(), + )) +} + +/// `/domains` +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) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).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)), + }; + + 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("tlds", &*TLDS_VEC); + context.insert("profile", &profile); + + // return + Ok(Html( + data.1.render("littleweb/domains.html", &context).unwrap(), + )) +} + +#[derive(Deserialize)] +pub struct FileBrowserProps { + #[serde(default)] + path: String, +} + +/// `/services/{id}` +pub async fn service_request( + jar: CookieJar, + Path(id): Path, + 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 service = match data.0.get_service_by_id(id).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + if user.id != service.owner + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_SERVICES) + { + 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("service", &service); + + match service.file(&props.path.replacen("/", "", 1)) { + Some((x, p)) => { + context.insert("id_path", &p); + context.insert("file", &x); + context.insert("files", &x.children); + } + None => { + context.insert("id_path", &Vec::<()>::new()); + context.insert("files", &service.files); + } + } + + let path_segments: Vec<&str> = props.path.split("/").collect(); + context.insert("path_segments", &path_segments); + context.insert("path", &props.path); + + // return + Ok(Html( + data.1.render("littleweb/service.html", &context).unwrap(), + )) +} + +/// `/domains/{id}` +pub async fn domain_request( + jar: CookieJar, + Path(id): Path, + 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 domain = match data.0.get_domain_by_id(id).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + if user.id != domain.owner + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_DOMAINS) + { + 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("domain", &domain); + + // return + Ok(Html( + data.1.render("littleweb/domain.html", &context).unwrap(), + )) +} + +/// `/net` +pub async fn browser_home_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + 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 + Ok(Html( + data.1.render("littleweb/browser.html", &context).unwrap(), + )) +} + +/// `/net/{uri}` +pub async fn browser_request( + jar: CookieJar, + Path(mut uri): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = get_user_from_token!(jar, data.0); + + if !uri.contains("/") { + uri = format!("{uri}/index.html"); + } + + if !uri.starts_with("atto://") { + 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("session", &session); + context.insert("path", &uri.replace("atto://", "")); + + // return + 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 3b99c5d..7ee2f72 100644 --- a/crates/app/src/routes/pages/misc.rs +++ b/crates/app/src/routes/pages/misc.rs @@ -7,10 +7,10 @@ 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}, + auth::{AchievementName, DefaultTimelineChoice, ACHIEVEMENTS}, permissions::FinePermission, requests::ActionType, Error, @@ -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 @@ -464,7 +464,7 @@ pub async fn achievements_request( // award achievement if let Err(e) = data .0 - .add_achievement(&mut user, AchievementName::OpenAchievements.into()) + .add_achievement(&mut user, AchievementName::OpenAchievements.into(), true) .await { return Err(Html(render_error(e, &jar, &data, &None).await)); @@ -473,6 +473,11 @@ pub async fn achievements_request( // ... let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + + context.insert( + "percentage", + &((achievements.len() as f32 / ACHIEVEMENTS as f32) * 100.0), + ); context.insert("achievements", &achievements); // return @@ -626,6 +631,10 @@ pub struct TimelineQuery { pub tag: String, #[serde(default)] pub paginated: bool, + #[serde(default)] + pub before: usize, + #[serde(default)] + pub responses_only: bool, } /// `/_swiss_army_timeline` @@ -673,17 +682,31 @@ pub async fn swiss_army_timeline_request( check_user_blocked_or_private!(user, other_user, data, jar); if req.tag.is_empty() { - data.0.get_posts_by_user(req.user_id, 12, req.page).await + if req.responses_only { + data.0 + .get_responses_by_user(req.user_id, 12, req.page) + .await + } else { + data.0.get_posts_by_user(req.user_id, 12, req.page).await + } } else { - data.0 - .get_posts_by_user_tag(req.user_id, &req.tag, 12, req.page) - .await + if req.responses_only { + data.0 + .get_responses_by_user_tag(req.user_id, &req.tag, 12, req.page) + .await + } else { + data.0 + .get_posts_by_user_tag(req.user_id, &req.tag, 12, req.page) + .await + } } } else { // everything else match req.tl { DefaultTimelineChoice::AllPosts => { - data.0.get_latest_posts(12, req.page, &user).await + data.0 + .get_latest_posts(12, req.page, &user, req.before) + .await } DefaultTimelineChoice::PopularPosts => { data.0.get_popular_posts(12, req.page, 604_800_000).await @@ -702,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 909fa2d..2f3c9d5 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -4,6 +4,8 @@ pub mod communities; 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; @@ -13,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() @@ -76,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)) @@ -139,12 +148,28 @@ pub fn routes() -> Router { .route("/@{owner}/{journal}", get(journals::index_view_request)) .route("/@{owner}/{journal}/{note}", get(journals::view_request)) .route("/x/{note}", get(journals::global_view_request)) + // littleweb + .route("/services", get(littleweb::services_request)) + .route("/domains", get(littleweb::domains_request)) + .route("/services/{id}", get(littleweb::service_request)) + .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 { + Router::new() } 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); @@ -157,6 +182,8 @@ pub async fn render_error( pub struct PaginatedQuery { #[serde(default)] pub page: usize, + #[serde(default)] + pub before: usize, } #[derive(Deserialize)] @@ -177,6 +204,10 @@ pub struct ProfileQuery { pub warning: bool, #[serde(default)] pub tag: String, + #[serde(default, alias = "r")] + pub responses_only: bool, + #[serde(default, alias = "f")] + pub force: bool, } #[derive(Deserialize)] 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 17d0d1f..3f6274b 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -8,10 +8,15 @@ 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::{auth::User, communities::Community, permissions::FinePermission, Error}; +use tetratto_core::model::{ + auth::{DefaultProfileTabChoice, User}, + communities::Community, + permissions::FinePermission, + Error, +}; use tetratto_shared::hash::hash; use contrasted::{Color, MINIMUM_CONTRAST_THRESHOLD}; @@ -58,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 { @@ -94,6 +117,11 @@ pub async fn settings_request( out }; + let ipblocks = match data.0.get_ipblocks_by_initiator(profile.id).await { + Ok(l) => l, + Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), + }; + let uploads = match data.0.get_uploads_by_owner(profile.id, 12, req.page).await { Ok(ua) => ua, Err(e) => { @@ -126,9 +154,11 @@ 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); + context.insert("ipblocks", &ipblocks); context.insert("invites", &invites); context.insert( "user_tokens_serde", @@ -246,6 +276,10 @@ pub async fn posts_request( check_user_blocked_or_private!(user, other_user, data, jar); + let responses_only = props.responses_only + | (other_user.settings.default_profile_tab == DefaultProfileTabChoice::Responses + && !props.force); + // check for warning if props.warning { let lang = get_lang!(jar, data.0); @@ -350,7 +384,13 @@ pub async fn posts_request( ); // return - Ok(Html(data.1.render("profile/posts.html", &context).unwrap())) + if responses_only { + Ok(Html( + data.1.render("profile/responses.html", &context).unwrap(), + )) + } else { + Ok(Html(data.1.render("profile/posts.html", &context).unwrap())) + } } /// `/@{username}/replies` @@ -691,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)), }, @@ -786,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 afcecb0..bd7ac03 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,22 +1,38 @@ [package] name = "tetratto-core" -version = "10.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" -emojis = "0.6.4" +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"] } +oiseau = { version = "0.1.2", default-features = false, features = [ + "postgres", + "redis", +], 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 7de4cfb..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,6 +194,30 @@ pub struct StripeConfig { /// /// pub billing_portal_url: 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) @@ -250,6 +276,10 @@ pub struct Config { /// so this host should be included in there as well. #[serde(default = "default_host")] pub host: String, + /// The main public host of the littleweb server. **Not** used to check against banned hosts, + /// so this host should be included in there as well. + #[serde(default = "default_lw_host")] + pub lw_host: String, /// Database security. #[serde(default = "default_security")] pub security: SecurityConfig, @@ -317,6 +347,10 @@ fn default_host() -> String { String::new() } +fn default_lw_host() -> String { + String::new() +} + fn default_security() -> SecurityConfig { SecurityConfig::default() } @@ -351,6 +385,9 @@ fn default_banned_usernames() -> Vec { "search".to_string(), "journals".to_string(), "links".to_string(), + "app".to_string(), + "services".to_string(), + "domains".to_string(), ] } @@ -383,6 +420,7 @@ impl Default for Config { port: default_port(), banned_hosts: default_banned_hosts(), host: default_host(), + lw_host: default_lw_host(), database: default_database(), security: default_security(), dirs: default_dirs(), 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 9faf6d4..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: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: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: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: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 a34b634..4ced643 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -1,6 +1,9 @@ use super::common::NAME_REGEX; use oiseau::cache::Cache; -use crate::model::auth::{Achievement, AchievementRarity, Notification, UserConnections}; +use crate::model::auth::{ + Achievement, AchievementName, AchievementRarity, Notification, StripeSellerData, + UserConnections, ACHIEVEMENTS, +}; use crate::model::moderation::AuditLogEntry; use crate::model::oauth::AuthGrant; use crate::model::permissions::SecondaryPermission; @@ -97,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(), @@ -112,12 +122,20 @@ impl DataManager { invite_code: get!(x->21(i64)) as usize, secondary_permissions: SecondaryPermission::from_bits(get!(x->22(i32)) as u32).unwrap(), 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. /// @@ -186,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)) ); @@ -267,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)", + "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), @@ -277,7 +295,7 @@ impl DataManager { &serde_json::to_string(&data.settings).unwrap(), &serde_json::to_string(&data.tokens).unwrap(), &(FinePermission::DEFAULT.bits() as i32), - &(if data.is_verified { 1_i32 } else { 0_i32 }), + &if data.is_verified { 1_i32 } else { 0_i32 }, &0_i32, &0_i32, &0_i32, @@ -293,6 +311,13 @@ impl DataManager { &(data.invite_code as i64), &(SecondaryPermission::DEFAULT.bits() as i32), &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 }, ] ); @@ -309,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 { @@ -519,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(), @@ -569,7 +599,7 @@ impl DataManager { } // ... - Ok(()) + Ok(user) } pub async fn update_user_verified_status(&self, id: usize, x: bool, user: User) -> Result<()> { @@ -610,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, @@ -688,6 +756,54 @@ impl DataManager { Ok(()) } + pub async fn update_user_awaiting_purchased_status( + &self, + id: usize, + x: bool, + user: User, + require_permission: bool, + ) -> Result<()> { + if (user.id != id) | require_permission { + if !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 awaiting_purchase = $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 + if user.id != other_user.id { + self.create_audit_log_entry(AuditLogEntry::new( + user.id, + format!( + "invoked `update_user_purchased_status` with x value `{}` and y value `{}`", + other_user.id, x + ), + )) + .await?; + } + + // ... + Ok(()) + } + pub async fn seen_user(&self, user: &User) -> Result<()> { let conn = match self.0.connect().await { Ok(c) => c, @@ -712,7 +828,17 @@ impl DataManager { /// Add an achievement to a user. /// /// Still returns `Ok` if the user already has the achievement. - pub async fn add_achievement(&self, user: &mut User, achievement: Achievement) -> Result<()> { + #[async_recursion::async_recursion] + pub async fn add_achievement( + &self, + user: &mut User, + achievement: Achievement, + check_for_final: bool, + ) -> Result<()> { + if user.settings.disable_achievements { + return Ok(()); + } + if user .achievements .iter() @@ -738,6 +864,15 @@ impl DataManager { self.update_user_achievements(user.id, user.achievements.to_owned()) .await?; + // check for final + if check_for_final { + if user.achievements.len() + 1 == ACHIEVEMENTS { + self.add_achievement(user, AchievementName::GetAllOtherAchievements.into(), false) + .await?; + } + } + + // ... Ok(()) } @@ -919,6 +1054,11 @@ impl DataManager { auto_method!(update_user_connections(UserConnections)@get_user_by_id -> "UPDATE users SET connections = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); 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 b3dc31b..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 { @@ -317,10 +316,10 @@ impl DataManager { Ok(()) } - auto_method!(update_channel_title(&str)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); - auto_method!(update_channel_position(i32)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET position = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); - auto_method!(update_channel_minimum_role_read(i32)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET minimum_role_read = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); - auto_method!(update_channel_minimum_role_write(i32)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET minimum_role_write = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); - auto_method!(update_channel_members(Vec)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET members = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.channel:{}"); + auto_method!(update_channel_title(&str)@get_channel_by_id:FinePermission::MANAGE_CHANNELS; -> "UPDATE channels SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); + auto_method!(update_channel_position(i32)@get_channel_by_id:FinePermission::MANAGE_CHANNELS; -> "UPDATE channels SET position = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); + auto_method!(update_channel_minimum_role_read(i32)@get_channel_by_id:FinePermission::MANAGE_CHANNELS; -> "UPDATE channels SET minimum_role_read = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); + auto_method!(update_channel_minimum_role_write(i32)@get_channel_by_id:FinePermission::MANAGE_CHANNELS; -> "UPDATE channels SET minimum_role_write = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); + auto_method!(update_channel_members(Vec)@get_channel_by_id:FinePermission::MANAGE_CHANNELS; -> "UPDATE channels SET members = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.channel:{}"); auto_method!(update_channel_last_message(i64) -> "UPDATE channels SET last_message = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); } diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 45111db..5e10783 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -40,6 +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_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 @@ -72,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] @@ -226,12 +255,12 @@ macro_rules! auto_method { } }; - ($name:ident()@$select_fn:ident:$permission:ident -> $query:literal) => { + ($name:ident()@$select_fn:ident:$permission:expr; -> $query:literal) => { pub async fn $name(&self, id: usize, user: &User) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( @@ -256,12 +285,12 @@ macro_rules! auto_method { } }; - ($name:ident()@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal) => { + ($name:ident()@$select_fn:ident:$permission:expr; -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal) => { pub async fn $name(&self, id: usize, user: &User) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( @@ -288,12 +317,12 @@ macro_rules! auto_method { } }; - ($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal) => { + ($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal) => { pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( @@ -319,12 +348,12 @@ macro_rules! auto_method { } }; - ($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal) => { + ($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal) => { pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( @@ -352,12 +381,12 @@ macro_rules! auto_method { } }; - ($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --serde) => { + ($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal --serde) => { pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( @@ -387,20 +416,17 @@ macro_rules! auto_method { } }; - ($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --serde --cache-key-tmpl=$cache_key_tmpl:literal) => { + ($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal --serde --cache-key-tmpl=$cache_key_tmpl:literal) => { pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( user.id, - format!( - "invoked `{}` with x value `{id}` and y value `{x:?}`", - stringify!($name) - ), + format!("invoked `{}` with x value `{id}`", stringify!($name)), )) .await? } @@ -570,12 +596,12 @@ macro_rules! auto_method { } }; - ($name:ident()@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:ident) => { + ($name:ident()@$select_fn:ident:$permission:expr; -> $query:literal --cache-key-tmpl=$cache_key_tmpl:ident) => { pub async fn $name(&self, id: usize, user: &User) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( @@ -603,12 +629,12 @@ macro_rules! auto_method { } }; - ($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:ident) => { + ($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal --cache-key-tmpl=$cache_key_tmpl:ident) => { pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( @@ -682,12 +708,12 @@ macro_rules! auto_method { } }; - ($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --serde --cache-key-tmpl=$cache_key_tmpl:ident) => { + ($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal --serde --cache-key-tmpl=$cache_key_tmpl:ident) => { pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( diff --git a/crates/core/src/database/communities.rs b/crates/core/src/database/communities.rs index 2642f37..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); } @@ -299,11 +304,10 @@ impl DataManager { } // add community owner as admin - self.create_membership(CommunityMembership::new( - data.owner, - data.id, - CommunityPermission::ADMINISTRATOR, - )) + self.create_membership( + CommunityMembership::new(data.owner, data.id, CommunityPermission::ADMINISTRATOR), + &owner, + ) .await .unwrap(); @@ -522,10 +526,10 @@ impl DataManager { Ok(()) } - auto_method!(update_community_context(CommunityContext)@get_community_by_id_no_void:MANAGE_COMMUNITIES -> "UPDATE communities SET context = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); - auto_method!(update_community_read_access(CommunityReadAccess)@get_community_by_id_no_void:MANAGE_COMMUNITIES -> "UPDATE communities SET read_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); - auto_method!(update_community_write_access(CommunityWriteAccess)@get_community_by_id_no_void:MANAGE_COMMUNITIES -> "UPDATE communities SET write_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); - auto_method!(update_community_join_access(CommunityJoinAccess)@get_community_by_id_no_void:MANAGE_COMMUNITIES -> "UPDATE communities SET join_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); + auto_method!(update_community_context(CommunityContext)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET context = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); + auto_method!(update_community_read_access(CommunityReadAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET read_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); + auto_method!(update_community_write_access(CommunityWriteAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET write_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); + auto_method!(update_community_join_access(CommunityJoinAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET join_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); auto_method!(incr_community_likes()@get_community_by_id_no_void -> "UPDATE communities SET likes = likes + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr); auto_method!(incr_community_dislikes()@get_community_by_id_no_void -> "UPDATE communities SET dislikes = dislikes + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr); diff --git a/crates/core/src/database/domains.rs b/crates/core/src/database/domains.rs new file mode 100644 index 0000000..737bd5f --- /dev/null +++ b/crates/core/src/database/domains.rs @@ -0,0 +1,183 @@ +use crate::{ + database::NAME_REGEX, + model::{ + auth::User, + littleweb::{Domain, DomainData, DomainTld}, + permissions::{FinePermission, SecondaryPermission}, + Error, Result, + }, +}; +use crate::{auto_method, DataManager}; +use oiseau::{cache::Cache, execute, get, params, query_row, query_rows, PostgresRow}; + +impl DataManager { + /// Get a [`Domain`] from an SQL row. + pub(crate) fn get_domain_from_row(x: &PostgresRow) -> Domain { + Domain { + 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)), + tld: (get!(x->4(String)).as_str()).into(), + data: serde_json::from_str(&get!(x->5(String))).unwrap(), + } + } + + auto_method!(get_domain_by_id(usize as i64)@get_domain_from_row -> "SELECT * FROM domains WHERE id = $1" --name="domain" --returns=Domain --cache-key-tmpl="atto.domain:{}"); + + /// Get a domain given its name and TLD. + /// + /// # Arguments + /// * `name` + /// * `tld` + pub async fn get_domain_by_name_tld(&self, name: &str, tld: &DomainTld) -> 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, + "SELECT * FROM domains WHERE name = $1 AND tld = $2", + &[&name, &tld.to_string()], + |x| { Ok(Self::get_domain_from_row(x)) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("domain".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get all domains by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch domains for + pub async fn get_domains_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 domains WHERE owner = $1 ORDER BY created DESC", + &[&(id as i64)], + |x| { Self::get_domain_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("domain".to_string())); + } + + Ok(res.unwrap()) + } + + const MAXIMUM_FREE_DOMAINS: usize = 10; + + /// Create a new domain in the database. + /// + /// # Arguments + /// * `data` - a mock [`Domain`] object to insert + pub async fn create_domain(&self, data: Domain) -> 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 domains + let owner = self.get_user_by_id(data.owner).await?; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + let domains = self.get_domains_by_user(data.owner).await?; + + if domains.len() >= Self::MAXIMUM_FREE_DOMAINS { + return Err(Error::MiscError( + "You already have the maximum number of domains you can have".to_string(), + )); + } + } + + // check name + let regex = regex::RegexBuilder::new(NAME_REGEX) + .multi_line(true) + .build() + .unwrap(); + + if regex.captures(&data.name).is_some() { + return Err(Error::MiscError( + "Domain name contains invalid characters".to_string(), + )); + } + + // check for existing + if self + .get_domain_by_name_tld(&data.name, &data.tld) + .await + .is_ok() + { + return Err(Error::MiscError( + "Domain + TLD already in use. Maybe try another TLD!".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 domains VALUES ($1, $2, $3, $4, $5, $6)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &data.name, + &data.tld.to_string(), + &serde_json::to_string(&data.data).unwrap(), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_domain(&self, id: usize, user: &User) -> Result<()> { + let domain = self.get_domain_by_id(id).await?; + + // check user permission + if user.id != domain.owner + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_DOMAINS) + { + 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 domains WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... + self.0.1.remove(format!("atto.domain:{}", id)).await; + Ok(()) + } + + auto_method!(update_domain_data(Vec<(String, DomainData)>)@get_domain_by_id:FinePermission::MANAGE_USERS; -> "UPDATE domains SET data = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.domain:{}"); +} diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index e1cfad7..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,3 +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_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_domains.sql b/crates/core/src/database/drivers/sql/create_domains.sql new file mode 100644 index 0000000..fc0f190 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_domains.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS domains ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + name TEXT NOT NULL, + tld TEXT NOT NULL, + data 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 new file mode 100644 index 0000000..ecb04d6 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_services.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS services ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + name 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 9cb0851..6a939e5 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -20,6 +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 + achievements TEXT NOT NULL, + awaiting_purchase 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/emojis.rs b/crates/core/src/database/emojis.rs index c61fed6..4f09af7 100644 --- a/crates/core/src/database/emojis.rs +++ b/crates/core/src/database/emojis.rs @@ -201,5 +201,5 @@ impl DataManager { Ok(()) } - auto_method!(update_emoji_name(&str)@get_emoji_by_id:MANAGE_EMOJIS -> "UPDATE emojis SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.emoji:{}"); + auto_method!(update_emoji_name(&str)@get_emoji_by_id:FinePermission::MANAGE_EMOJIS; -> "UPDATE emojis SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.emoji:{}"); } diff --git a/crates/core/src/database/invite_codes.rs b/crates/core/src/database/invite_codes.rs index 2c6d950..084cfb3 100644 --- a/crates/core/src/database/invite_codes.rs +++ b/crates/core/src/database/invite_codes.rs @@ -20,8 +20,8 @@ impl DataManager { } } - auto_method!(get_invite_code_by_id()@get_invite_code_from_row -> "SELECT * FROM invite_codes WHERE id = $1" --name="invite_code" --returns=InviteCode --cache-key-tmpl="atto.invite_code:{}"); - auto_method!(get_invite_code_by_code(&str)@get_invite_code_from_row -> "SELECT * FROM invite_codes WHERE code = $1" --name="invite_code" --returns=InviteCode); + auto_method!(get_invite_code_by_id()@get_invite_code_from_row -> "SELECT * FROM invite_codes WHERE id = $1" --name="invite code" --returns=InviteCode --cache-key-tmpl="atto.invite_code:{}"); + auto_method!(get_invite_code_by_code(&str)@get_invite_code_from_row -> "SELECT * FROM invite_codes WHERE code = $1" --name="invite code" --returns=InviteCode); /// Get invite_codes by `owner`. pub async fn get_invite_codes_by_owner( @@ -96,23 +96,24 @@ impl DataManager { const MAXIMUM_FREE_INVITE_CODES: usize = 4; const MAXIMUM_SUPPORTER_INVITE_CODES: usize = 48; - const MINIMUM_ACCOUNT_AGE_FOR_FREE_INVITE_CODES: usize = 2_629_800_000; // 1mo + const MINIMUM_ACCOUNT_AGE_FOR_INVITE_CODES: usize = 2_629_800_000; // 1mo /// Create a new invite_code in the database. /// /// # Arguments /// * `data` - a mock [`InviteCode`] object to insert pub async fn create_invite_code(&self, data: InviteCode, user: &User) -> Result { - if !user.permissions.check(FinePermission::SUPPORTER) { - // check account creation date - if unix_epoch_timestamp() - user.created - < Self::MINIMUM_ACCOUNT_AGE_FOR_FREE_INVITE_CODES - { + // check account creation date (if we aren't a supporter OR this is a purchased account) + if !user.permissions.check(FinePermission::SUPPORTER) | user.was_purchased { + if unix_epoch_timestamp() - user.created < Self::MINIMUM_ACCOUNT_AGE_FOR_INVITE_CODES { return Err(Error::MiscError( "Your account is too young to do this".to_string(), )); } + } + // ... + if !user.permissions.check(FinePermission::SUPPORTER) { // our account is old enough, but we need to make sure we don't already have // 2 invite codes if (self.get_invite_codes_by_owner_count(user.id).await? as usize) diff --git a/crates/core/src/database/ipbans.rs b/crates/core/src/database/ipbans.rs index 550570d..4748424 100644 --- a/crates/core/src/database/ipbans.rs +++ b/crates/core/src/database/ipbans.rs @@ -23,7 +23,7 @@ impl DataManager { /// /// # Arguments /// * `prefix` - pub async fn get_ipban_by_addr(&self, addr: RemoteAddr) -> Result { + pub async fn get_ipban_by_addr(&self, addr: &RemoteAddr) -> Result { let conn = match self.0.connect().await { Ok(c) => c, Err(e) => return Err(Error::DatabaseConnection(e.to_string())), diff --git a/crates/core/src/database/ipblocks.rs b/crates/core/src/database/ipblocks.rs index f94ed51..c3e92b2 100644 --- a/crates/core/src/database/ipblocks.rs +++ b/crates/core/src/database/ipblocks.rs @@ -1,10 +1,8 @@ use oiseau::cache::Cache; +use crate::model::addr::RemoteAddr; use crate::model::{Error, Result, auth::User, auth::IpBlock, permissions::FinePermission}; use crate::{auto_method, DataManager}; - -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_row, params}; +use oiseau::{query_rows, PostgresRow, execute, get, query_row, params}; impl DataManager { /// Get an [`IpBlock`] from an SQL row. @@ -19,11 +17,11 @@ impl DataManager { auto_method!(get_ipblock_by_id()@get_ipblock_from_row -> "SELECT * FROM ipblocks WHERE id = $1" --name="ip block" --returns=IpBlock --cache-key-tmpl="atto.ipblock:{}"); - /// Get a user block by `initiator` and `receiver` (in that order). + /// Get a ip block by `initiator` and `receiver` (in that order). pub async fn get_ipblock_by_initiator_receiver( &self, initiator: usize, - receiver: &str, + receiver: &RemoteAddr, ) -> Result { let conn = match self.0.connect().await { Ok(c) => c, @@ -32,19 +30,19 @@ impl DataManager { let res = query_row!( &conn, - "SELECT * FROM ipblocks WHERE initiator = $1 AND receiver = $2", - params![&(initiator as i64), &receiver], + "SELECT * FROM ipblocks WHERE initiator = $1 AND receiver LIKE $2", + params![&(initiator as i64), &format!("{}%", receiver.prefix(None))], |x| { Ok(Self::get_ipblock_from_row(x)) } ); if res.is_err() { - return Err(Error::GeneralNotFound("user block".to_string())); + return Err(Error::GeneralNotFound("ip block".to_string())); } Ok(res.unwrap()) } - /// Get a user block by `receiver` and `initiator` (in that order). + /// Get a ip block by `receiver` and `initiator` (in that order). pub async fn get_ipblock_by_receiver_initiator( &self, receiver: &str, @@ -63,13 +61,34 @@ impl DataManager { ); if res.is_err() { - return Err(Error::GeneralNotFound("user block".to_string())); + return Err(Error::GeneralNotFound("ip block".to_string())); } Ok(res.unwrap()) } - /// Create a new user block in the database. + /// Get all ip blocks by `initiator`. + pub async fn get_ipblocks_by_initiator(&self, initiator: 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 ipblocks WHERE initiator = $1", + params![&(initiator as i64)], + |x| { Self::get_ipblock_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("ip block".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new ip block in the database. /// /// # Arguments /// * `data` - a mock [`IpBlock`] object to insert @@ -102,7 +121,7 @@ impl DataManager { let block = self.get_ipblock_by_id(id).await?; if user.id != block.initiator { - // only the initiator (or moderators) can delete user blocks! + // only the initiator (or moderators) can delete ip blocks! if !user.permissions.check(FinePermission::MANAGE_FOLLOWS) { return Err(Error::NotAllowed); } diff --git a/crates/core/src/database/journals.rs b/crates/core/src/database/journals.rs index 2ad4078..4855f9d 100644 --- a/crates/core/src/database/journals.rs +++ b/crates/core/src/database/journals.rs @@ -183,7 +183,7 @@ impl DataManager { Ok(()) } - auto_method!(update_journal_title(&str)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.journal:{}"); - auto_method!(update_journal_privacy(JournalPrivacyPermission)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}"); - auto_method!(update_journal_dirs(Vec<(usize, usize, String)>)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET dirs = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}"); + auto_method!(update_journal_title(&str)@get_journal_by_id:FinePermission::MANAGE_JOURNALS; -> "UPDATE journals SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.journal:{}"); + auto_method!(update_journal_privacy(JournalPrivacyPermission)@get_journal_by_id:FinePermission::MANAGE_JOURNALS; -> "UPDATE journals SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}"); + auto_method!(update_journal_dirs(Vec<(usize, usize, String)>)@get_journal_by_id:FinePermission::MANAGE_JOURNALS; -> "UPDATE journals SET dirs = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}"); } 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/memberships.rs b/crates/core/src/database/memberships.rs index 4ae7094..610d0a0 100644 --- a/crates/core/src/database/memberships.rs +++ b/crates/core/src/database/memberships.rs @@ -1,4 +1,5 @@ use oiseau::cache::Cache; +use crate::model::auth::AchievementName; use crate::model::communities::Community; use crate::model::requests::{ActionRequest, ActionType}; use crate::model::{ @@ -169,7 +170,11 @@ impl DataManager { /// # Arguments /// * `data` - a mock [`CommunityMembership`] object to insert #[async_recursion::async_recursion] - pub async fn create_membership(&self, data: CommunityMembership) -> Result { + pub async fn create_membership( + &self, + data: CommunityMembership, + user: &User, + ) -> Result { // make sure membership doesn't already exist if self .get_membership_by_owner_community_no_void(data.owner, data.community) @@ -199,7 +204,7 @@ impl DataManager { .await?; // ... - return self.create_membership(data).await; + return self.create_membership(data, user).await; } } _ => (), @@ -237,6 +242,13 @@ impl DataManager { Ok(if data.role.check(CommunityPermission::REQUESTED) { "Join request sent".to_string() } else { + self.add_achievement( + &mut user.clone(), + AchievementName::JoinCommunity.into(), + true, + ) + .await?; + "Community joined".to_string() }) } 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 5f81259..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; @@ -5,6 +6,7 @@ mod channels; mod common; mod communities; pub mod connections; +mod domains; mod drafts; mod drivers; mod emojis; @@ -12,6 +14,7 @@ mod invite_codes; mod ipbans; mod ipblocks; mod journals; +mod letters; mod memberships; mod message_reactions; mod messages; @@ -20,10 +23,12 @@ mod notifications; mod polls; mod pollvotes; mod posts; +mod products; mod questions; mod reactions; mod reports; mod requests; +mod services; mod stackblocks; mod stacks; mod uploads; diff --git a/crates/core/src/database/notes.rs b/crates/core/src/database/notes.rs index 2754baf..9769159 100644 --- a/crates/core/src/database/notes.rs +++ b/crates/core/src/database/notes.rs @@ -289,10 +289,10 @@ impl DataManager { self.0.1.remove(format!("atto.note:{}", x.title)).await; } - auto_method!(update_note_title(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET title = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); - auto_method!(update_note_content(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET content = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); - auto_method!(update_note_dir(i64)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET dir = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); - auto_method!(update_note_tags(Vec)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET tags = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_note); + auto_method!(update_note_title(&str)@get_note_by_id:FinePermission::MANAGE_NOTES; -> "UPDATE notes SET title = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); + auto_method!(update_note_content(&str)@get_note_by_id:FinePermission::MANAGE_NOTES; -> "UPDATE notes SET content = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); + auto_method!(update_note_dir(i64)@get_note_by_id:FinePermission::MANAGE_NOTES; -> "UPDATE notes SET dir = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); + auto_method!(update_note_tags(Vec)@get_note_by_id:FinePermission::MANAGE_NOTES; -> "UPDATE notes SET tags = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_note); auto_method!(update_note_edited(i64)@get_note_by_id -> "UPDATE notes SET edited = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); auto_method!(update_note_is_global(i32)@get_note_by_id -> "UPDATE notes SET is_global = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); } diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index c1f1dca..0891fed 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -1,8 +1,7 @@ use std::collections::HashMap; -use oiseau::cache::Cache; use crate::config::StringBan; -use crate::model::auth::Notification; -use crate::model::communities::{Poll, Question}; +use crate::model::auth::{AchievementName, Notification}; +use crate::model::communities::{CommunityMembership, CommunityReadAccess, Poll, Question}; use crate::model::communities_permissions::CommunityPermission; use crate::model::moderation::AuditLogEntry; use crate::model::stacks::{StackMode, StackSort, UserStack}; @@ -16,17 +15,18 @@ use tetratto_shared::unix_epoch_timestamp; use crate::{auto_method, DataManager}; use oiseau::{PostgresRow, cache::redis::Commands}; -use oiseau::{execute, get, query_row, query_rows, params}; +use oiseau::{execute, get, query_row, query_rows, params, cache::Cache}; pub type FullPost = ( Post, 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) => { @@ -225,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) { @@ -239,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) } @@ -323,7 +333,7 @@ impl DataManager { Post, User, Option<(User, Post)>, - Option<(Question, User)>, + Option<(Question, User, Option<(User, Post)>)>, Option<(Poll, bool, bool)>, Option, )>, @@ -333,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 { @@ -374,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, )); @@ -385,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; @@ -455,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, )); @@ -478,7 +493,9 @@ 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(); for post in posts { if post.is_deleted { @@ -493,6 +510,30 @@ impl DataManager { continue; } + // check membership + if community.read_access == CommunityReadAccess::Joined { + if let Some(user) = user { + if let Some(membership) = memberships.get(&community.id) { + if !membership.role.check(CommunityPermission::MEMBER) { + continue; + } + } else { + if let Ok(membership) = self + .get_membership_by_owner_community_no_void(user.id, community.id) + .await + { + if !membership.role.check(CommunityPermission::MEMBER) { + continue; + } + } else { + continue; + } + } + } else { + continue; + } + } + // stack let (can_view, stack) = self .get_post_stack( @@ -520,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, )); @@ -537,6 +579,32 @@ impl DataManager { continue; } + // check membership + let community = self.get_community_by_id(community).await?; + if community.read_access == CommunityReadAccess::Joined { + if let Some(user) = user { + if let Some(membership) = memberships.get(&community.id) { + if !membership.role.check(CommunityPermission::MEMBER) { + continue; + } + } else { + if let Ok(membership) = self + .get_membership_by_owner_community_no_void(user.id, community.id) + .await + { + memberships.insert(owner, membership.clone()); + if !membership.role.check(CommunityPermission::MEMBER) { + continue; + } + } else { + continue; + } + } + } else { + continue; + } + } + // check relationship if ua.settings.private_profile && ua.id != user_id { if user_id == 0 { @@ -587,14 +655,14 @@ impl DataManager { } // ... - let community = self.get_community_by_id(community).await?; seen_before.insert((owner, community.id), (ua.clone(), community.clone())); out.push(( post.clone(), 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, )); @@ -667,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(); + } } // ... @@ -709,6 +781,37 @@ impl DataManager { Ok(res.unwrap()) } + /// Get all posts (that are answering a question) from the given user (from most recent). + /// + /// # Arguments + /// * `id` - the ID of the user the requested posts belong to + /// * `batch` - the limit of posts in each page + /// * `page` - the page number + pub async fn get_responses_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 posts WHERE owner = $1 AND replying_to = 0 AND NOT (context::json->>'is_profile_pinned')::boolean AND is_deleted = 0 AND NOT context::jsonb->>'answering' = '0' ORDER BY created DESC LIMIT $2 OFFSET $3", + &[&(id as i64), &(batch as i64), &((page * batch) as i64)], + |x| { Self::get_post_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("post".to_string())); + } + + Ok(res.unwrap()) + } + /// Calculate the GPA (great post average) of a given user. /// /// To be considered a "great post", a post must have a score ((likes - dislikes) / (likes + dislikes)) @@ -1017,6 +1120,45 @@ impl DataManager { Ok(res.unwrap()) } + /// Get all posts (that are answering a question) from the given user + /// with the given tag (from most recent). + /// + /// # Arguments + /// * `id` - the ID of the user the requested posts belong to + /// * `tag` - the tag to filter by + /// * `batch` - the limit of posts in each page + /// * `page` - the page number + pub async fn get_responses_by_user_tag( + &self, + id: usize, + tag: &str, + 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 posts WHERE owner = $1 AND context::json->>'tags' LIKE $2 AND is_deleted = 0 AND NOT context::jsonb->>'answering' = '0' ORDER BY created DESC LIMIT $3 OFFSET $4", + params![ + &(id as i64), + &format!("%\"{tag}\"%"), + &(batch as i64), + &((page * batch) as i64) + ], + |x| { Self::get_post_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("post".to_string())); + } + + Ok(res.unwrap()) + } + /// Get all posts from the given community (from most recent). /// /// # Arguments @@ -1325,6 +1467,7 @@ impl DataManager { batch: usize, page: usize, as_user: &Option, + before_time: usize, ) -> Result> { let hide_answers: bool = if let Some(user) = as_user { user.settings.all_timeline_hide_answers @@ -1332,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())), @@ -1340,7 +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 { @@ -1369,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(); @@ -1383,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, @@ -1392,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) } @@ -1575,6 +1745,8 @@ impl DataManager { self.get_community_by_id(data.community).await? }; + let mut owner = self.get_user_by_id(data.owner).await?; + // check values (if this isn't reposting something else) let is_reposting = if let Some(ref repost) = data.context.repost { repost.reposting.is_some() @@ -1602,6 +1774,14 @@ impl DataManager { } else if data.title.len() > 128 { return Err(Error::DataTooLong("title".to_string())); } + + // award achievement + self.add_achievement( + &mut owner, + AchievementName::CreatePostWithTitle.into(), + true, + ) + .await?; } } @@ -1614,7 +1794,6 @@ impl DataManager { data.context.is_nsfw = community.context.is_nsfw; // remove request if we were answering a question - let owner = self.get_user_by_id(data.owner).await?; if data.context.answering != 0 { let question = self.get_question_by_id(data.context.answering).await?; @@ -1741,6 +1920,10 @@ impl DataManager { ) .await?; } + + // award achievement + self.add_achievement(&mut owner, AchievementName::CreateRepost.into(), true) + .await?; } // check if the post we're replying to allows commments @@ -1819,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, @@ -2214,6 +2401,15 @@ impl DataManager { } } + // auto unlist + if user.settings.auto_unlist { + 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 a6ca60a..3703d4f 100644 --- a/crates/core/src/database/questions.rs +++ b/crates/core/src/database/questions.rs @@ -1,6 +1,8 @@ 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::{ @@ -37,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 { @@ -52,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() @@ -61,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)); } } @@ -71,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); } @@ -360,20 +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, &data.ip) - .await - .is_ok() - { - return Err(Error::NotAllowed); - } + // this should be unreachable + return Err(Error::Unknown); } } else { // single @@ -391,9 +406,21 @@ 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, &data.ip) + .get_ipblock_by_initiator_receiver(receiver.id, &RemoteAddr::from(data.ip.as_str())) .await .is_ok() { @@ -401,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/reactions.rs b/crates/core/src/database/reactions.rs index 4d15190..c26c3dc 100644 --- a/crates/core/src/database/reactions.rs +++ b/crates/core/src/database/reactions.rs @@ -1,5 +1,6 @@ use oiseau::cache::Cache; use crate::model::{ + addr::RemoteAddr, auth::{AchievementName, Notification, User}, permissions::FinePermission, reactions::{AssetType, Reaction}, @@ -127,7 +128,12 @@ impl DataManager { /// /// # Arguments /// * `data` - a mock [`Reaction`] object to insert - pub async fn create_reaction(&self, data: Reaction, user: &User) -> Result<()> { + pub async fn create_reaction( + &self, + data: Reaction, + user: &User, + addr: &RemoteAddr, + ) -> Result<()> { let conn = match self.0.connect().await { Ok(c) => c, Err(e) => return Err(Error::DatabaseConnection(e.to_string())), @@ -140,10 +146,14 @@ impl DataManager { .get_userblock_by_initiator_receiver(post.owner, user.id) .await .is_ok() - | self + | (self .get_user_stack_blocked_users(post.owner) .await - .contains(&user.id)) + .contains(&user.id) + | self + .get_ipblock_by_initiator_receiver(post.owner, &addr) + .await + .is_ok())) && !user.permissions.check(FinePermission::MANAGE_POSTS) { return Err(Error::NotAllowed); @@ -152,26 +162,26 @@ impl DataManager { // achievements if user.id != post.owner { let mut owner = self.get_user_by_id(post.owner).await?; - self.add_achievement(&mut owner, AchievementName::Get1Like.into()) + self.add_achievement(&mut owner, AchievementName::Get1Like.into(), true) .await?; if post.likes >= 9 { - self.add_achievement(&mut owner, AchievementName::Get10Likes.into()) + self.add_achievement(&mut owner, AchievementName::Get10Likes.into(), true) .await?; } if post.likes >= 49 { - self.add_achievement(&mut owner, AchievementName::Get50Likes.into()) + self.add_achievement(&mut owner, AchievementName::Get50Likes.into(), true) .await?; } if post.likes >= 99 { - self.add_achievement(&mut owner, AchievementName::Get100Likes.into()) + self.add_achievement(&mut owner, AchievementName::Get100Likes.into(), true) .await?; } if post.dislikes >= 24 { - self.add_achievement(&mut owner, AchievementName::Get25Dislikes.into()) + self.add_achievement(&mut owner, AchievementName::Get25Dislikes.into(), true) .await?; } } diff --git a/crates/core/src/database/services.rs b/crates/core/src/database/services.rs new file mode 100644 index 0000000..adc9bc6 --- /dev/null +++ b/crates/core/src/database/services.rs @@ -0,0 +1,134 @@ +use crate::model::{ + auth::User, + littleweb::{Service, ServiceFsEntry}, + permissions::{FinePermission, SecondaryPermission}, + Error, Result, +}; +use crate::{auto_method, DataManager}; +use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow}; + +impl DataManager { + /// Get a [`Service`] from an SQL row. + pub(crate) fn get_service_from_row(x: &PostgresRow) -> Service { + Service { + 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)), + files: serde_json::from_str(&get!(x->4(String))).unwrap(), + revision: get!(x->5(i64)) as usize, + } + } + + auto_method!(get_service_by_id(usize as i64)@get_service_from_row -> "SELECT * FROM services WHERE id = $1" --name="service" --returns=Service --cache-key-tmpl="atto.service:{}"); + + /// Get all services by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch services for + pub async fn get_services_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 services WHERE owner = $1 ORDER BY created DESC", + &[&(id as i64)], + |x| { Self::get_service_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("service".to_string())); + } + + Ok(res.unwrap()) + } + + const MAXIMUM_FREE_SERVICES: usize = 10; + + /// Create a new service in the database. + /// + /// # Arguments + /// * `data` - a mock [`Service`] object to insert + pub async fn create_service(&self, data: Service) -> 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 services + let owner = self.get_user_by_id(data.owner).await?; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + let services = self.get_services_by_user(data.owner).await?; + + if services.len() >= Self::MAXIMUM_FREE_SERVICES { + return Err(Error::MiscError( + "You already have the maximum number of services 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 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) + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_service(&self, id: usize, user: &User) -> Result<()> { + let service = self.get_service_by_id(id).await?; + + // check user permission + if user.id != service.owner + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_SERVICES) + { + 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 services WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... + self.0.1.remove(format!("atto.service:{}", id)).await; + Ok(()) + } + + 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 46a3e30..cea2be9 100644 --- a/crates/core/src/database/stacks.rs +++ b/crates/core/src/database/stacks.rs @@ -56,7 +56,7 @@ impl DataManager { match stack.sort { StackSort::Created => { self.fill_posts_with_community( - self.get_latest_posts(batch, page, &user).await?, + self.get_latest_posts(batch, page, &user, 0).await?, as_user_id, &ignore_users, user, @@ -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(), )); @@ -245,10 +247,10 @@ impl DataManager { Ok(()) } - auto_method!(update_stack_name(&str)@get_stack_by_id:MANAGE_STACKS -> "UPDATE stacks SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.stack:{}"); - auto_method!(update_stack_users(Vec)@get_stack_by_id:MANAGE_STACKS -> "UPDATE stacks SET users = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); + auto_method!(update_stack_name(&str)@get_stack_by_id:FinePermission::MANAGE_STACKS; -> "UPDATE stacks SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.stack:{}"); + auto_method!(update_stack_users(Vec)@get_stack_by_id:FinePermission::MANAGE_STACKS; -> "UPDATE stacks SET users = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); - auto_method!(update_stack_privacy(StackPrivacy)@get_stack_by_id:MANAGE_STACKS -> "UPDATE stacks SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); - auto_method!(update_stack_mode(StackMode)@get_stack_by_id:MANAGE_STACKS -> "UPDATE stacks SET mode = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); - auto_method!(update_stack_sort(StackSort)@get_stack_by_id:MANAGE_STACKS -> "UPDATE stacks SET sort = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); + auto_method!(update_stack_privacy(StackPrivacy)@get_stack_by_id:FinePermission::MANAGE_STACKS; -> "UPDATE stacks SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); + auto_method!(update_stack_mode(StackMode)@get_stack_by_id:FinePermission::MANAGE_STACKS; -> "UPDATE stacks SET mode = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); + auto_method!(update_stack_sort(StackSort)@get_stack_by_id:FinePermission::MANAGE_STACKS; -> "UPDATE stacks SET sort = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); } 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/userblocks.rs b/crates/core/src/database/userblocks.rs index ac95cab..3cdaaf9 100644 --- a/crates/core/src/database/userblocks.rs +++ b/crates/core/src/database/userblocks.rs @@ -85,7 +85,21 @@ impl DataManager { } /// Get the receiver of all user blocks for the given `initiator`. - pub async fn get_userblocks_receivers(&self, initiator: usize) -> Vec { + pub async fn get_userblocks_receivers( + &self, + initiator: usize, + associated: &Vec, + do_associated: bool, + ) -> Vec { + let mut associated_str = String::new(); + + if do_associated { + for id in associated { + associated_str.push_str(&(" OR initiator = ".to_string() + &id.to_string())); + } + } + + // ... let conn = match self.0.connect().await { Ok(c) => c, Err(_) => return Vec::new(), @@ -93,7 +107,7 @@ impl DataManager { let res = query_rows!( &conn, - "SELECT * FROM userblocks WHERE initiator = $1", + &format!("SELECT * FROM userblocks WHERE initiator = $1{associated_str}"), &[&(initiator as i64)], |x| { Self::get_userblock_from_row(x) } ); diff --git a/crates/core/src/database/userfollows.rs b/crates/core/src/database/userfollows.rs index 3409443..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) @@ -262,8 +284,52 @@ impl DataManager { // check if we're staff if initiator.permissions.check(FinePermission::STAFF_BADGE) { - self.add_achievement(&mut other_user, AchievementName::FollowedByStaff.into()) - .await?; + self.add_achievement( + &mut other_user, + AchievementName::FollowedByStaff.into(), + true, + ) + .await?; + } + + // other achivements + self.add_achievement(&mut other_user, AchievementName::Get1Follower.into(), true) + .await?; + + if other_user.follower_count >= 9 { + self.add_achievement( + &mut other_user, + AchievementName::Get10Followers.into(), + true, + ) + .await?; + } + + if other_user.follower_count >= 49 { + self.add_achievement( + &mut other_user, + AchievementName::Get50Followers.into(), + true, + ) + .await?; + } + + if other_user.follower_count >= 99 { + self.add_achievement( + &mut other_user, + AchievementName::Get100Followers.into(), + true, + ) + .await?; + } + + if initiator.following_count >= 9 { + self.add_achievement( + &mut initiator.clone(), + AchievementName::Follow10Users.into(), + true, + ) + .await?; } } @@ -334,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 11f015f..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}, @@ -61,6 +60,38 @@ pub struct User { /// Users collect achievements through little actions across the site. #[serde(default)] pub achievements: Vec, + /// If the account was registered as a "bought" account, the user should not + /// be allowed to actually use the account if they haven't paid for supporter yet. + #[serde(default)] + pub awaiting_purchase: bool, + /// This value cannot be changed after account creation. This value is used to + /// lock the user's account again if the subscription is cancelled and they haven't + /// 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 = @@ -115,6 +146,20 @@ impl DefaultTimelineChoice { } } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum DefaultProfileTabChoice { + /// General posts (in any community) from the user. + Posts, + /// Responses to questions. + Responses, +} + +impl Default for DefaultProfileTabChoice { + fn default() -> Self { + Self::Posts + } +} + #[derive(Clone, Debug, Serialize, Deserialize, Default)] pub struct UserSettings { #[serde(default)] @@ -192,6 +237,15 @@ pub struct UserSettings { /// Custom CSS input. #[serde(default)] pub theme_custom_css: String, + /// The color of an online online indicator. + #[serde(default)] + pub theme_color_online: String, + /// The color of an idle online indicator. + #[serde(default)] + pub theme_color_idle: String, + /// The color of an offline online indicator. + #[serde(default)] + pub theme_color_offline: String, #[serde(default)] pub disable_other_themes: bool, #[serde(default)] @@ -261,6 +315,40 @@ pub struct UserSettings { /// Increase the text size of buttons and paragraphs. #[serde(default)] pub large_text: bool, + /// Disable achievements. + #[serde(default)] + pub disable_achievements: bool, + /// Automatically hide users that you've blocked on your other accounts from your timelines. + #[serde(default)] + pub hide_associated_blocked_users: bool, + /// 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 { @@ -304,6 +392,13 @@ impl User { invite_code: 0, secondary_permissions: SecondaryPermission::DEFAULT, 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, } } @@ -478,7 +573,15 @@ pub struct ExternalConnectionData { } /// The total number of achievements needed to 100% Tetratto! -pub const ACHIEVEMENTS: usize = 16; +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, + AchievementName::OpenTos, + AchievementName::OpenPrivacyPolicy, + AchievementName::AcceptProfileWarning, + AchievementName::OpenSessionSettings, +]; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum AchievementName { @@ -498,6 +601,26 @@ pub enum AchievementName { Get50Likes, Get100Likes, Get25Dislikes, + Get1Follower, + Get10Followers, + Get50Followers, + Get100Followers, + Follow10Users, + JoinCommunity, + CreateDraft, + EditPost, + Enable2fa, + EditNote, + CreatePostWithTitle, + CreateRepost, + OpenTos, + OpenPrivacyPolicy, + OpenReference, + GetAllOtherAchievements, + AcceptProfileWarning, + OpenSessionSettings, + CreateSite, + CreateDomain, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -526,6 +649,26 @@ impl AchievementName { Self::Get50Likes => "banger post follow for more", Self::Get100Likes => "everyone liked that", Self::Get25Dislikes => "Sorry...", + Self::Get1Follower => "Friends?", + Self::Get10Followers => "Friends!", + Self::Get50Followers => "50 WHOLE FOLLOWERS??", + Self::Get100Followers => "Everyone is my friend!", + Self::Follow10Users => "Big fan", + Self::JoinCommunity => "A sense of community...", + Self::CreateDraft => "Maybe later!", + Self::EditPost => "Grammar police?", + Self::Enable2fa => "Locked in", + Self::EditNote => "I take it back!", + Self::CreatePostWithTitle => "Must declutter", + Self::CreateRepost => "More than a like or comment...", + Self::OpenTos => "Well informed!", + Self::OpenPrivacyPolicy => "Privacy conscious", + Self::OpenReference => "What does this do?", + Self::GetAllOtherAchievements => "The final performance", + Self::AcceptProfileWarning => "I accept the risks!", + Self::OpenSessionSettings => "Am I alone in here?", + Self::CreateSite => "Littlewebmaster", + Self::CreateDomain => "LittleDNS", } } @@ -547,6 +690,26 @@ impl AchievementName { Self::Get50Likes => "Get 50 likes on one post.", Self::Get100Likes => "Get 100 likes on one post.", Self::Get25Dislikes => "Get 25 dislikes on one post... :(", + Self::Get1Follower => "Get 1 follower. Cool!", + Self::Get10Followers => "Get 10 followers. You're getting popular!", + Self::Get50Followers => "Get 50 followers. Okay, you're fairly popular!", + Self::Get100Followers => "Get 100 followers. You might be famous..?", + Self::Follow10Users => "Follow 10 other users. I'm sure people appreciate it!", + Self::JoinCommunity => "Join a community. Welcome!", + Self::CreateDraft => "Save a post as a draft.", + Self::EditPost => "Edit a post.", + Self::Enable2fa => "Enable TOTP 2FA.", + Self::EditNote => "Edit a note.", + Self::CreatePostWithTitle => "Create a post with a title.", + Self::CreateRepost => "Create a repost or quote.", + Self::OpenTos => "Open the terms of service.", + Self::OpenPrivacyPolicy => "Open the privacy policy.", + Self::OpenReference => "Open the source code reference documentation.", + 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.", } } @@ -570,6 +733,26 @@ impl AchievementName { Self::Get50Likes => Uncommon, Self::Get100Likes => Rare, Self::Get25Dislikes => Uncommon, + Self::Get1Follower => Common, + Self::Get10Followers => Common, + Self::Get50Followers => Uncommon, + Self::Get100Followers => Rare, + Self::Follow10Users => Common, + Self::JoinCommunity => Common, + Self::CreateDraft => Common, + Self::EditPost => Common, + Self::Enable2fa => Rare, + Self::EditNote => Uncommon, + Self::CreatePostWithTitle => Common, + Self::CreateRepost => Common, + Self::OpenTos => Uncommon, + Self::OpenPrivacyPolicy => Uncommon, + Self::OpenReference => Uncommon, + 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 7b1957f..14f640f 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; use super::communities_permissions::CommunityPermission; -#[derive(Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Community { pub id: usize, pub created: usize, @@ -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, } } } @@ -381,6 +384,12 @@ impl Question { pub struct QuestionContext { #[serde(default)] pub is_nsfw: bool, + /// 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/littleweb.rs b/crates/core/src/model/littleweb.rs new file mode 100644 index 0000000..f06154d --- /dev/null +++ b/crates/core/src/model/littleweb.rs @@ -0,0 +1,294 @@ +use std::fmt::Display; +use serde::{Serialize, Deserialize}; +use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; +use paste::paste; +use std::sync::LazyLock; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Service { + pub id: usize, + pub created: usize, + pub owner: usize, + pub name: String, + pub files: Vec, + pub revision: usize, +} + +impl Service { + /// Create a new [`Service`]. + pub fn new(name: String, owner: usize) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + owner, + name, + files: Vec::new(), + revision: unix_epoch_timestamp(), + } + } + + /// Resolve a file from the virtual file system. + /// + /// # Returns + /// `(file, id path)` + pub fn file(&self, path: &str) -> Option<(ServiceFsEntry, Vec)> { + let segments = path.chars().filter(|x| x == &'/').count(); + + let mut path = path.split("/"); + let mut path_segment = path.next().unwrap(); + let mut ids = Vec::new(); + let mut i = 0; + + let mut f = &self.files; + + while let Some(nf) = f.iter().find(|x| x.name == path_segment) { + ids.push(nf.id.clone()); + + if i == segments { + return Some((nf.to_owned(), ids)); + } + + f = &nf.children; + path_segment = path.next().unwrap(); + i += 1; + } + + None + } + + /// Resolve a file from the virtual file system (mutable). + /// + /// # Returns + /// `&mut file` + pub fn file_mut(&mut self, id_path: Vec) -> Option<&mut ServiceFsEntry> { + let total_segments = id_path.len(); + let mut i = 0; + + let mut f = &mut self.files; + for segment in id_path { + if let Some(nf) = f.iter_mut().find(|x| (**x).id == segment) { + if i == total_segments - 1 { + return Some(nf); + } + + f = &mut nf.children; + i += 1; + } else { + break; + } + } + + None + } +} + +/// A file type for [`ServiceFsEntry`] structs. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ServiceFsMime { + #[serde(alias = "text/html")] + Html, + #[serde(alias = "text/css")] + Css, + #[serde(alias = "text/javascript")] + Js, + #[serde(alias = "application/json")] + Json, + #[serde(alias = "text/plain")] + Plain, +} + +impl Display for ServiceFsMime { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Html => "text/html", + Self::Css => "text/css", + Self::Js => "text/javascript", + Self::Json => "application/json", + Self::Plain => "text/plain", + }) + } +} + +/// A single entry in the file system of [`Service`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServiceFsEntry { + /// Files use a UUID since they're generated on the client. + pub id: String, + pub name: String, + pub mime: ServiceFsMime, + pub children: Vec, + pub content: String, +} + +macro_rules! domain_tld_display_match { + ($self:ident, $($tld:ident),+ $(,)?) => { + match $self { + $( + Self::$tld => stringify!($tld).to_lowercase(), + )+ + } + } +} + +macro_rules! domain_tld_strings { + ($($tld:ident),+ $(,)?) => { + $( + paste! { + /// Constant from macro. + const []: LazyLock = LazyLock::new(|| stringify!($tld).to_lowercase()); + } + )+ + } +} + +macro_rules! domain_tld_from_match { + ($value:ident, $($tld:ident),+ $(,)?) => { + { + $( + paste! { + let [<$tld:snake:lower>] = &*[]; + } + )+; + + // can't use match here, the expansion is going to look really ugly + $( + if $value == paste!{ [<$tld:snake:lower>] } { + return Self::$tld; + } + )+ + + return Self::Bunny; + } + } +} + +macro_rules! define_domain_tlds { + ($($tld:ident),+ $(,)?) => { + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] + pub enum DomainTld { + $($tld),+ + } + + domain_tld_strings!($($tld),+); + + impl From<&str> for DomainTld { + fn from(value: &str) -> Self { + domain_tld_from_match!( + value, $($tld),+ + ) + } + } + + impl Display for DomainTld { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // using this macro allows us to just copy and paste the enum variants + f.write_str(&domain_tld_display_match!( + self, $($tld),+ + )) + } + } + + /// This is VERY important so that I don't have to manually type them all for the UI dropdown. + pub const TLDS_VEC: LazyLock> = LazyLock::new(|| vec![$(stringify!($tld)),+]); + } +} + +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, All, + Me, Bug, Slop, Retro, Eye, Neo, Spring, Nurse, Pony +); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Domain { + pub id: usize, + pub created: usize, + pub owner: usize, + pub name: String, + pub tld: DomainTld, + /// Data about the domain. This can only be configured by the domain's owner. + /// + /// Maximum of 4 entries. Stored in a structure of `(subdomain string, data)`. + pub data: Vec<(String, DomainData)>, +} + +impl Domain { + /// Create a new [`Domain`]. + pub fn new(name: String, tld: DomainTld, owner: usize) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + owner, + name, + tld, + data: Vec::new(), + } + } + + /// Get the domain's subdomain, name, TLD, and path segments from a string. + /// + /// If no subdomain is provided, the subdomain will be "@". This means that + /// domain data entries should use "@" as the root service. + pub fn from_str(value: &str) -> (String, String, DomainTld, String) { + let no_protocol = value.replace("atto://", ""); + + // we're reversing this so it's predictable, as there might not always be a subdomain + // (we shouldn't have the variable entry be first, there is always going to be a tld) + let mut s: Vec<&str> = no_protocol.split("/").next().unwrap().split(".").collect(); + s.reverse(); + let mut s = s.into_iter(); + + let tld = DomainTld::from(s.next().unwrap()); + let domain = s.next().unwrap_or("default.bunny"); + let subdomain = s.next().unwrap_or("@"); + + // get path + let mut chars = no_protocol.chars(); + let mut char = '.'; + + while char != '/' { + // we need to keep eating characters until we reach the first / + // (marking the start of the path) + char = chars.next().unwrap_or('/'); + } + + let path: String = chars.collect(); + + // return + (subdomain.to_owned(), domain.to_owned(), tld, path) + } + + /// Update an HTML/JS/CSS string with the correct URL for all "atto://" protocol requests. + /// + /// This would not be needed if the JS custom protocol API wasn't awful. + pub fn http_assets(input: String) -> String { + // this is served over the littleweb api NOT the main api! + // + // littleweb requests MUST be on another subdomain so cookies are + // not shared with custom user HTML (since users can embed JS which can make POST requests) + // + // the littleweb routes are used by providing the "LITTLEWEB" env var + input.replace("\"atto://", "/api/v1/file?addr=atto://") + } + + /// Get the domain's service ID. + pub fn service(&self, subdomain: &str) -> Option { + let s = self.data.iter().find(|x| x.0 == subdomain)?; + match s.1 { + DomainData::Service(ref id) => Some(match id.parse::() { + Ok(id) => id, + Err(_) => return None, + }), + _ => None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum DomainData { + /// The ID of the service this domain points to. The first service found will + /// always be used. This means having multiple service entires will be useless. + Service(String), + /// A text entry with a maximum of 512 characters. + Text(String), +} 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 839310f..06c4149 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -6,9 +6,12 @@ pub mod channels; pub mod communities; pub mod communities_permissions; pub mod journals; +pub mod littleweb; +pub mod mail; pub mod moderation; pub mod oauth; pub mod permissions; +pub mod products; pub mod reactions; pub mod requests; pub mod socket; @@ -49,6 +52,7 @@ pub enum Error { QuestionsDisabled, RequiresSupporter, DrawingsDisabled, + AppHitStorageLimit, Unknown, } @@ -73,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 e783a1e..aa0e00a 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -68,8 +68,14 @@ pub enum AppScope { UserReadJournals, /// Read the user's notes. UserReadNotes, - /// Read the user's links. - UserReadLinks, + /// Read the user's layouts. + UserReadLayouts, + /// Read the user's domains. + UserReadDomains, + /// Read the user's services. + UserReadServices, + /// Read the user's products. + UserReadProducts, /// Create posts as the user. UserCreatePosts, /// Create messages as the user. @@ -88,8 +94,14 @@ pub enum AppScope { UserCreateJournals, /// Create notes on behalf of the user. UserCreateNotes, - /// Create links on behalf of the user. - UserCreateLinks, + /// Create layouts on behalf of the user. + UserCreateLayouts, + /// Create domains on behalf of the user. + 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. @@ -124,8 +136,16 @@ pub enum AppScope { UserManageJournals, /// Manage the user's notes. UserManageNotes, - /// Manage the user's links. - UserManageLinks, + /// Manage the user's layouts. + UserManageLayouts, + /// Manage the user's domains. + 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 1584083..61ebb61 100644 --- a/crates/core/src/model/permissions.rs +++ b/crates/core/src/model/permissions.rs @@ -174,6 +174,11 @@ bitflags! { pub struct SecondaryPermission: u32 { const DEFAULT = 1 << 0; 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 1b3e5e1..5993ffc 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -1,12 +1,14 @@ [package] name = "tetratto-l10n" -version = "10.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 5fd4230..a866bd1 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -1,18 +1,20 @@ [package] name = "tetratto-shared" -version = "10.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 1ae665b..022e23d 100644 --- a/crates/shared/src/markdown.rs +++ b/crates/shared/src/markdown.rs @@ -1,35 +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, - 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("