diff --git a/Cargo.lock b/Cargo.lock index 1126bca..a11c634 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -787,18 +787,6 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4443176a9f2c162692bd3d352d745ef9413eec5782a80d8fd6f8a1ac692a07f7" -[[package]] -name = "fallible-iterator" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" - -[[package]] -name = "fallible-streaming-iterator" -version = "0.1.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" - [[package]] name = "fastrand" version = "1.9.0" @@ -839,12 +827,6 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" -[[package]] -name = "foldhash" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" - [[package]] name = "foreign-types" version = "0.3.2" @@ -1112,18 +1094,6 @@ name = "hashbrown" version = "0.15.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5971ac85611da7067dbfcabef3c70ebb5606018acd9e2a3903a0da507521e0d5" -dependencies = [ - "foldhash", -] - -[[package]] -name = "hashlink" -version = "0.10.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" -dependencies = [ - "hashbrown", -] [[package]] name = "heck" @@ -1697,16 +1667,6 @@ version = "0.2.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8355be11b20d696c8f18f6cc018c4e372165b1fa8126cef092399c9951984ffa" -[[package]] -name = "libsqlite3-sys" -version = "0.34.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91632f3b4fb6bd1d72aa3d78f41ffecfcf2b1a6648d8c241dbe7dbfaf4875e15" -dependencies = [ - "pkg-config", - "vcpkg", -] - [[package]] name = "libwebp-sys" version = "0.9.6" @@ -2033,7 +1993,6 @@ checksum = "99b097052e28781d560587373845626a85460969a55d180fc418aecd58f6fef3" dependencies = [ "bb8-postgres", "redis", - "rusqlite", "serde", "tokio-postgres", ] @@ -2290,7 +2249,7 @@ dependencies = [ "base64 0.22.1", "byteorder", "bytes", - "fallible-iterator 0.2.0", + "fallible-iterator", "hmac", "md-5", "memchr", @@ -2306,7 +2265,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613283563cd90e1dfc3518d548caee47e0e725455ed619881f5cf21f36de4b48" dependencies = [ "bytes", - "fallible-iterator 0.2.0", + "fallible-iterator", "postgres-protocol", ] @@ -2662,9 +2621,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "reqwest" -version = "0.12.19" +version = "0.12.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2f8e5513d63f2e5b386eb5106dc67eaf3f84e95258e210489136b8b92ad6119" +checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813" dependencies = [ "base64 0.22.1", "bytes", @@ -2679,12 +2638,10 @@ dependencies = [ "hyper-rustls", "hyper-tls 0.6.0", "hyper-util", - "ipnet", "js-sys", "log", "mime", "native-tls", - "once_cell", "percent-encoding", "pin-project-lite", "rustls-pki-types", @@ -2725,20 +2682,6 @@ dependencies = [ "windows-sys 0.52.0", ] -[[package]] -name = "rusqlite" -version = "0.36.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3de23c3319433716cf134eed225fe9986bc24f63bed9be9f20c329029e672dc7" -dependencies = [ - "bitflags 2.9.1", - "fallible-iterator 0.3.0", - "fallible-streaming-iterator", - "hashlink", - "libsqlite3-sys", - "smallvec", -] - [[package]] name = "rustc-demangle" version = "0.1.24" @@ -3288,7 +3231,7 @@ dependencies = [ [[package]] name = "tetratto" -version = "7.0.0" +version = "9.0.0" dependencies = [ "ammonia", "async-stripe", @@ -3319,7 +3262,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "7.0.0" +version = "9.0.0" dependencies = [ "async-recursion", "base16ct", @@ -3341,7 +3284,7 @@ dependencies = [ [[package]] name = "tetratto-l10n" -version = "7.0.0" +version = "9.0.0" dependencies = [ "pathbufd", "serde", @@ -3350,7 +3293,7 @@ dependencies = [ [[package]] name = "tetratto-shared" -version = "7.0.0" +version = "9.0.0" dependencies = [ "ammonia", "chrono", @@ -3527,7 +3470,7 @@ dependencies = [ "async-trait", "byteorder", "bytes", - "fallible-iterator 0.2.0", + "fallible-iterator", "futures-channel", "futures-util", "log", diff --git a/README.md b/README.md index 77155e8..e1ac999 100644 --- a/README.md +++ b/README.md @@ -10,10 +10,10 @@ Make sure you have AT LEAST rustc version 1.89.0-nightly. Everything Tetratto needs will be built into the main binary. You can build Tetratto with the following command: ```bash -cargo build -r --no-default-features --features=redis,sqlite +cargo build -r ``` -You can replace `sqlite` in the above command with `postgres`, if you'd like. Redis (or a Redis fork) is required for features such as chats and (realtime) notifications! +Tetratto **requires** a PostgreSQL server, as well as a Redis (or Redis fork) instance. You can then take the binary and place it somewhere else (highly recommended; the binary will create a fair number of files!). You can do this to move it to a directory just called "tetratto" in the parent directory: @@ -33,6 +33,8 @@ Tetratto **requires** Cloudflare Turnstile for registrations. Testing keys are l A `docs` directory will be generated in the same directory that you ran the `tetratto` binary in. **Markdown** files placed here will be served at `/doc/{*file_name}`. For other types of assets, you can place them in the generated `public` directory. This directory serves everything at `/public/{*file_name}`. +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. + ## 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 a575e9d..e29dcb9 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,33 +1,25 @@ [package] name = "tetratto" -version = "7.0.0" +version = "9.0.0" edition = "2024" -[features] -postgres = ["tetratto-core/postgres"] -sqlite = ["tetratto-core/sqlite"] -redis = ["tetratto-core/redis"] -default = ["sqlite", "redis"] - [dependencies] pathbufd = "0.1.4" 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"] } +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"] } axum-extra = { version = "0.10.1", features = ["cookie", "multipart"] } ammonia = "4.1.0" tetratto-shared = { path = "../shared" } -tetratto-core = { path = "../core", features = [ - "redis", -], default-features = false } +tetratto-core = { path = "../core" } tetratto-l10n = { path = "../l10n" } image = "0.25.6" -reqwest = { version = "0.12.19", features = ["json", "stream"] } +reqwest = { version = "0.12.20", features = ["json", "stream"] } regex = "1.11.1" serde_json = "1.0.140" mime_guess = "2.0.5" diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 45f40f1..3958f09 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -17,7 +17,6 @@ use tetratto_core::{ auth::{DefaultTimelineChoice, User}, permissions::FinePermission, }, - PUBSUB_ENABLED, }; use tetratto_l10n::LangFile; use tetratto_shared::hash::salt; @@ -33,12 +32,14 @@ pub const FAVICON: &str = include_str!("./public/images/favicon.svg"); pub const STYLE_CSS: &str = include_str!("./public/css/style.css"); pub const ROOT_CSS: &str = include_str!("./public/css/root.css"); pub const UTILITY_CSS: &str = include_str!("./public/css/utility.css"); +pub const CHATS_CSS: &str = include_str!("./public/css/chats.css"); // js pub const LOADER_JS: &str = include_str!("./public/js/loader.js"); 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"); // html pub const BODY: &str = include_str!("./public/html/body.lisp"); @@ -50,6 +51,7 @@ pub const MISC_ERROR: &str = include_str!("./public/html/misc/error.lisp"); pub const MISC_NOTIFICATIONS: &str = include_str!("./public/html/misc/notifications.lisp"); pub const MISC_MARKDOWN: &str = include_str!("./public/html/misc/markdown.lisp"); pub const MISC_REQUESTS: &str = include_str!("./public/html/misc/requests.lisp"); +pub const MISC_ACHIEVEMENTS: &str = include_str!("./public/html/misc/achievements.lisp"); pub const AUTH_BASE: &str = include_str!("./public/html/auth/base.lisp"); pub const AUTH_LOGIN: &str = include_str!("./public/html/auth/login.lisp"); @@ -98,6 +100,7 @@ pub const TIMELINES_FOLLOWING_QUESTIONS: &str = pub const TIMELINES_ALL_QUESTIONS: &str = include_str!("./public/html/timelines/all_questions.lisp"); pub const TIMELINES_SEARCH: &str = include_str!("./public/html/timelines/search.lisp"); +pub const TIMELINES_SWISS_ARMY: &str = include_str!("./public/html/timelines/swiss_army.lisp"); pub const MOD_AUDIT_LOG: &str = include_str!("./public/html/mod/audit_log.lisp"); pub const MOD_REPORTS: &str = include_str!("./public/html/mod/reports.lisp"); @@ -113,8 +116,9 @@ pub const CHATS_MESSAGE: &str = include_str!("./public/html/chats/message.lisp") pub const CHATS_CHANNELS: &str = include_str!("./public/html/chats/channels.lisp"); pub const STACKS_LIST: &str = include_str!("./public/html/stacks/list.lisp"); -pub const STACKS_POSTS: &str = include_str!("./public/html/stacks/posts.lisp"); +pub const STACKS_FEED: &str = include_str!("./public/html/stacks/feed.lisp"); pub const STACKS_MANAGE: &str = include_str!("./public/html/stacks/manage.lisp"); +pub const STACKS_ADD_USER: &str = include_str!("./public/html/stacks/add_user.lisp"); pub const FORGE_HOME: &str = include_str!("./public/html/forge/home.lisp"); pub const FORGE_BASE: &str = include_str!("./public/html/forge/base.lisp"); @@ -125,6 +129,8 @@ pub const DEVELOPER_HOME: &str = include_str!("./public/html/developer/home.lisp pub const DEVELOPER_APP: &str = include_str!("./public/html/developer/app.lisp"); pub const DEVELOPER_LINK: &str = include_str!("./public/html/developer/link.lisp"); +pub const JOURNALS_APP: &str = include_str!("./public/html/journals/app.lisp"); + // langs pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); @@ -150,7 +156,7 @@ pub(crate) async fn pull_icon(icon: &str, icons_dir: &str) { "https://raw.githubusercontent.com/lucide-icons/lucide/refs/heads/main/icons/{icon}.svg" ); - let file_path = PathBufD::current().extend(&[icons_dir, icon]); + 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()); @@ -175,7 +181,8 @@ macro_rules! vendor_icon { let writer = &mut ICONS.write().await; writer.insert($name.to_string(), $icon.to_string()); - let file_path = PathBufD::current().extend(&[$icons_dir.clone(), $name.to_string()]); + let file_path = + PathBufD::current().extend(&[$icons_dir.clone(), format!("{}.svg", $name.to_string())]); std::fs::write(file_path, $icon).unwrap(); }}; } @@ -343,6 +350,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"misc/notifications.html"(crate::assets::MISC_NOTIFICATIONS) --config=config --lisp plugins); write_template!(html_path->"misc/markdown.html"(crate::assets::MISC_MARKDOWN) --config=config --lisp plugins); write_template!(html_path->"misc/requests.html"(crate::assets::MISC_REQUESTS) --config=config --lisp plugins); + write_template!(html_path->"misc/achievements.html"(crate::assets::MISC_ACHIEVEMENTS) --config=config --lisp plugins); write_template!(html_path->"auth/base.html"(crate::assets::AUTH_BASE) -d "auth" --config=config --lisp plugins); write_template!(html_path->"auth/login.html"(crate::assets::AUTH_LOGIN) --config=config --lisp plugins); @@ -386,6 +394,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"timelines/following_questions.html"(crate::assets::TIMELINES_FOLLOWING_QUESTIONS) --config=config --lisp plugins); write_template!(html_path->"timelines/all_questions.html"(crate::assets::TIMELINES_ALL_QUESTIONS) --config=config --lisp plugins); write_template!(html_path->"timelines/search.html"(crate::assets::TIMELINES_SEARCH) --config=config --lisp plugins); + write_template!(html_path->"timelines/swiss_army.html"(crate::assets::TIMELINES_SWISS_ARMY) --config=config --lisp plugins); write_template!(html_path->"mod/audit_log.html"(crate::assets::MOD_AUDIT_LOG) -d "mod" --config=config --lisp plugins); write_template!(html_path->"mod/reports.html"(crate::assets::MOD_REPORTS) --config=config --lisp plugins); @@ -401,8 +410,9 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"chats/channels.html"(crate::assets::CHATS_CHANNELS) --config=config --lisp plugins); write_template!(html_path->"stacks/list.html"(crate::assets::STACKS_LIST) -d "stacks" --config=config --lisp plugins); - write_template!(html_path->"stacks/posts.html"(crate::assets::STACKS_POSTS) --config=config --lisp plugins); + write_template!(html_path->"stacks/feed.html"(crate::assets::STACKS_FEED) --config=config --lisp plugins); write_template!(html_path->"stacks/manage.html"(crate::assets::STACKS_MANAGE) --config=config --lisp plugins); + write_template!(html_path->"stacks/add_user.html"(crate::assets::STACKS_ADD_USER) --config=config --lisp plugins); write_template!(html_path->"forge/home.html"(crate::assets::FORGE_HOME) -d "forge" --config=config --lisp plugins); write_template!(html_path->"forge/base.html"(crate::assets::FORGE_BASE) --config=config --lisp plugins); @@ -413,6 +423,8 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"developer/app.html"(crate::assets::DEVELOPER_APP) --config=config --lisp plugins); write_template!(html_path->"developer/link.html"(crate::assets::DEVELOPER_LINK) --config=config --lisp plugins); + write_template!(html_path->"journals/app.html"(crate::assets::JOURNALS_APP) -d "journals" --config=config --lisp plugins); + html_path } @@ -468,7 +480,7 @@ pub(crate) async fn initial_context( ) -> Context { let mut ctx = Context::new(); ctx.insert("config", &config); - ctx.insert("pubsub", &PUBSUB_ENABLED); + ctx.insert("pubsub", &true); ctx.insert("user", &user); ctx.insert("use_user_theme", &true); @@ -488,6 +500,13 @@ pub(crate) async fn initial_context( } ctx.insert("lang", &lang.data); - ctx.insert("random_cache_breaker", &CACHE_BREAKER.clone()); + ctx.insert( + "random_cache_breaker", + &if let Ok(c) = std::env::var("CACHE_BREAKER") { + c + } else { + CACHE_BREAKER.clone() + }, + ); ctx } diff --git a/crates/app/src/image.rs b/crates/app/src/image.rs index 75b231c..a6fd32e 100644 --- a/crates/app/src/image.rs +++ b/crates/app/src/image.rs @@ -127,11 +127,8 @@ where Err(_) => return Err((StatusCode::BAD_REQUEST, "json data isn't utf8".to_string())), }) { Ok(s) => s, - Err(_) => { - return Err(( - StatusCode::BAD_REQUEST, - "could not parse json data as json".to_string(), - )); + Err(e) => { + return Err((StatusCode::BAD_REQUEST, e.to_string())); } }; diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index d32ef2e..13b6b64 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -16,6 +16,8 @@ version = "1.0.0" "general:link.ip_bans" = "IP bans" "general:link.stats" = "Stats" "general:link.search" = "Search" +"general:link.journals" = "Journals" +"general:link.achievements" = "Achievements" "general:action.save" = "Save" "general:action.delete" = "Delete" "general:action.purge" = "Purge" @@ -38,6 +40,9 @@ version = "1.0.0" "general:label.account_banned" = "Account banned" "general:label.account_banned_body" = "Your account has been banned for violating our policies." "general:label.better_with_account" = "It's better with an account! Login or sign up to explore more." +"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.supporter_motivation" = "Become a supporter!" "general:action.become_supporter" = "Become supporter" @@ -131,6 +136,8 @@ version = "1.0.0" "communities:label.file" = "File" "communities:label.drafts" = "Drafts" "communities:label.load" = "Load" +"communities:action.draw" = "Draw" +"communities:action.remove_drawing" = "Remove drawing" "notifs:action.mark_as_read" = "Mark as read" "notifs:action.mark_as_unread" = "Mark as unread" @@ -162,10 +169,13 @@ version = "1.0.0" "settings:label.export" = "Export" "settings:label.manage_blocks" = "Manage blocks" "settings:label.users" = "Users" +"settings:label.generate_invites" = "Generate invites" +"settings:label.add_to_stack" = "Add to stack" "settings:tab.security" = "Security" "settings:tab.blocks" = "Blocks" "settings:tab.billing" = "Billing" "settings:tab.uploads" = "Uploads" +"settings:tab.invites" = "Invites" "mod_panel:label.open_reported_content" = "Open reported content" "mod_panel:label.manage_profile" = "Manage profile" @@ -173,6 +183,9 @@ version = "1.0.0" "mod_panel:label.warnings" = "Warnings" "mod_panel:label.create_warning" = "Create warning" "mod_panel:label.associations" = "Associations" +"mod_panel:label.invited_by" = "Invited by" +"mod_panel:label.send_debug_payload" = "Send debug payload" +"mod_panel:action.send" = "Send" "requests:label.requests" = "Requests" "requests:label.community_join_request" = "Community join request" @@ -204,6 +217,8 @@ version = "1.0.0" "stacks:tab.users" = "Users" "stacks:label.add_user" = "Add user" "stacks:label.remove" = "Remove" +"stacks:label.block_all" = "Block all" +"stacks:label.unblock_all" = "Unblock all" "forge:label.my_forges" = "My forges" "forge:label.create_new" = "Create new forge" @@ -226,3 +241,22 @@ version = "1.0.0" "developer:label.guides_and_help" = "Guides & help" "developer:action.delete" = "Delete app" "developer:action.authorize" = "Authorize" + +"journals:label.my_journals" = "My journals" +"journals:action.create_journal" = "Create journal" +"journals:action.create_note" = "Create note" +"journals:label.welcome" = "Welcome to Journals!" +"journals:label.select_a_journal" = "Select or create a journal to get started." +"journals:label.select_a_note" = "Select or create a note in this journal to get started." +"journals:label.mobile_click_my_journals" = "Click \"My journals\" at the top to open the sidebar." +"journals:label.editor" = "Editor" +"journals:label.preview_pane" = "Preview" +"journals:action.edit_tags" = "Edit tags" +"journals:action.tags" = "Tags" +"journals:label.directories" = "Directories" +"journals:action.create_subdir" = "Create subdirectory" +"journals:action.create_root_dir" = "Create root directory" +"journals:action.move" = "Move" +"journals:action.publish" = "Publish" +"journals:action.unpublish" = "Unpublish" +"journals:action.view" = "View" diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index a990ee1..01406bb 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -193,7 +193,10 @@ macro_rules! check_user_blocked_or_private { // check if other user is banned if $other_user.permissions.check_banned() { if let Some(ref ua) = $user { - if !ua.permissions.check(FinePermission::MANAGE_USERS) { + if !ua + .permissions + .check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS) + { $crate::user_banned!($user, $other_user, $data, $jar); } } else { @@ -203,12 +206,19 @@ macro_rules! check_user_blocked_or_private { // check if we're blocked if let Some(ref ua) = $user { - if $data + if ($data .0 .get_userblock_by_initiator_receiver($other_user.id, ua.id) .await .is_ok() - && !ua.permissions.check(FinePermission::MANAGE_USERS) + | $data + .0 + .get_user_stack_blocked_users($other_user.id) + .await + .contains(&ua.id)) + && !ua + .permissions + .check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS) { let lang = get_lang!($jar, $data.0); let mut context = initial_context(&$data.0.0.0, lang, &$user).await; @@ -233,7 +243,9 @@ macro_rules! check_user_blocked_or_private { if $other_user.settings.private_profile { if let Some(ref ua) = $user { if (ua.id != $other_user.id) - && !ua.permissions.check(FinePermission::MANAGE_USERS) + && !ua + .permissions + .check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS) && $data .0 .get_userfollow_by_initiator_receiver($other_user.id, ua.id) @@ -291,10 +303,14 @@ macro_rules! check_user_blocked_or_private { ($user:expr, $other_user:ident, $data:ident, @api) => { // check if we're blocked if let Some(ref ua) = $user { - if $data + if ($data .get_userblock_by_initiator_receiver($other_user.id, ua.id) .await .is_ok() + | $data + .get_user_stack_blocked_users($other_user.id) + .await + .contains(&ua.id)) && !ua .permissions .check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS) @@ -338,6 +354,7 @@ macro_rules! ignore_users_gen { [ $data.0.get_userblocks_receivers(ua.id).await, $data.0.get_userblocks_initiator_by_receivers(ua.id).await, + $data.0.get_user_stack_blocked_users(ua.id).await, ] .concat() } else { @@ -345,16 +362,17 @@ macro_rules! ignore_users_gen { } }; - ($user:ident!, $data:ident) => { + ($user:ident!, $data:ident) => {{ [ $data.0.get_userblocks_receivers($user.id).await, $data .0 .get_userblocks_initiator_by_receivers($user.id) .await, + $data.0.get_user_stack_blocked_users($user.id).await, ] .concat() - }; + }}; ($user:ident!, #$data:ident) => { [ diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index b4f188c..52b35be 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -11,12 +11,16 @@ use assets::{init_dirs, write_assets}; use tetratto_core::model::{permissions::FinePermission, uploads::CustomEmoji}; pub use tetratto_core::*; -use axum::{Extension, Router}; +use axum::{ + http::{HeaderName, HeaderValue}, + Extension, Router, +}; use reqwest::Client; use tera::{Tera, Value}; use tower_http::{ - trace::{self, TraceLayer}, catch_panic::CatchPanicLayer, + set_header::SetResponseHeaderLayer, + trace::{self, TraceLayer}, }; use tracing::{Level, info}; @@ -34,6 +38,10 @@ fn render_markdown(value: &Value, _: &HashMap) -> tera::Result) -> tera::Result { + Ok(CustomEmoji::replace(value.as_str().unwrap()).into()) +} + fn color_escape(value: &Value, _: &HashMap) -> tera::Result { Ok(sanitize::color_escape(value.as_str().unwrap()).into()) } @@ -74,7 +82,11 @@ async fn main() { .compact() .init(); - let config = config::Config::get_config(); + let mut config = config::Config::get_config(); + if let Ok(port) = var("PORT") { + let port = port.parse::().expect("port should be a u16"); + config.port = port; + } // init init_dirs(&config).await; @@ -98,6 +110,7 @@ async fn main() { 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(); @@ -115,6 +128,10 @@ 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)) diff --git a/crates/app/src/public/css/chats.css b/crates/app/src/public/css/chats.css new file mode 100644 index 0000000..b98db51 --- /dev/null +++ b/crates/app/src/public/css/chats.css @@ -0,0 +1,232 @@ +:root { + --list-bar-width: 64px; + --channels-bar-width: 256px; + --sidebar-height: calc(100dvh - 42px); + --channel-header-height: 48px; +} + +html, +body { + overflow: hidden; +} + +.name.shortest { + max-width: 165px; + overflow-wrap: normal; +} + +.send_button { + width: 48px; + height: 48px; +} + +.send_button .icon { + width: 2em; + height: 2em; +} + +a.channel_icon { + width: 48px; + height: 48px; + min-height: 48px; +} + +a.channel_icon .icon { + min-width: 24px; + height: 24px; +} + +a.channel_icon.small { + width: 24px; + height: 24px; + min-height: 24px; +} + +a.channel_icon.small .icon { + min-width: 12px; + height: 12px; +} + +a.channel_icon:has(img) { + padding: 0; +} + +a.channel_icon img { + min-width: 48px; + min-height: 48px; +} + +a.channel_icon img, +a.channel_icon:has(.icon) { + transition: + outline 0.25s, + background 0.15s !important; +} + +a.channel_icon:not(.selected):hover img, +a.channel_icon:not(.selected):hover:has(.icon) { + outline: solid 1px var(--color-text); +} +a.channel_icon.selected img, +a.channel_icon.selected:has(.icon) { + outline: solid 2px var(--color-text); +} + +nav { + background: var(--color-raised); + color: var(--color-text-raised) !important; + height: 42px; + position: sticky !important; +} + +nav::after { + display: block; + position: absolute; + background: var(--color-super-lowered); + height: 1px; + width: calc(100% - var(--list-bar-width)); + bottom: 0; + left: var(--list-bar-width); + content: ""; +} + +nav .content_container { + max-width: 100% !important; + width: 100%; +} + +.chats_nav { + display: none; + padding: 0; +} + +.chats_nav button { + justify-content: flex-start; + width: 100% !important; + flex-direction: row !important; + font-size: 16px !important; + margin-top: -4px; +} + +.chats_nav button svg { + margin-right: var(--pad-4); +} + +.sidebar { + background: var(--color-raised); + color: var(--color-text-raised); + border-right: solid 1px var(--color-super-lowered); + padding: 0.4rem; + width: max-content; + height: var(--sidebar-height); + overflow: auto; + transition: left 0.15s; + z-index: 2; +} + +.sidebar .title:not(.dropdown *) { + padding: var(--pad-4); + border-bottom: solid 1px var(--color-super-lowered); +} + +.sidebar#channels_list { + width: var(--channels-bar-width); + background: var(--color-surface); + color: var(--color-text); +} + +.sidebar#notes_list { + width: calc(var(--channels-bar-width) + var(--list-bar-width)); + flex: 1 0 auto; +} + +#stream { + width: calc( + 100dvw - var(--list-bar-width) - var(--channels-bar-width) + ) !important; + height: var(--sidebar-height); +} + +.message { + transition: background 0.15s; + box-shadow: none; + position: relative; +} + +.message:hover { + background: var(--color-raised); +} + +.message:hover .hidden, +.message:focus .hidden, +.message:active .hidden { + display: flex !important; +} + +.message.grouped { + padding: var(--pad-1) var(--pad-4) var(--pad-1) + calc(var(--pad-4) + var(--pad-2) + 42px); +} + +turbo-frame { + display: contents; +} + +.channel_header { + height: var(--channel-header-height); +} + +.members_list_half { + padding-top: var(--pad-4); + border-top: solid 1px var(--color-super-lowered); +} + +.channels_list_half:not(.no_members), +.members_list_half { + overflow: auto; + height: calc( + (var(--sidebar-height) - var(--channel-header-height) - 8rem) / 2 + ); +} + +@media screen and (max-width: 900px) { + :root { + --sidebar-height: calc(100dvh - 42px * 2); + } + + .message.grouped { + padding: var(--pad-1) var(--pad-4) var(--pad-1) + calc(var(--pad-4) + var(--pad-2) + 31px); + } + + body:not(.sidebars_shown) .sidebar { + position: absolute; + left: -200%; + } + + body.sidebars_shown .sidebar { + position: absolute; + } + + #stream { + width: 100dvw !important; + height: var(--sidebar-height); + } + + nav::after { + width: 100dvw; + left: 0; + } + + .chats_nav { + display: flex; + } + + nav:has(+ .chats_nav) .dropdown .inner { + top: calc(100% + 44px); + } + + .padded_section { + padding: 0 !important; + } +} diff --git a/crates/app/src/public/css/root.css b/crates/app/src/public/css/root.css index 1614a5e..3de8708 100644 --- a/crates/app/src/public/css/root.css +++ b/crates/app/src/public/css/root.css @@ -116,7 +116,7 @@ article { padding: 0; } - body .card:not(.card *):not(#stream *):not(.user_plate), + body .card:not(.card *):not(.user_plate), body .pillmenu:not(.card *) > a, body .card-nest:not(.card *) > .card, body .banner { @@ -213,6 +213,14 @@ ol { margin-left: var(--pad-4); } +pre { + padding: var(--pad-4); +} + +code { + padding: 0; +} + pre, code { font-family: "Jetbrains Mono", "Fire Code", monospace; @@ -221,18 +229,12 @@ code { overflow: auto; background: var(--color-lowered); border-radius: var(--radius); - padding: var(--pad-1); font-size: 0.8rem; } -pre { - padding: var(--pad-4); -} - svg.icon { stroke: currentColor; width: 18px; - width: 1em; height: 1em; } @@ -263,7 +265,6 @@ code { overflow-wrap: normal; text-wrap: pretty; word-wrap: break-word; - overflow-wrap: anywhere; } h1, @@ -275,7 +276,6 @@ h6 { margin: 0; font-weight: 700; width: -moz-max-content; - width: max-content; position: relative; max-width: 100%; } @@ -344,3 +344,56 @@ img.emoji { height: 1em; aspect-ratio: 1 / 1; } + +blockquote { + padding-left: 1rem; + border-left: solid 5px var(--color-super-lowered); + font-style: italic; +} + +.skel { + display: block; + border-radius: var(--radius); + background: var(--color-raised); + animation: skel ease-in-out infinite 2s forwards running; + transition: opacity 0.15s; +} + +@keyframes skel { + from { + background: var(--color-raised); + } + + 50% { + background: var(--color-lowered); + } + + to { + background: var(--color-raised); + } +} + +.loader { + animation: spin linear infinite 2s forwards running; + display: flex; + justify-content: center; + align-items: center; +} + +@keyframes spin { + from { + transform: rotateZ(0deg); + } + + to { + transform: rotateZ(360deg); + } +} + +canvas { + border-radius: var(--radius); + border: solid 5px var(--color-primary); + background: white; + box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size) + var(--color-shadow); +} diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 154f64c..5533a96 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -56,7 +56,8 @@ animation: popin ease-in-out 1 0.15s forwards running; } -.lightbox a { +.lightbox a, +.lightbox img { --padding: 2rem; cursor: zoom-in; max-height: calc(100dvh - var(--padding)); @@ -272,6 +273,12 @@ button, font-weight: 600; } +button:disabled, +.button:disabled { + cursor: not-allowed; + opacity: 50%; +} + button.small, .button.small { /* min-height: max-content; */ @@ -406,6 +413,44 @@ select:focus { overflow-wrap: anywhere; } +input[type="checkbox"] { + --color: #c9b1bc; + appearance: none; + border-radius: var(--radius); + transition: + box-shadow 0.15s, + background 0.15s; + background-color: var(--color-super-raised); + background-position: center; + background-origin: padding-box; + background-size: 14px; + background-repeat: no-repeat; + width: 1em !important; + height: 1em; + min-width: 1em; + outline: none; + border: solid 1px var(--color-super-lowered); + padding: 0; + cursor: pointer; + color: var(--color-text-primary); +} + +input[type="checkbox"]:hover { + box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size) + var(--color-shadow); +} + +input[type="checkbox"]:focus { + outline: solid 2px var(--color); + outline-offset: 2px; +} + +input[type="checkbox"]:checked { + border-color: var(--color); + background-color: var(--color); + background-image: url("/icons/check.svg"); +} + /* pillmenu */ .pillmenu { display: flex; @@ -564,11 +609,9 @@ select:focus { nav { background: var(--color-primary); color: var(--color-text-primary) !important; - color: inherit; width: 100%; display: flex; justify-content: space-between; - color: var(--color-text); position: sticky; top: 0; z-index: 6374; @@ -684,6 +727,9 @@ nav .button:not(.title):not(.active):hover { gap: var(--pad-2); align-items: center; justify-content: space-between; + position: sticky; + top: 0; + z-index: 1; } /* mobile nav chip nav */ @@ -712,19 +758,25 @@ nav .button:not(.title):not(.active):hover { border-bottom-right-radius: var(--radius) !important; } +@media screen and (min-width: 900px) { + .mobile_nav:not(.mobile) { + border-radius: var(--radius); + border: solid 1px var(--color-super-lowered); + } +} + /* dialog */ dialog { padding: 0; position: fixed; bottom: 0; top: 0; - display: flex; + display: none; background: var(--color-surface); border: solid 1px var(--color-super-lowered) !important; border-radius: var(--radius); max-width: 100%; border-style: none; - display: none; margin: auto; color: var(--color-text); animation: popin ease-in-out 1 0.1s forwards running; @@ -1051,14 +1103,14 @@ details summary::-webkit-details-marker { display: none; } -details[open] summary { +details[open] > summary { position: relative; - color: var(--color-primary); - background: var(--color-super-lowered); + color: var(--color-text-lowered) !important; + background: var(--color-super-lowered) !important; margin-bottom: var(--pad-1); } -details[open] summary::after { +details[open] > summary::after { top: 0; left: 0; width: 5px; @@ -1071,7 +1123,7 @@ details[open] summary::after { animation: fadein ease-in-out 1 0.1s forwards running; } -details .card { +details > .card { background: var(--color-super-raised); } @@ -1112,3 +1164,156 @@ details.accordion .inner { border: solid 1px var(--color-super-lowered); border-top: none; } + +/* codemirror */ +.CodeMirror { + color: var(--color-text) !important; +} + +.CodeMirror { + background: transparent !important; + font-family: inherit !important; + height: 10rem !important; + min-height: 100%; + max-height: 100%; + cursor: text; +} + +.CodeMirror-cursor { + border-color: rgb(0, 0, 0) !important; +} + +.CodeMirror-cursor:is(.dark *) { + border-color: rgb(255, 255, 255) !important; +} + +.CodeMirror-cursor { + height: 22px !important; +} + +[role="presentation"]::-moz-selection, +[role="presentation"] *::-moz-selection { + background-color: rgb(191, 219, 254) !important; +} + +[role="presentation"]::selection, +[role="presentation"] *::selection, +.CodeMirror-selected { + background-color: rgb(191, 219, 254) !important; +} + +[role="presentation"]:is(.dark *)::-moz-selection, +[role="presentation"] *:is(.dark *)::-moz-selection { + background-color: rgb(64, 64, 64) !important; +} + +[role="presentation"]:is(.dark *)::selection, +[role="presentation"] *:is(.dark *)::selection, +.CodeMirror-selected:is(.dark *) { + background-color: rgb(64, 64, 64) !important; +} + +.cm-header { + color: inherit !important; +} + +.cm-variable-2, +.cm-quote, +.cm-keyword, +.cm-string, +.cm-atom { + color: rgb(63, 98, 18) !important; +} + +.cm-variable-2:is(.dark *), +.cm-quote:is(.dark *), +.cm-keyword:is(.dark *), +.cm-string:is(.dark *), +.cm-atom:is(.dark *) { + color: rgb(217, 249, 157) !important; +} + +.cm-comment { + color: rgb(153 27 27) !important; +} + +.cm-comment:is(.dark *) { + color: rgb(254, 202, 202) !important; +} + +.cm-comment { + font-family: ui-monospace, monospace; +} + +.cm-link { + color: var(--color-link) !important; +} + +.cm-url, +.cm-property, +.cm-qualifier { + color: rgb(29, 78, 216) !important; +} + +.cm-url:is(.dark *), +.cm-property:is(.dark *), +.cm-qualifier:is(.dark *) { + color: rgb(191, 219, 254) !important; +} + +.cm-variable-3, +.cm-tag, +.cm-def, +.cm-attribute, +.cm-number { + color: rgb(91, 33, 182) !important; +} + +.cm-variable-3:is(.dark *), +.cm-tag:is(.dark *), +.cm-def:is(.dark *), +.cm-attribute:is(.dark *), +.cm-number:is(.dark *) { + color: rgb(221, 214, 254) !important; +} + +.CodeMirror { + height: auto !important; +} + +.CodeMirror-line { + padding-left: 0 !important; +} + +.CodeMirror-focused .CodeMirror-placeholder { + opacity: 50%; +} + +.CodeMirror-gutters { + border-color: var(--color-super-lowered) !important; + background-color: var(--color-lowered) !important; +} + +.CodeMirror-hints { + background: var(--color-raised) !important; + box-shadow: var(--shadow-x-offset) var(--shadow-y-offset) var(--shadow-size) + var(--color-shadow); + border-radius: var(--radius) !important; + padding: var(--pad-1) !important; + border-color: var(--color-super-lowered) !important; +} + +.CodeMirror-hints li { + color: var(--color-text-raised) !important; + border-radius: var(--radius) !important; + transition: + background 0.15s, + color 0.15s; + font-size: 10px; + padding: calc(var(--pad-1) / 2) var(--pad-2); +} + +.CodeMirror-hints li.CodeMirror-hint-active { + background-color: var(--color-primary) !important; + color: var(--color-text-primary) !important; +} diff --git a/crates/app/src/public/html/auth/connection.lisp b/crates/app/src/public/html/auth/connection.lisp index 905b215..8c4fcef 100644 --- a/crates/app/src/public/html/auth/connection.lisp +++ b/crates/app/src/public/html/auth/connection.lisp @@ -12,6 +12,7 @@ (text "{% if connection_type == \"Spotify\" and user and user.connections.Spotify and config.connections.spotify_client_id %}") (script + ("defer" "true") (text "setTimeout(async () => { const code = new URLSearchParams(window.location.search).get(\"code\"); const client_id = \"{{ config.connections.spotify_client_id }}\"; @@ -46,10 +47,11 @@ setTimeout(() => { window.location.href = \"/settings#/connections\"; }, 500); - }, 150);")) + }, 1000);")) (text "{% elif connection_type == \"LastFm\" and user and user.connections.LastFm and config.connections.last_fm_key %}") (script + ("defer" "true") (text "setTimeout(async () => { const token = new URLSearchParams(window.location.search).get(\"token\"); const api_key = \"{{ config.connections.last_fm_key }}\"; diff --git a/crates/app/src/public/html/auth/login.lisp b/crates/app/src/public/html/auth/login.lisp index 82ce3b4..cb8bfff 100644 --- a/crates/app/src/public/html/auth/login.lisp +++ b/crates/app/src/public/html/auth/login.lisp @@ -90,7 +90,7 @@ }), }) .then((res) => res.json()) - .then((res) => { + .then(async (res) => { trigger(\"atto::toast\", [ res.ok ? \"success\" : \"error\", res.message, @@ -98,7 +98,7 @@ if (res.ok) { // update tokens - const new_tokens = ns(\"me\").LOGIN_ACCOUNT_TOKENS; + const new_tokens = (await ns(\"me\")).LOGIN_ACCOUNT_TOKENS; new_tokens[e.target.username.value] = res.message; trigger(\"me::set_login_account_tokens\", [new_tokens]); diff --git a/crates/app/src/public/html/auth/register.lisp b/crates/app/src/public/html/auth/register.lisp index 116cdcf..9e6c22b 100644 --- a/crates/app/src/public/html/auth/register.lisp +++ b/crates/app/src/public/html/auth/register.lisp @@ -25,7 +25,7 @@ (div ("class" "flex flex-col gap-1") (label - ("for" "username") + ("for" "password") (b (text "Password"))) (input @@ -34,6 +34,20 @@ ("required" "") ("name" "password") ("id" "password"))) + (text "{% if config.security.enable_invite_codes -%}") + (div + ("class" "flex flex-col gap-1") + (label + ("for" "invite_code") + (b + (text "Invite code"))) + (input + ("type" "text") + ("placeholder" "invite code") + ("required" "") + ("name" "invite_code") + ("id" "invite_code"))) + (text "{%- endif %}") (hr) (div ("class" "card-nest w-full") @@ -56,7 +70,7 @@ ("href" "{{ config.policies.privacy }}") (text "Privacy policy")))) (div - ("class" "flex gap-2") + ("class" "flex items-center gap-2") (input ("type" "checkbox") ("name" "policy_consent") @@ -89,10 +103,11 @@ captcha_response: e.target.querySelector( \"[name=cf-turnstile-response]\", ).value, + invite_code: (e.target.invite_code || { value: \"\" }).value, }), }) .then((res) => res.json()) - .then((res) => { + .then(async (res) => { trigger(\"atto::toast\", [ res.ok ? \"success\" : \"error\", res.message, @@ -100,7 +115,7 @@ if (res.ok) { // update tokens - const new_tokens = ns(\"me\").LOGIN_ACCOUNT_TOKENS; + const new_tokens = (await ns(\"me\")).LOGIN_ACCOUNT_TOKENS; new_tokens[e.target.username.value] = res.message; trigger(\"me::set_login_account_tokens\", [new_tokens]); diff --git a/crates/app/src/public/html/body.lisp b/crates/app/src/public/html/body.lisp index 3b51c42..82e5fe9 100644 --- a/crates/app/src/public/html/body.lisp +++ b/crates/app/src/public/html/body.lisp @@ -1,9 +1,63 @@ (div ("id" "toast_zone")) +; templates +(template + ("id" "loading_skeleton") + (div + ("class" "flex flex-col gap-2") + ("ui_ident" "loading_skel") + (div + ("class" "card lowered green flex items-center gap-2") + (div ("class" "loader") (icon (text "loader-circle"))) + (span (str (text "general:label.loading")))) + (div + ("class" "card secondary flex gap-2") + (div ("class" "skel avatar")) + (div + ("class" "flex flex-col gap-2 w-full") + (div ("class" "skel") ("style" "width: 25%; height: 25px;")) + (div ("class" "skel") ("style" "width: 100%; height: 150px")))))) + +(template + ("id" "carp_canvas") + (div + ("class" "flex flex-col gap-2") + (div ("ui_ident" "canvas_loc")) + (div + ("class" "flex justify-between gap-2") + (div + ("class" "flex gap-2") + (input + ("type" "color") + ("style" "width: 5rem") + ("ui_ident" "color_picker")) + + (input + ("type" "range") + ("min" "1") + ("max" "25") + ("step" "1") + ("value" "2") + ("ui_ident" "stroke_range"))) + + (div + ("class" "flex gap-2") + (button + ("title" "Undo") + ("ui_ident" "undo") + ("type" "button") + (icon (text "undo"))) + + (button + ("title" "Redo") + ("ui_ident" "redo") + ("type" "button") + (icon (text "redo"))))))) + ; random js (text " {%- endif %}") @@ -228,6 +286,17 @@ return; } + while (!ns(\"spotify\")) { + console.log(\"still no spotify\"); + await (() => { + return new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 500); + }); + })(); + } + window.spotify_init = true; const client_id = \"{{ config.connections.spotify_client_id }}\"; let token = \"{{ user.connections.Spotify[0].data.token }}\"; @@ -243,21 +312,11 @@ if (playing.error) { // refresh token - const [new_token, new_refresh_token, expires_in] = - await trigger(\"spotify::refresh_token\", [ - client_id, + const [new_token, new_refresh_token] = + await trigger(\"spotify::refresh\", [ refresh_token, ]); - await trigger(\"connections::push_con_data\", [ - \"Spotify\", - { - token: new_token, - expires_in: expires_in.toString(), - name: profile.display_name, - }, - ]); - token = new_token; refresh_token = new_refresh_token; return; diff --git a/crates/app/src/public/html/chats/app.lisp b/crates/app/src/public/html/chats/app.lisp index e7cc4ec..0dc16c3 100644 --- a/crates/app/src/public/html/chats/app.lisp +++ b/crates/app/src/public/html/chats/app.lisp @@ -1,7 +1,7 @@ (text "{% extends \"root.html\" %} {% block head %}") (title (text "Chats - {{ config.name }}")) - +(link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/css/chats.css?v=tetratto-{{ random_cache_breaker }}")) (text "{% endblock %} {% block body %} {{ macros::nav(selected=\"chats\", hide_user_menu=true) }}") (nav ("class" "chats_nav") @@ -16,7 +16,6 @@ (b (text "{{ text \"chats:label.my_chats\" }}")) (text "{%- endif %}"))) - (div ("class" "flex") (div @@ -87,7 +86,7 @@ (text "{{ components::user_plate(user=user, show_menu=true) }}")) (text "{% if channel -%}") (div - ("class" "w-full flex flex-col gap-2") + ("class" "w-full flex flex-col gap-2 padded_section") ("id" "stream") ("style" "padding: var(--pad-4)") (turbo-frame @@ -110,225 +109,23 @@ ("title" "Send") (text "{{ icon \"send-horizontal\" }}")))) (text "{%- endif %}") - (style - (text ":root { - --list-bar-width: 64px; - --channels-bar-width: 256px; - --sidebar-height: calc(100dvh - 42px); - --channel-header-height: 48px; - } - html, - body { - overflow: hidden; - } + ; emoji picker + (text "{{ components::emoji_picker(element_id=\"\", render_dialog=true, render_button=false) }}") + (input ("id" "react_emoji_picker_field") ("class" "hidden") ("type" "hidden")) - .name.shortest { - max-width: 165px; - overflow-wrap: normal; - } - - .send_button { - width: 48px; - height: 48px; - } - - .send_button .icon { - width: 2em; - height: 2em; - } - - a.channel_icon { - width: 48px; - height: 48px; - min-height: 48px; - } - - a.channel_icon .icon { - min-width: 24px; - height: 24px; - } - - a.channel_icon.small { - width: 24px; - height: 24px; - min-height: 24px; - } - - a.channel_icon.small .icon { - min-width: 12px; - height: 12px; - } - - a.channel_icon:has(img) { - padding: 0; - } - - a.channel_icon img { - min-width: 48px; - min-height: 48px; - } - - a.channel_icon img, - a.channel_icon:has(.icon) { - transition: - outline 0.25s, - background 0.15s !important; - } - - a.channel_icon:not(.selected):hover img, - a.channel_icon:not(.selected):hover:has(.icon) { - outline: solid 1px var(--color-text); - } - a.channel_icon.selected img, - a.channel_icon.selected:has(.icon) { - outline: solid 2px var(--color-text); - } - - nav { - background: var(--color-raised); - color: var(--color-text-raised) !important; - height: 42px; - position: sticky !important; - } - - nav::after { - display: block; - position: absolute; - background: var(--color-super-lowered); - height: 1px; - width: calc(100% - var(--list-bar-width)); - bottom: 0; - left: var(--list-bar-width); - content: \"\"; - } - - nav .content_container { - max-width: 100% !important; - width: 100%; - } - - .chats_nav { - display: none; - padding: 0; - } - - .chats_nav button { - justify-content: flex-start; - width: 100% !important; - flex-direction: row !important; - font-size: 16px !important; - margin-top: -4px; - } - - .chats_nav button svg { - margin-right: var(--pad-4); - } - - .sidebar { - background: var(--color-raised); - color: var(--color-text-raised); - border-right: solid 1px var(--color-super-lowered); - padding: 0.4rem; - width: max-content; - height: var(--sidebar-height); - overflow: auto; - transition: left 0.15s; - z-index: 1; - } - - .sidebar .title:not(.dropdown *) { - padding: var(--pad-4); - border-bottom: solid 1px var(--color-super-lowered); - } - - .sidebar#channels_list { - width: var(--channels-bar-width); - background: var(--color-surface); - color: var(--color-text); - } - - #stream { - width: calc( - 100dvw - var(--list-bar-width) - var(--channels-bar-width) - ) !important; - height: var(--sidebar-height); - } - - .message { - transition: background 0.15s; - box-shadow: none; - position: relative; - } - - .message:hover { - background: var(--color-raised); - } - - .message:hover .hidden, - .message:focus .hidden, - .message:active .hidden { - display: flex !important; - } - - .message.grouped { - padding: var(--pad-1) var(--pad-4) var(--pad-1) calc(var(--pad-4) + var(--pad-2) + 42px); - } - - turbo-frame { - display: contents; - } - - .channel_header { - height: var(--channel-header-height); - } - - .members_list_half { - padding-top: var(--pad-4); - border-top: solid 1px var(--color-super-lowered); - } - - .channels_list_half:not(.no_members), - .members_list_half { - overflow: auto; - height: calc( - (var(--sidebar-height) - var(--channel-header-height) - 8rem) / - 2 - ); - } - - @media screen and (max-width: 900px) { - :root { - --sidebar-height: calc(100dvh - 42px * 2); + (script + (text "window.EMOJI_PICKER_MODE = \"replace\"; + document.getElementById(\"react_emoji_picker_field\").addEventListener(\"change\", (e) => { + if (!EMOJI_PICKER_REACTION_MESSAGE_ID) { + return; } - .message.grouped { - padding: var(--pad-1) var(--pad-4) var(--pad-1) calc(var(--pad-4) + var(--pad-2) + 31px); - } + const emoji = e.target.value === \"::\" ? \":heart:\" : e.target.value; + trigger(\"me::message_react\", [document.getElementById(`message-${EMOJI_PICKER_REACTION_MESSAGE_ID}`), EMOJI_PICKER_REACTION_MESSAGE_ID, emoji]); + });")) - body:not(.sidebars_shown) .sidebar { - position: absolute; - left: -200%; - } - - body.sidebars_shown .sidebar { - position: absolute; - } - - #stream { - width: 100dvw !important; - height: var(--sidebar-height); - } - - nav::after { - width: 100dvw; - left: 0; - } - - .chats_nav { - display: flex; - } - }")) + ; ... (script (text "window.CURRENT_PAGE = Number.parseInt(\"{{ page }}\"); window.VIEWING_SINGLE = \"{{ message }}\".length > 0; @@ -654,6 +451,7 @@ const clean_text = () => { trigger(\"atto::clean_date_codes\"); trigger(\"atto::hooks::online_indicator\"); + trigger(\"atto::hooks::check_message_reactions\"); }; document.addEventListener( @@ -684,5 +482,4 @@ } }, 100);")) (text "{%- endif %}")) - (text "{% endblock %}") diff --git a/crates/app/src/public/html/chats/stream.lisp b/crates/app/src/public/html/chats/stream.lisp index 8a2243a..9b9affa 100644 --- a/crates/app/src/public/html/chats/stream.lisp +++ b/crates/app/src/public/html/chats/stream.lisp @@ -10,7 +10,7 @@ (b (text "{{ text \"chats:label.viewing_old_messages\" }}")) (a - ("href" "/chats/{{ community }}/{{ channel }}/_stream?page={{ page - 1}}") + ("href" "/chats/{{ community }}/{{ channel.id }}/_stream?page={{ page - 1}}") ("class" "button small") ("onclick" "window.CURRENT_PAGE -= 1") (text "{{ text \"chats:label.go_back\" }}"))) @@ -20,7 +20,7 @@ (b (text "{{ text \"chats:label.viewing_single_message\" }}")) (a - ("href" "/chats/{{ community }}/{{ channel }}?page={{ page }}") + ("href" "/chats/{{ community }}/{{ channel.id }}?page={{ page }}") ("class" "button small") ("onclick" "window.VIEWING_SINGLE = false") ("target" "_top") @@ -30,7 +30,7 @@ ("class" "flex gap-2 w-full justify-center") (a ("class" "button") - ("href" "/chats/{{ community }}/{{ channel }}/_stream?page={{ page + 1 }}") + ("href" "/chats/{{ community }}/{{ channel.id }}/_stream?page={{ page + 1 }}") ("onclick" "window.CURRENT_PAGE += 1") (text "{{ icon \"clock\" }}") (span diff --git a/crates/app/src/public/html/communities/create_post.lisp b/crates/app/src/public/html/communities/create_post.lisp index 51b7ebf..0b7cf19 100644 --- a/crates/app/src/public/html/communities/create_post.lisp +++ b/crates/app/src/public/html/communities/create_post.lisp @@ -97,6 +97,13 @@ ("value" "{{ community.id }}") ("selected" "{% if selected_community == community.id -%}true{% else %}false{%- endif %}") (text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %}")) + (text "{% endfor %}") + (text "{% for stack in stacks %}") + (option + ("value" "{{ stack.id }}") + ("selected" "{% if selected_stack == stack.id -%}true{% else %}false{%- endif %}") + ("is_stack" "true") + (text "{{ stack.name }} (circle)")) (text "{% endfor %}"))) (form ("class" "card flex flex-col gap-2") @@ -132,7 +139,7 @@ ("id" "files_list") ("class" "flex gap-2 flex-wrap")) (div - ("class" "flex justify-between gap-2") + ("class" "flex justify-between flex-collapse gap-2") (text "{{ components::create_post_options() }}") (div ("class" "flex gap-2") @@ -184,13 +191,19 @@ } } + const is_selected_stack = document.getElementById( + \"community_to_post_to\", + ).selectedOptions[0].getAttribute(\"is_stack\") === \"true\"; + const selected_community = document.getElementById( + \"community_to_post_to\", + ).selectedOptions[0].value; + body.append( \"body\", JSON.stringify({ content: e.target.content.value, - community: document.getElementById( - \"community_to_post_to\", - ).selectedOptions[0].value, + community: !is_selected_stack ? selected_community : \"0\", + stack: is_selected_stack ? selected_community : \"0\", poll: poll_data[1], title: e.target.title.value, }), @@ -316,12 +329,15 @@ (text "{% else %}") (script (text "async function create_post_from_form(e) { + e.preventDefault(); const id = await trigger(\"me::repost\", [ \"{{ quoting[1].id }}\", e.target.content.value, document.getElementById(\"community_to_post_to\") .selectedOptions[0].value, false, + document.getElementById(\"community_to_post_to\") + .selectedOptions[0].getAttribute(\"is_stack\") === \"true\", ]); // update settings @@ -394,27 +410,34 @@ (text "{%- endif %}")) (script - (text "const town_square = \"{{ config.town_square }}\"; + (text "(() => {const town_square = \"{{ config.town_square }}\"; const user_id = \"{{ user.id }}\"; - function update_community_avatar(e) { + window.update_community_avatar = (e) => { const element = e.target.parentElement.querySelector(\".avatar\"); + const is_stack = e.target.selectedOptions[0].getAttribute(\"is_stack\") === \"true\"; const id = e.target.selectedOptions[0].value; element.setAttribute(\"title\", id); element.setAttribute(\"alt\", `${id}'s avatar`); - if (id === town_square) { + if (id === town_square || is_stack) { element.src = `/api/v1/auth/user/${user_id}/avatar?selector_type=id`; } else { element.src = `/api/v1/communities/${id}/avatar`; } } - function check_community_supports_title(e) { + window.check_community_supports_title = async (e) => { const element = document.getElementById(\"title_field\"); + const is_stack = e.target.selectedOptions[0].getAttribute(\"is_stack\") === \"true\"; const id = e.target.selectedOptions[0].value; + if (is_stack) { + element.classList.add(\"hidden\"); + return; + } + fetch(`/api/v1/communities/${id}/supports_titles`) .then((res) => res.json()) .then((res) => { @@ -436,7 +459,7 @@ }); }, 150); - async function cancel_create_post() { + window.cancel_create_post = async () => { if ( !(await trigger(\"atto::confirm\", [ \"Are you sure you would like to do this? Your post content will be lost.\", @@ -446,6 +469,6 @@ } window.history.back(); - }")) + }})();")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/communities/settings.lisp b/crates/app/src/public/html/communities/settings.lisp index 3027aee..4213cb9 100644 --- a/crates/app/src/public/html/communities/settings.lisp +++ b/crates/app/src/public/html/communities/settings.lisp @@ -572,9 +572,9 @@ (text "{%- endif %}")) (script - (text "setTimeout(() => { + (text "setTimeout(async () => { const element = document.getElementById(\"membership_info\"); - const ui = ns(\"ui\"); + const ui = await ns(\"ui\"); const uid = new URLSearchParams(window.location.search).get(\"uid\"); if (uid) { @@ -665,7 +665,7 @@ `/api/v1/communities/{{ community.id }}/memberships/${e.target.uid.value}`, ) .then((res) => res.json()) - .then((res) => { + .then(async (res) => { trigger(\"atto::toast\", [ res.ok ? \"success\" : \"error\", res.message, @@ -676,7 +676,7 @@ } // permissions manager - const get_permissions_html = trigger( + const get_permissions_html = await trigger( \"ui::generate_permissions_ui\", [ { @@ -750,8 +750,8 @@ (text "{{ community.context|json_encode()|safe }}")) (script - (text "setTimeout(() => { - const ui = ns(\"ui\"); + (text "setTimeout(async () => { + const ui = await ns(\"ui\"); const settings = JSON.parse( document.getElementById(\"settings_json\").innerHTML, ); diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 8a4c953..75f9620 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -72,7 +72,7 @@ ("style" "display: contents") (text "{% if user.settings.display_name -%} {{ user.settings.display_name }} {% else %} {{ user.username }} {%- endif %}")) -(text "{%- endmacro %} {% macro likes(id, asset_type, likes=0, dislikes=0, secondary=false) -%}") +(text "{%- endmacro %} {% macro likes(id, asset_type, likes=0, dislikes=0, secondary=false, disable_dislikes=false) -%}") (button ("title" "Like") ("class" "{% if secondary -%}lowered{% else %}camo{%- endif %} small") @@ -83,7 +83,7 @@ (text "{{ likes }}")) (text "{%- endif %}")) -(text "{% if not user or not user.settings.hide_dislikes -%}") +(text "{% if not user or not user.settings.hide_dislikes and not disable_dislikes -%}") (button ("title" "Dislike") ("class" "{% if secondary -%}lowered{% else %}camo{%- endif %} small") @@ -94,7 +94,7 @@ (text "{{ dislikes }}")) (text "{%- endif %}")) -(text "{%- endif %} {%- endmacro %} {% macro full_username(user) -%}") +(text "{%- endif %} {%- endmacro %} {% macro full_username(user) -%} {% if user and user.username -%}") (div ("class" "flex items-center") (a @@ -110,14 +110,13 @@ ("class" "flex items-center") (text "{{ icon \"badge-check\" }}")) (text "{%- endif %}")) - -(text "{%- endmacro %} {% macro repost(repost, post, owner, secondary=false, community=false, show_community=true, can_manage_post=false) -%}") +(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 %}") (div - ("class" "card-nest") + ("class" "card-nest post_outer:{{ post.id }} post_outer") (text "{% if question -%} {{ self::question(question=question[0], owner=question[1], profile=owner) }} {% else %}") (div ("class" "card small") @@ -130,10 +129,10 @@ (text "{% if post.context.is_pinned or post.context.is_profile_pinned -%} {{ icon \"pin\" }} {%- endif %}"))) (text "{%- endif %} {%- endif %}") (div - ("class" "card flex flex-col gap-2 {% if secondary -%}secondary{%- endif %}") - ("id" "post:{{ post.id }}") + ("class" "card flex flex-col post gap-2 post:{{ post.id }} {% if secondary -%}secondary{%- endif %}") ("data-community" "{{ post.community }}") ("data-ownsup" "{{ owner.permissions|has_supporter }}") + ("data-id" "{{ post.id }}") ("hook" "verify_emojis") (div ("class" "w-full flex gap-2") @@ -173,8 +172,14 @@ ("class" "flex items-center") ("style" "color: var(--color-primary)") (text "{{ icon \"square-asterisk\" }}")) - (text "{%- endif %}") - (text "{% if community and community.is_forge -%} {% if post.is_open -%}") + (text "{%- endif %} {% if post.stack -%}") + (a + ("title" "Posted to a stack you're in") + ("class" "flex items-center flush") + ("style" "color: var(--color-primary)") + ("href" "/stacks/{{ post.stack }}") + (text "{{ icon \"layers\" }}")) + (text "{%- endif %} {% if community and community.is_forge -%} {% if post.is_open -%}") (span ("title" "Open") ("class" "flex items-center green") @@ -210,7 +215,7 @@ ("class" "flush") ("href" "/post/{{ post.id }}") (h2 - ("id" "post-content:{{ post.id }}") + ("id" "post_content:{{ post.id }}") ("class" "no_p_margin post_content") ("hook" "long") (text "{{ post.title }}")) @@ -219,7 +224,6 @@ (text "{% else %}") (text "{% if not post.context.content_warning -%}") (span - ("id" "post-content:{{ post.id }}") ("class" "no_p_margin post_content") ("hook" "long") @@ -230,12 +234,13 @@ (text "{%- endif %}") ; content - (text "{{ post.content|markdown|safe }} {% 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 %}") + (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 %}") (div ("class" "card lowered red flex items-center gap-2") (text "{{ icon \"frown\" }}") (span - (text "Could not find original post..."))) + (str (text "general:label.could_not_find_post")))) (text "{%- endif %} {%- endif %}")) (text "{{ self::post_media(upload_ids=post.uploads) }} {% else %}") (details @@ -247,7 +252,6 @@ (div ("class" "flex flex-col gap-2") (span - ("id" "post-content:{{ post.id }}") ("class" "no_p_margin post_content") ("hook" "long") @@ -257,7 +261,8 @@ (text "{% endif %}") ; content - (text "{{ post.content|markdown|safe }} {% 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 %}") + (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 %}") (div ("class" "card lowered red flex items-center gap-2") (text "{{ icon \"frown\" }}") @@ -284,7 +289,7 @@ ("class" "flex gap-1 reactions_box") ("hook" "check_reactions") ("hook-arg:id" "{{ post.id }}") - (text "{% if post.context.reactions_enabled -%} {% if post.content|length > 0 -%} {{ self::likes(id=post.id, asset_type=\"Post\", likes=post.likes, dislikes=post.dislikes) }} {%- endif %} {%- endif %} {% if post.context.repost and post.context.repost.reposting -%}") + (text "{% if post.context.reactions_enabled -%} {% if post.content|length > 0 or post.uploads|length > 0 -%} {{ self::likes(id=post.id, asset_type=\"Post\", likes=post.likes, dislikes=post.dislikes, disable_dislikes=owner.settings.hide_dislikes) }} {%- endif %} {%- endif %} {% if post.context.repost and post.context.repost.reposting -%}") (a ("href" "/post/{{ post.context.repost.reposting }}") ("class" "button small camo") @@ -324,7 +329,7 @@ ("class" "title") (text "{{ text \"general:label.share\" }}")) (button - ("onclick" "trigger('me::repost', ['{{ post.id }}', '', '{{ config.town_square }}'])") + ("onclick" "trigger('me::repost', ['{{ post.id }}', '', '{{ config.town_square }}', true])") (text "{{ icon \"repeat-2\" }}") (span (text "{{ text \"communities:label.repost\" }}"))) @@ -334,7 +339,32 @@ (text "{{ icon \"quote\" }}") (span (text "{{ text \"communities:label.quote_post\" }}"))) + (button + ("onclick" "trigger('me::intent_twitter', [trigger('me::gen_share', [{ q: '{{ post.context.answering }}', p: '{{ post.id }}' }, 280, true])])") + (icon (text "bird")) + (span + (text "Twitter"))) + (button + ("onclick" "trigger('me::intent_bluesky', [trigger('me::gen_share', [{ q: '{{ post.context.answering }}', p: '{{ post.id }}' }, 280, true])])") + (icon (text "cloud")) + (span + (text "BlueSky"))) (text "{%- endif %}") + (text "{% if user.id != post.owner -%}") + (b + ("class" "title") + (text "{{ text \"general:label.safety\" }}")) + (button + ("class" "red") + ("onclick" "trigger('me::report', ['{{ post.id }}', 'post'])") + (text "{{ icon \"flag\" }}") + (span + (text "{{ text \"general:action.report\" }}"))) + (text "{%- endif %} {% if (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 -%}") (button ("class" "green") @@ -350,20 +380,7 @@ (span (text "{{ text \"forge:action.reopen\" }}"))) (text "{%- endif %} {%- endif %}") - (text "{% if user.id != post.owner -%}") - (b - ("class" "title") - (text "{{ text \"general:label.safety\" }}")) - (button - ("class" "red") - ("onclick" "trigger('me::report', ['{{ post.id }}', 'post'])") - (text "{{ icon \"flag\" }}") - (span - (text "{{ text \"general:action.report\" }}"))) - (text "{%- endif %} {% if (user.id == post.owner) or is_helper or can_manage_post %}") - (b - ("class" "title") - (text "{{ text \"general:action.manage\" }}")) + ; owner stuff (text "{% if user.id == post.owner -%}") (a ("href" "/post/{{ post.id }}#/edit") @@ -400,7 +417,7 @@ (text "{%- 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%}") +(text "{%- endif %} {%- endmacro %} {% macro post_media(upload_ids) -%} {% if upload_ids|length > 0 -%}") (div ("class" "media_gallery gap-2") (text "{% for upload in upload_ids %}") @@ -607,7 +624,7 @@ (text "{%- endif %} {%- endmacro %} {% macro question(question, owner, show_community=true, secondary=false, profile=false) -%}") (div - ("class" "card {% if secondary -%}secondary{%- endif %} flex gap-2") + ("class" "card question {% if secondary -%}secondary{%- endif %} flex gap-2") (text "{% if owner.id == 0 -%}") (span (text "{% if profile and profile.settings.anonymous_avatar_url -%}") @@ -671,7 +688,10 @@ (span ("class" "no_p_margin") ("style" "font-weight: 500") + ("id" "question_content:{{ question.id }}") (text "{{ question.content|markdown|safe }}")) + ; question drawings + (text "{{ self::post_media(upload_ids=question.drawings) }}") ; 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 %}") @@ -688,7 +708,7 @@ (div ("class" "flex gap-2 items-center justify-between")))) -(text "{%- endmacro %} {% macro create_question_form(receiver=\"0\", community=\"\", header=\"\", is_global=false) -%}") +(text "{%- endmacro %} {% macro create_question_form(receiver=\"0\", community=\"\", header=\"\", is_global=false, drawing_enabled=false) -%}") (div ("class" "card-nest") (div @@ -702,6 +722,12 @@ ("onsubmit" "create_question_from_form(event)") (div ("class" "flex flex-col gap-1") + ; carp canvas + (text "{% if drawing_enabled -%}") + (div ("ui_ident" "carp_canvas_field")) + (text "{%- endif %}") + + ; form (label ("for" "content") (text "{{ text \"communities:label.content\" }}")) @@ -713,25 +739,83 @@ ("required" "") ("minlength" "2") ("maxlength" "4096"))) - (button - ("class" "primary") - (text "{{ text \"communities:action.create\" }}")))) + (div + ("class" "flex gap-2") + (button + ("class" "primary") + (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\" }}")) + + (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(); + + 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=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 %}")))) (script - (text "async function create_question_from_form(e) { + (text "globalThis.gerald = null; + async function create_question_from_form(e) { e.preventDefault(); await trigger(\"atto::debounce\", [\"questions::create\"]); - fetch(\"/api/v1/questions\", { - method: \"POST\", - headers: { - \"Content-Type\": \"application/json\", - }, - body: JSON.stringify({ + + // create body + const body = new FormData(); + + if (globalThis.gerald) { + body.append(\"drawing.carpgraph\", new Blob([new Uint8Array(globalThis.gerald.as_carp2())], { + type: \"application/octet-stream\" + })); + } + + + body.append( + \"body\", + JSON.stringify({ content: e.target.content.value, receiver: \"{{ receiver }}\", community: \"{{ community }}\", is_global: \"{{ is_global }}\" == \"true\", }), + ); + + // ... + fetch(\"/api/v1/questions\", { + method: \"POST\", + body, }) .then((res) => res.json()) .then((res) => { @@ -742,6 +826,10 @@ if (res.ok) { e.target.reset(); + + if (globalThis.gerald) { + globalThis.gerald.clear(); + } } }); }")) @@ -951,9 +1039,29 @@ (text "{%- endif %}") (div ("class" "flex w-full gap-2 justify-between") - (span - ("class" "no_p_margin") - (text "{{ message.content|markdown|safe }}")) + (div + ("class" "flex flex-col gap-2") + (span + ("class" "no_p_margin") + (text "{{ message.content|markdown|safe }}")) + + (div + ("class" "flex w-full gap-1 flex-wrap") + ("onclick" "window.EMOJI_PICKER_REACTION_MESSAGE_ID = '{{ message.id }}'") + ("hook" "check_message_reactions") + ("hook-arg:id" "{{ message.id }}") + + (text "{% for emoji,num in message.reactions -%}") + (button + ("class" "small lowered") + ("ui_ident" "emoji_{{ emoji }}") + ("onclick" "trigger('me::message_react', [event.target.parentElement.parentElement, '{{ message.id }}', '{{ emoji }}'])") + (span (text "{{ emoji|emojis|safe }} {{ num }}"))) + (text "{%- endfor %}") + + (div + ("class" "hidden") + (text "{{ self::emoji_picker(element_id=\"react_emoji_picker_field\", render_dialog=false, render_button=true, small=true) }}")))) (text "{% if grouped -%}") (div ("class" "hidden") @@ -971,6 +1079,14 @@ (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"))) + (a + ("href" "/achievements") + (icon (text "award")) + (str (text "general:link.achievements"))) (a ("href" "/settings") (text "{{ icon \"settings\" }}") @@ -1014,6 +1130,18 @@ ("data-turbo" "false") (icon (text "rabbit")) (str (text "general:link.reference"))) + + (a + ("href" "{{ config.policies.terms_of_service }}") + ("class" "button") + (icon (text "heart-handshake")) + (text "Terms of service")) + + (a + ("href" "{{ config.policies.privacy }}") + ("class" "button") + (icon (text "cookie")) + (text "Privacy policy")) (b ("class" "title") (str (text "general:label.account"))) (button ("onclick" "trigger('me::switch_account')") @@ -1094,13 +1222,15 @@ (text "{{ text \"chats:action.kick_member\" }}"))))) (text "{%- endif %}")) -(text "{%- endmacro %} {% macro emoji_picker(element_id, render_dialog=false) -%}") +(text "{%- endmacro %} {% macro emoji_picker(element_id, render_dialog=false, render_button=true, small=false) -%}") +(text "{% if render_button -%}") (button - ("class" "button small square lowered") + ("class" "button small {% if not small -%} square {%- endif %} lowered") ("onclick" "window.EMOJI_PICKER_TEXT_ID = '{{ element_id }}'; document.getElementById('emoji_dialog').showModal()") ("title" "Emojis") ("type" "button") (text "{{ icon \"smile-plus\" }}")) +(text "{%- endif %}") (text "{% if render_dialog -%}") (dialog @@ -1146,20 +1276,41 @@ } if (event.detail.unicode) { - document.getElementById( - window.EMOJI_PICKER_TEXT_ID, - ).value += ` :${await ( - await fetch(\"/api/v1/lookup_emoji\", { - method: \"POST\", - body: event.detail.unicode, - }) - ).text()}:`; + if (window.EMOJI_PICKER_MODE === \"replace\") { + document.getElementById( + window.EMOJI_PICKER_TEXT_ID, + ).value = `:${await ( + await fetch(\"/api/v1/lookup_emoji\", { + method: \"POST\", + body: event.detail.unicode, + }) + ).text()}:`; + } else { + document.getElementById( + window.EMOJI_PICKER_TEXT_ID, + ).value += ` :${await ( + await fetch(\"/api/v1/lookup_emoji\", { + method: \"POST\", + body: event.detail.unicode, + }) + ).text()}:`; + } } else { - document.getElementById( - window.EMOJI_PICKER_TEXT_ID, - ).value += ` :${event.detail.emoji.shortcodes[0]}:`; + if (window.EMOJI_PICKER_MODE === \"replace\") { + document.getElementById( + window.EMOJI_PICKER_TEXT_ID, + ).value = `:${event.detail.emoji.shortcodes[0]}:`; + } else { + document.getElementById( + window.EMOJI_PICKER_TEXT_ID, + ).value += ` :${event.detail.emoji.shortcodes[0]}:`; + } } + document.getElementById( + window.EMOJI_PICKER_TEXT_ID, + ).dispatchEvent(new Event(\"change\")); + document.getElementById(\"emoji_dialog\").close(); });")) (div @@ -1266,7 +1417,7 @@ (text "{%- endif %} {%- endmacro %} {% macro create_post_options() -%}") (div - ("class" "flex gap-2") + ("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 %}") (button @@ -1281,7 +1432,20 @@ ("title" "More options") ("onclick" "document.getElementById('post_options_dialog').showModal()") ("type" "button") - (text "{{ icon \"ellipsis\" }}"))) + (text "{{ icon \"ellipsis\" }}")) + + (label + ("class" "flex items-center gap-1 button lowered") + ("title" "Mark as NSFW/hide from public timelines") + ("for" "is_nsfw") + (input + ("type" "checkbox") + ("name" "is_nsfw") + ("id" "is_nsfw") + ("checked" "{{ user.settings.auto_unlist }}") + ("onchange" "POST_INITIAL_SETTINGS['is_nsfw'] = event.target.checked")) + + (span (icon (text "eye-closed"))))) (dialog ("id" "post_options_dialog") @@ -1341,11 +1505,11 @@ window.POST_INITIAL_SETTINGS.reactions_enabled.toString(), \"checkbox\", ], - [ - [\"is_nsfw\", \"Hide from public timelines\"], - window.POST_INITIAL_SETTINGS.is_nsfw.toString(), - \"checkbox\", - ], + // [ + // [\"is_nsfw\", \"Hide from public timelines\"], + // window.POST_INITIAL_SETTINGS.is_nsfw.toString(), + // \"checkbox\", + // ], [ [\"content_warning\", \"Content warning\"], window.POST_INITIAL_SETTINGS.content_warning, @@ -1817,3 +1981,278 @@ ("class" "no_p_margin") (text "{{ post.title|markdown|safe }}")))) (text "{%- endmacro %}") + +(text "{% macro stack_listing(stack) -%}") +(a + ("href" "/stacks/{{ stack.id }}") + ("class" "card secondary flex flex-col gap-2") + (div + ("class" "flex items-center gap-2") + (text "{{ icon \"list\" }}") + (b + (text "{{ stack.name }}"))) + (span + (text "Created ") + (span + ("class" "date") + (text "{{ stack.created }}")) + (text "; {{ stack.privacy }}; {{ stack.users|length }} users"))) +(text "{%- endmacro %}") + +(text "{% macro journal_listing(journal, notes, selected_note, selected_journal, view_mode=false, owner=false) -%}") +(text "{% if selected_journal != journal.id -%}") +; not selected +(div + ("class" "flex flex-row gap-1") + (a + ("href" "/journals/{{ journal.id }}/0?view={{ view_mode }}") + ("class" "button justify-start lowered w-full") + (icon (text "notebook")) + (text "{{ journal.title }}")) + + (div + ("class" "dropdown") + (button + ("class" "big_icon lowered") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("style" "width: 32px") + (text "{{ icon \"ellipsis\" }}")) + (div + ("class" "inner") + (button + ("onclick" "delete_journal('{{ journal.id }}')") + ("class" "red") + (text "{{ icon \"trash\" }}") + (span + (text "{{ text \"general:action.delete\" }}")))))) +(text "{% else %}") +; selected +(div + ("class" "flex flex-row gap-1") + (button + ("class" "justify-start lowered w-full") + (icon (text "arrow-down")) + (text "{{ journal.title }}")) + + (text "{% if user and user.id == journal.owner -%}") + (div + ("class" "dropdown") + (button + ("class" "big_icon lowered") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("style" "width: 32px") + (text "{{ icon \"ellipsis\" }}")) + (div + ("class" "inner") + (a + ("class" "button") + ("href" "/journals/{{ journal.id }}/0?view={{ view_mode }}") + (icon (text "house")) + (str (text "general:link.home"))) + (button + ("onclick" "delete_journal('{{ journal.id }}')") + ("class" "red") + (icon (text "trash")) + (str (text "general:action.delete"))))) + (text "{%- endif %}")) + +(text "{% if selected_note -%}") +; open all details elements above the selected note +(script + ("defer" "true") + (text "setTimeout(() => { + let cursor = document.querySelector(\"[ui_ident=active_note]\"); + while (cursor) { + if (cursor.nodeName === \"DETAILS\") { + cursor.setAttribute(\"open\", \"true\"); + } + + cursor = cursor.parentElement; + } + }, 150);")) +(text "{%- endif %}") + +(div + ("class" "flex flex-col gap-2") + ("style" "margin-left: 10px; padding-left: 5px; border-left: solid 2px var(--color-super-lowered); width: calc(100% - 10px)") + ; create note + (text "{% if user and user.id == journal.owner -%}") + (button + ("class" "lowered justify-start w-full") + ("onclick" "create_note()") + (icon (text "plus")) + (str (text "journals:action.create_note"))) + (text "{%- endif %}") + + ; note listings + (text "{{ self::notes_list_dir_listing_inner(dir=[0, 0, \"root\"], dirs=journal.dirs, notes=notes, owner=owner, journal=journal, view_mode=view_mode) }}")) +(text "{%- endif %}") +(text "{%- endmacro %}") + +(text "{% macro notes_list_dir_listing(dir, dirs, notes, owner, journal, view_mode=false) -%}") +(details + (summary + ("class" "button w-full justify-start raised w-full") + (icon (text "folder")) + (text "{{ dir[2] }}")) + + (div + ("class" "flex flex-col gap-2") + ("style" "margin-left: 10px; padding-left: 5px; border-left: solid 2px var(--color-super-lowered); width: calc(100% - 10px)") + (text "{{ self::notes_list_dir_listing_inner(dir=dir, dirs=dirs, notes=notes, owner=owner, journal=journal, view_mode=view_mode) }}"))) +(text "{%- endmacro %}") + +(text "{% macro notes_list_dir_listing_inner(dir, dirs, notes, owner, journal, view_mode=false) -%}") +; child dirs +(text "{% for subdir in dirs %} {% if subdir[1] == dir[0] -%}") + (text "{{ self::notes_list_dir_listing(dir=subdir, dirs=dirs, notes=notes, owner=owner, journal=journal) }}") +(text "{%- endif %} {% endfor %}") + +; child notes +(text "{% for note in notes %} {% if note.dir == dir[0] -%} {% if not view_mode or note.title != \"journal.css\" -%}") + (text "{{ self::notes_list_note_listing(note=note, owner=owner, journal=journal) }}") +(text "{%- endif %} {%- endif %} {% endfor %}") +(text "{%- endmacro %}") + +(text "{% macro notes_list_note_listing(owner, journal, note) -%}") +(div + ("class" "flex flex-row gap-1") + ("ui_ident" "{% if selected_note == note.id -%} active_note {%- else -%} inactive_note {%- endif %}") + (a + ("href" "{% if owner -%} /@{{ owner.username }}/{{ journal.title }}/{{ note.title }} {%- else -%} /journals/{{ journal.id }}/{{ note.id }} {%- endif %}") + ("class" "button justify-start w-full {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}") + (icon (text "file-text")) + (text "{{ note.title }}")) + + (text "{% if user and user.id == journal.owner -%}") + (div + ("class" "dropdown") + (button + ("class" "big_icon {% if selected_note == note.id -%} lowered {%- else -%} raised {%- endif %}") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("style" "width: 32px") + (text "{{ icon \"ellipsis\" }}")) + (div + ("class" "inner") + (button + ("onclick" "change_note_title('{{ note.id }}')") + (icon (text "pencil")) + (str (text "chats:action.rename"))) + (a + ("href" "/journals/{{ journal.id }}/{{ note.id }}#/tags") + (icon (text "tag")) + (str (text "journals:action.edit_tags"))) + (button + ("onclick" "window.NOTE_MOVER_NOTE_ID = '{{ note.id }}'; document.getElementById('note_mover_dialog').showModal()") + (icon (text "brush-cleaning")) + (str (text "journals:action.move"))) + (text "{% if note.is_global -%}") + (a + ("class" "button") + ("href" "/x/{{ note.title }}") + (icon (text "eye")) + (str (text "journals:action.view"))) + + (button + ("class" "purple") + ("onclick" "unpublish_note('{{ note.id }}')") + (icon (text "globe-lock")) + (str (text "journals:action.unpublish"))) + (text "{% elif note.title != 'journal.css' %}") + (button + ("class" "green") + ("onclick" "publish_note('{{ note.id }}')") + (icon (text "globe")) + (str (text "journals:action.publish"))) + (text "{%- endif %}") + (button + ("onclick" "delete_note('{{ note.id }}')") + ("class" "red") + (icon (text "trash")) + (str (text "general:action.delete"))))) + (text "{%- endif %}")) +(text "{%- endmacro %}") + +(text "{% macro note_tags(note) -%} {% if note and note.tags|length > 0 -%}") +(div + ("class" "flex gap-1 flex-wrap") + (text "{% for tag in note.tags %}") + (a + ("href" "{% if view_mode -%} /@{{ owner.username }} {%- else -%} /@{{ user.username }} {%- endif -%} /{{ journal.title }}?tag={{ tag }}") + ("class" "notification chip") + (span (text "{{ tag }}"))) + (text "{% endfor %}")) +(text "{%- endif %} {%- endmacro %}") + +(text "{% macro directories_editor(dirs) -%}") +(button + ("onclick" "create_directory('0')") + (icon (text "plus")) + (str (text "journals:action.create_root_dir"))) + +(text "{% for dir in dirs %} {% if dir[1] == 0 -%}") + (text "{{ self::directories_editor_listing(dir=dir, dirs=dirs) }}") +(text "{%- endif %} {% endfor %}") +(text "{%- endmacro %}") + +(text "{% macro directories_editor_listing(dir, dirs) -%}") +(div + ("class" "flex flex-row gap-1") + (button + ("class" "justify-start lowered w-full") + (icon (text "folder-open")) + (text "{{ dir[2] }}")) + + (div + ("class" "dropdown") + (button + ("class" "big_icon lowered") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("style" "width: 32px") + (text "{{ icon \"ellipsis\" }}")) + (div + ("class" "inner") + (button + ("onclick" "create_directory('{{ dir[0] }}')") + (icon (text "plus")) + (str (text "journals:action.create_subdir"))) + (button + ("onclick" "delete_directory('{{ dir[0] }}')") + ("class" "red") + (icon (text "trash")) + (str (text "general:action.delete")))))) + +(div + ("class" "flex flex-col gap-2") + ("style" "margin-left: 10px; padding-left: 5px; border-left: solid 2px var(--color-super-lowered); width: calc(100% - 10px)") + ; subdir listings + (text "{% for subdir in dirs %} {% if subdir[1] == dir[0] -%}") + (text "{{ self::directories_editor_listing(dir=subdir, dirs=dirs) }}") + (text "{%- endif %} {% endfor %}")) +(text "{%- endmacro %}") + +(text "{% macro note_mover_dirs(dirs) -%}") +(text "{% for dir in dirs %} {% if dir[1] == 0 -%}") + (text "{{ self::note_mover_dirs_listing(dir=dir, dirs=dirs) }}") +(text "{%- endif %} {% endfor %}") +(text "{%- endmacro %}") + +(text "{% macro note_mover_dirs_listing(dir, dirs) -%}") +(button + ("onclick" "move_note_dir(window.NOTE_MOVER_NOTE_ID, '{{ dir[0] }}'); document.getElementById('note_mover_dialog').close()") + ("class" "justify-start lowered w-full") + (icon (text "folder-open")) + (text "{{ dir[2] }}")) + +(div + ("class" "flex flex-col gap-2") + ("style" "margin-left: 10px; padding-left: 5px; border-left: solid 2px var(--color-super-lowered); width: calc(100% - 10px)") + ; subdir listings + (text "{% for subdir in dirs %} {% if subdir[1] == dir[0] -%}") + (text "{{ self::note_mover_dirs_listing(dir=subdir, dirs=dirs) }}") + (text "{%- endif %} {% endfor %}")) +(text "{%- endmacro %}") diff --git a/crates/app/src/public/html/developer/app.lisp b/crates/app/src/public/html/developer/app.lisp index b9012ec..2850ef5 100644 --- a/crates/app/src/public/html/developer/app.lisp +++ b/crates/app/src/public/html/developer/app.lisp @@ -129,7 +129,7 @@ (pre ("class" "hidden red w-full") (code ("id" "scope_error_message") ("style" "white-space: pre-wrap"))) (details - (summary ("class" "button lowered small") (icon (text "circle-help")) (text "Help")) + (summary ("class" "button lowered small") (icon (text "circle-question-mark")) (text "Help")) (div ("class" "card flex flex-col gap-1") (span ("class" "fade") (text "Scopes should be separated by a single space.")) diff --git a/crates/app/src/public/html/developer/home.lisp b/crates/app/src/public/html/developer/home.lisp index ffc68b8..aefd55d 100644 --- a/crates/app/src/public/html/developer/home.lisp +++ b/crates/app/src/public/html/developer/home.lisp @@ -92,7 +92,7 @@ ("class" "card-nest") (div ("class" "card small flex items-center gap-2") - (icon (text "circle-help")) + (icon (text "circle-question-mark")) (str (text "developer:label.guides_and_help"))) (div @@ -101,15 +101,15 @@ (li (a ("href" "https://trisua.com/t/tetratto") (text "Source code"))) (li - (a ("href" "https://tetratto.com/reference/tetratto/index.html") (text "Source code reference"))) + (a ("href" "https://tetratto.com/reference/tetratto/index.html") ("data-turbo" "false") (text "Source code reference"))) (li - (a ("href" "https://tetratto.com/reference/tetratto/model/struct.ApiReturn.html") (text "API response structure"))) + (a ("href" "https://tetratto.com/reference/tetratto/model/struct.ApiReturn.html") ("data-turbo" "false") (text "API response structure"))) (li - (a ("href" "https://tetratto.com/reference/tetratto/model/oauth/enum.AppScope.html") (text "App scopes"))) + (a ("href" "https://tetratto.com/reference/tetratto/model/oauth/enum.AppScope.html") ("data-turbo" "false") (text "App scopes"))) (li - (a ("href" "https://tetratto.com/reference/tetratto/model/permissions/struct.FinePermission.html") (text "User permissions"))) + (a ("href" "https://tetratto.com/reference/tetratto/model/permissions/struct.FinePermission.html") ("data-turbo" "false") (text "User permissions"))) (li - (a ("href" "https://tetratto.com/reference/tetratto/model/communities_permissions/struct.CommunityPermission.html") (text "Community member permissions"))) + (a ("href" "https://tetratto.com/reference/tetratto/model/communities_permissions/struct.CommunityPermission.html") ("data-turbo" "false") (text "Community member permissions"))) (li (a ("href" "https://tetratto.com/forge/tetratto") (text "Report issues"))))))) diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp new file mode 100644 index 0000000..255b2ec --- /dev/null +++ b/crates/app/src/public/html/journals/app.lisp @@ -0,0 +1,967 @@ +(text "{% extends \"root.html\" %} {% block head %}") + +(text "{% if journal -%} {% if note -%}") +(title (text "{{ note.title }}")) +(text "{% else %}") +(title (text "{{ journal.title }}")) +(text "{%- endif %} {% else %}") +(title (text "Journals - {{ config.name }}")) +(text "{%- endif %}") + +(text "{% if note and journal and owner -%}") +(meta + ("name" "og:title") + ("content" "{{ note.title }}")) + +(text "{% if not global_mode -%}") +(meta + ("name" "description") + ("content" "View this note from the {{ journal.title }} journal on {{ config.name }}!")) + +(meta + ("name" "og:description") + ("content" "View this note from the {{ journal.title }} journal on {{ config.name }}!")) + +(meta + ("name" "twitter:description") + ("content" "View this note from the {{ journal.title }} journal on {{ config.name }}!")) +(text "{% else %}") +(meta + ("name" "description") + ("content" "View this note on {{ config.name }}!")) + +(meta + ("name" "og:description") + ("content" "View this note on {{ config.name }}!")) + +(meta + ("name" "twitter:description") + ("content" "View this note on {{ config.name }}!")) +(text "{%- endif %}") + +(meta + ("property" "og:type") + ("content" "website")) + +(meta + ("name" "og:image") + ("content" "{{ config.host|safe }}/api/v1/auth/user/{{ owner.username }}/avatar?selector_type=username")) + +(meta + ("name" "twitter:image") + ("content" "{{ config.host|safe }}/api/v1/auth/user/{{ owner.username }}/avatar?selector_type=usernamev")) + +(meta + ("name" "twitter:card") + ("content" "summary")) + +(meta + ("name" "twitter:title") + ("content" "{{ note.title }}")) +(text "{%- endif %}") +(link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/css/chats.css?v=tetratto-{{ random_cache_breaker }}")) + +(style + (text "html, body { + overflow: hidden auto !important; + } + + .sidebar { + position: sticky; + top: 42px; + } + + @media screen and (max-width: 900px) { + .sidebar { + position: absolute; + top: unset; + } + + body.sidebars_shown { + overflow: hidden !important; + } + }")) + +(text "{% if view_mode and journal and is_editor -%} {% if note -%}") +; redirect to note +(meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}/{{ note.title }}")) +(text "{% else %}") +; redirect to journal homepage +(meta ("http-equiv" "refresh") ("content" "0; url=/@{{ user.username }}/{{ journal.title }}")) +(text "{%- endif %} {%- endif %}") + +(text "{% if view_mode and journal -%}") +; add journal css +(link ("rel" "stylesheet") ("data-turbo-temporary" "true") ("href" "/api/v1/journals/{{ journal.id }}/journal.css?v=tetratto-{{ random_cache_breaker }}")) +(text "{%- endif %}") +(text "{% endblock %} {% block body %} {% if not global_mode -%} {{ macros::nav(selected=\"journals\") }} {%- endif %}") +(text "{% if not view_mode -%}") +(nav + ("class" "chats_nav") + (button + ("class" "flex gap-2 items-center active") + ("onclick" "toggle_sidebars(event)") + (text "{{ icon \"panel-left\" }} {% if community -%}") + (b + ("class" "name shorter") + (text "{% if community.context.display_name -%} {{ community.context.display_name }} {% else %} {{ community.title }} {%- endif %}")) + (text "{% else %}") + (b + (text "{{ text \"journals:label.my_journals\" }}")) + (text "{%- endif %}"))) +(text "{%- endif %}") +(div + ("class" "flex") + ; journals/notes listing + (text "{% if not view_mode -%}") + ; this isn't shown if we're in view mode + (div + ("class" "sidebar flex flex-col gap-2 justify-between") + ("id" "notes_list") + (div + ("class" "flex flex-col gap-2 w-full") + (button + ("class" "lowered justify-start w-full") + ("onclick" "create_journal()") + (icon (text "plus")) + (str (text "journals:action.create_journal"))) + + (text "{% for journal in journals %}") + (text "{{ components::journal_listing(journal=journal, notes=notes, selected_note=selected_note, selected_journal=selected_journal, view_mode=view_mode) }}") + (text "{% endfor %}"))) + (text "{%- endif %}") + ; editor + (div + ("class" "w-full padded_section") + ("id" "editor") + ("style" "padding: var(--pad-4)") + (main + ("class" "flex flex-col gap-2") + ; the journal/note header is always shown + (text "{% if journal and not global_mode -%}") + (div + ("class" "mobile_nav w-full flex items-center justify-between gap-2") + (div + ("class" "flex flex-col gap-2") + (div + ("class" "flex gap-2 items-center") + (a + ("class" "flex items-center") + ("href" "/@{{ owner.username }}") + (text "{{ components::avatar(username=owner.username, selector_type=\"username\", size=\"18px\") }}")) + + (text "{% if (view_mode and owner) or not view_mode -%}") + (a + ("class" "flush") + ("href" "{% if view_mode -%} /@{{ owner.username }}/{{ journal.title }} {%- else -%} /journals/{{ journal.id }}/0 {%- endif %}") + (b (text "{{ journal.title }}"))) + (text "{%- endif %}") + + (text "{% if note -%}") + (span (text "/")) + (b (text "{{ note.title }}")) + (text "{%- endif %}"))) + + (text "{% if user and user.id == journal.owner and (not note or note.title != \"journal.css\") -%}") + (div + ("class" "pillmenu") + (a + ("class" "{% if not view_mode -%}active{%- endif %}") + ("href" "/journals/{{ journal.id }}/{% if note -%} {{ note.id }} {%- else -%} 0 {%- endif %}") + ("data-turbo" "false") + (icon (text "pencil"))) + (a + ("class" "{% if view_mode -%}active{%- endif %}") + ("href" "/@{{ user.username }}/{{ journal.title }}{% if note -%} /{{ note.title }} {%- endif %}") + (icon (text "eye")))) + (text "{%- endif %}")) + (text "{%- endif %}") + + ; we're going to put some help panes in here if something is 0 + ; ...people got confused on the chats page because they somehow couldn't figure out how to open the sidebar + (text "{% if selected_journal == 0 -%}") + ; no journal selected + (div + ("class" "card w-full flex flex-col gap-2") + (h3 (str (text "journals:label.welcome"))) + (span (str (text "journals:label.select_a_journal"))) + (span ("class" "mobile") (str (text "journals:label.mobile_click_my_journals"))) + (button + ("onclick" "create_journal()") + (icon (text "plus")) + (str (text "journals:action.create_journal")))) + (text "{% elif selected_note == 0 -%}") + ; journal selected, but no note is selected + (text "{% if not view_mode -%}") + ; we're the journal owner and we're not in view mode + (div + ("class" "card w-full flex flex-col gap-2") + (h3 (text "{{ journal.title }}")) + (span (str (text "journals:label.select_a_note"))) + (button + ("onclick" "create_note()") + (icon (text "plus")) + (str (text "journals:action.create_note")))) + + ; we'll also let users edit the journal's settings here i guess + (details + ("class" "w-full") + (summary + ("class" "button lowered w-full justify-start") + (icon (text "settings")) + (str (text "general:action.manage"))) + + (div + ("class" "card flex flex-col gap-2 lowered") + (div + ("class" "card-nest") + (div + ("class" "card small") + (b + (text "Privacy"))) + (div + ("class" "card") + (select + ("onchange" "change_journal_privacy(event)") + (option + ("value" "Private") + ("selected" "{% if journal.privacy == 'Private' -%}true{% else %}false{%- endif %}") + (text "Private")) + (option + ("value" "Public") + ("selected" "{% if journal.privacy == 'Public' -%}true{% else %}false{%- endif %}") + (text "Public"))))) + + (div + ("class" "card-nest") + (div + ("class" "card small") + (label + ("for" "title") + (b (str (text "communities:label.title"))))) + + (form + ("class" "card flex flex-col gap-2") + ("onsubmit" "change_journal_title(event)") + (div + ("class" "flex flex-col gap-1") + (input + ("type" "text") + ("name" "title") + ("id" "title") + ("placeholder" "title") + ("required" "") + ("minlength" "2"))) + (button + ("class" "primary") + (text "{{ icon \"check\" }}") + (span + (text "{{ text \"general:action.save\" }}"))))))) + + ; users should also be able to manage the journal's sub directories here + (details + ("class" "w-full") + (summary + ("class" "button lowered w-full justify-start") + (icon (text "folders")) + (str (text "journals:label.directories"))) + + (div + ("class" "card flex flex-col gap-2 lowered") + (text "{{ components::directories_editor(dirs=journal.dirs) }}"))) + (text "{% else %}") + ; we're in view mode; just show journal listing and notes as journal homepage + (div + ("class" "card flex flex-col gap-2") + (text "{% if tag|length > 0 -%}") + (a + ("href" "?") + ("class" "notification chip w-content") + (text "{{ tag }}")) + (text "{%- endif %}") + + (text "{{ components::journal_listing(journal=journal, notes=notes, selected_note=selected_note, selected_journal=selected_journal, view_mode=view_mode, owner=owner) }}")) + (text "{%- endif %}") + (text "{% else %}") + ; journal AND note selected + (text "{% if not view_mode -%}") + ; not view mode; show editor + ; import codemirror + (script ("src" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.js") ("data-turbo-temporary" "true")) + (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/display/placeholder.js") ("data-turbo-temporary" "true")) + (link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/lib/codemirror.css") ("data-turbo-temporary" "true")) + + (text "{% if note.title == \"journal.css\" -%}") + ; css editor + (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/edit/closebrackets.js") ("data-turbo-temporary" "true")) + (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/hint/css-hint.js") ("data-turbo-temporary" "true")) + (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/hint/show-hint.js") ("data-turbo-temporary" "true")) + (script ("src" "https://unpkg.com/codemirror@5.39.2/addon/lint/css-lint.js") ("data-turbo-temporary" "true")) + (script ("src" "https://unpkg.com/codemirror@5.39.2/mode/css/css.js") ("data-turbo-temporary" "true")) + (link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/addon/hint/show-hint.css") ("data-turbo-temporary" "true")) + (link ("rel" "stylesheet") ("href" "https://unpkg.com/codemirror@5.39.2/addon/lint/lint.css") ("data-turbo-temporary" "true")) + + (style + (text ".CodeMirror { + font-family: monospace !important; + font-size: 16px; + border: solid 1px var(--color-super-lowered); + border-radius: var(--radius); + } + + .CodeMirror-line { + padding-left: 5px !important; + }")) + (text "{% else %}") + ; markdown editor + (script ("src" "https://unpkg.com/codemirror@5.39.2/mode/markdown/markdown.js") ("data-turbo-temporary" "true")) + (text "{%- endif %}") + + ; tab bar + (text "{% if note.title != \"journal.css\" -%}") + (div + ("class" "pillmenu") + (a + ("href" "#/editor") + ("data-tab-button" "editor") + ("data-turbo" "false") + ("class" "active") + (str (text "journals:label.editor"))) + + (a + ("href" "#/preview") + ("data-tab-button" "preview") + ("data-turbo" "false") + (str (text "journals:label.preview_pane"))) + + (a + ("href" "#/tags") + ("data-tab-button" "tags") + ("data-turbo" "false") + ("class" "hidden") + (str (text "journals:action.edit_tags")))) + (text "{%- endif %}") + + ; tabs + (text "{{ components::note_tags(note=note) }}") + + (div + ("data-tab" "editor") + ("class" "flex flex-col gap-2") + (div + ("class" "flex flex-col gap-2 card") + ("style" "animation: fadein ease-in-out 1 0.5s forwards running") + ("id" "editor_tab")) + + (button + ("onclick" "change_note_content('{{ note.id }}')") + (icon (text "check")) + (str (text "general:action.save")))) + + (div + ("data-tab" "preview") + ("class" "flex flex-col gap-2 card hidden") + ("style" "animation: fadein ease-in-out 1 0.5s forwards running") + ("id" "preview_tab")) + + (div + ("data-tab" "tags") + ("class" "flex flex-col gap-2 card hidden") + ("style" "animation: fadein ease-in-out 1 0.5s forwards running") + (form + ("onsubmit" "save_tags(event)") + ("class" "flex flex-col gap-1") + (label + ("for" "tags") + (str (text "journals:action.tags")) + (textarea + ("type" "text") + ("name" "tags") + ("id" "tags") + ("placeholder" "tags") + ("required" "") + ("minlength" "2") + ("maxlength" "128") + (text "{% for tag in note.tags -%} {{ tag }}, {% endfor %}")) + (span ("class" "fade") (text "Tags should be separated by a comma."))) + + (button + (icon (text "check")) + (str (text "general:action.save")))) + + (script + (text "globalThis.save_tags = (e) => { + event.preventDefault(); + fetch(\"/api/v1/notes/{{ selected_note }}/tags\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + tags: e.target.tags.value.split(\",\").map(t => t.trim()).filter(t => t), + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }"))) + + ; init codemirror + (script ("id" "editor_content") ("type" "text/markdown") (text "{{ note.content|remove_script_tags|safe }}")) + (script + (text "setTimeout(async () => { + document.getElementById(\"editor_tab\").innerHTML = \"\"; + globalThis.editor = CodeMirror(document.getElementById(\"editor_tab\"), { + value: document.getElementById(\"editor_content\").innerHTML, + mode: \"{% if note.title == 'journal.css' -%} css {%- else -%} markdown {%- endif %}\", + lineWrapping: true, + lineNumbers: \"{{ note.title }}\" === \"journal.css\", + autoCloseBrackets: true, + autofocus: true, + viewportMargin: Number.POSITIVE_INFINITY, + inputStyle: \"contenteditable\", + highlightFormatting: false, + fencedCodeBlockHighlighting: false, + xml: false, + smartIndent: true, + indentUnit: 4, + placeholder: `# {{ note.title }}`, + extraKeys: { + Home: \"goLineLeft\", + End: \"goLineRight\", + Enter: (cm) => { + cm.replaceSelection(\"\\n\"); + }, + }, + }); + + editor.on(\"keydown\", (cm, e) => { + if (e.key.length > 1) { + // ignore all keys that aren't a letter + return; + } + + CodeMirror.showHint(cm, CodeMirror.hint.css); + }); + + document.querySelector(\"[data-tab-button=editor]\").addEventListener(\"click\", async (e) => { + e.preventDefault(); + trigger(\"atto::hooks::tabs:switch\", [\"editor\"]); + }); + + document.querySelector(\"[data-tab-button=preview]\").addEventListener(\"click\", async (e) => { + e.preventDefault(); + const res = await ( + await fetch(\"/api/v1/notes/preview\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + content: globalThis.editor.getValue(), + }), + }) + ).text(); + + const preview_token = window.crypto.randomUUID(); + document.getElementById(\"preview_tab\").innerHTML = `${res}`; + trigger(\"atto::hooks::tabs:switch\", [\"preview\"]); + }); + }, 150);")) + (text "{% else %}") + ; we're just viewing this note + (div + ("class" "flex flex-col gap-2 card") + (text "{{ note.content|markdown|safe }}")) + + (div + ("class" "flex w-full justify-between gap-2") + (div + ("class" "flex flex-col gap-2 fade") + (span (text "Last updated: ") (span ("class" "date") (text "{{ note.edited }}"))) + + (text "{% if global_mode -%}") + (span ("class" "flex gap-1") (text "Created by: ") (text "{{ components::full_username(user=owner) }}")) + (span (text "Views: {{ redis_views }}")) + (text "{% elif note.is_global -%}") + ; globsl note, but we aren't viewing globally... + (a + ("href" "/x/{{ note.title }}") + ("class" "button lowered small green") + (icon (text "globe")) + (text "View as global")) + (text "{%- endif %}") + + (text "{{ components::note_tags(note=note) }}")) + + (text "{% if user and user.id == owner.id -%}") + (button + ("class" "small") + ("onclick" "{% if note.is_global -%} + trigger('atto::copy_text', ['{{ config.host }}/x/{{ note.title }}']) + {%- else -%} + {% if journal.privacy == \"Public\" -%} + trigger('atto::copy_text', ['{{ config.host }}/@{{ owner.username }}/{{ journal.title }}/{{ note.title }}']) + {%- else -%} + prompt_make_public(); + trigger('atto::copy_text', ['{{ config.host }}/@{{ owner.username }}/{{ journal.title }}/{{ note.title }}']) + {%- endif -%} {%- endif %}") + (icon (text "share")) + (str (text "general:label.share"))) + + (script + (text "globalThis.prompt_make_public = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Would you like to make this journal public? This is required for others to view this note.\", + ])) + ) { + return; + } + + change_journal_privacy({ target: { selectedOptions: [{ value: \"Public\" }] }, preventDefault: () => {} }); + }")) + (text "{%- endif %}")) + (text "{%- endif %}") + (text "{%- endif %}"))) + (style + (text "nav::after { + width: 100%; + left: 0; + }")) + (script + (text "window.JOURNAL_PROPS = { + selected_journal: \"{{ selected_journal }}\", + selected_note: \"{{ selected_note }}\", + }; + + // journals/notes + globalThis.create_journal = async () => { + const title = await trigger(\"atto::prompt\", [\"Journal title:\"]); + + if (!title) { + return; + } + + fetch(\"/api/v1/journals\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + title, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + setTimeout(() => { + window.location.href = `/journals/${res.payload}/0`; + }, 100); + } + }); + } + + globalThis.create_note = async () => { + const title = await trigger(\"atto::prompt\", [\"Note title:\"]); + + if (!title) { + return; + } + + fetch(\"/api/v1/notes\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + title, + content: title === \"journal.css\" ? `/* ${title} */\\n` : `# ${title}`, + journal: \"{{ selected_journal }}\", + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + setTimeout(() => { + window.location.href = `/journals/{{ selected_journal }}/${res.payload}`; + }, 100); + } + }); + } + + globalThis.delete_journal = async (id) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(`/api/v1/journals/${id}`, { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + setTimeout(() => { + window.location.href = \"/journals\"; + }, 100); + } + }); + } + + globalThis.delete_note = async (id) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(`/api/v1/notes/${id}`, { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + setTimeout(() => { + window.location.href = \"/journals/{{ selected_journal }}/0\"; + }, 100); + } + }); + } + + globalThis.change_journal_title = async (e) => { + e.preventDefault(); + fetch(\"/api/v1/journals/{{ selected_journal }}/title\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + title: e.target.title.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + e.reset(); + } + }); + } + + globalThis.change_journal_privacy = async (e) => { + e.preventDefault(); + const selected = e.target.selectedOptions[0]; + fetch(\"/api/v1/journals/{% if journal -%} {{ journal.id }} {%- else -%} {{ selected_journal }} {%- endif %}/privacy\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + privacy: selected.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + globalThis.change_note_title = async (id) => { + const title = await trigger(\"atto::prompt\", [\"New note title:\"]); + + if (!title) { + return; + } + + fetch(`/api/v1/notes/${id}/title`, { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + title, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + e.reset(); + } + }); + } + + globalThis.change_note_content = async (id) => { + fetch(`/api/v1/notes/${id}/content`, { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + content: globalThis.editor.getValue(), + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + globalThis.create_directory = async (parent) => { + const name = await trigger(\"atto::prompt\", [\"Directory name:\"]); + + if (!name) { + return; + } + + fetch(\"/api/v1/journals/{{ selected_journal }}/dirs\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + name, + parent, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.reload(); + } + }); + } + + globalThis.delete_directory = async (id) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this? This will delete all notes within this directory.\", + ])) + ) { + return; + } + + fetch(\"/api/v1/journals/{{ selected_journal }}/dirs\", { + method: \"DELETE\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + id, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + fetch(`/api/v1/notes/{{ selected_journal }}/dir/${id}`, { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + }); + } + + globalThis.move_note_dir = async (id, dir) => { + fetch(`/api/v1/notes/${id}/dir`, { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + dir, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.reload(); + } + }); + } + + globalThis.publish_note = async (id) => { + if ( + !(await trigger(\"atto::confirm\", [ + `Are you sure you would like to do this? The note will be public at '/x/name', even if the journal is private. + +Publishing your note is specifically for making the note accessible through the global endpoint. The note will be public under your username as long as the journal is public.`, + ])) + ) { + return; + } + + fetch(`/api/v1/notes/${id}/global`, { + method: \"POST\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + setTimeout(() => { + window.location.reload(); + }, 100); + } + }); + } + + globalThis.unpublish_note = async (id) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this? This global note name will be made available.\", + ])) + ) { + return; + } + + fetch(`/api/v1/notes/${id}/global`, { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + setTimeout(() => { + window.location.reload(); + }, 100); + } + }); + } + + // sidebars + window.SIDEBARS_OPEN = false; + if (new URLSearchParams(window.location.search).get(\"nav\") === \"true\") { + window.SIDEBARS_OPEN = true; + } + + if ( + window.SIDEBARS_OPEN && + !document.body.classList.contains(\"sidebars_shown\") + ) { + toggle_sidebars(); + window.SIDEBARS_OPEN = true; + } + + for (const anchor of document.querySelectorAll(\"[data-turbo=false]\")) { + anchor.href += `?nav=${window.SIDEBARS_OPEN}`; + } + + function toggle_sidebars() { + window.SIDEBARS_OPEN = !window.SIDEBARS_OPEN; + + for (const anchor of document.querySelectorAll( + \"[data-turbo=false]\", + )) { + anchor.href = anchor.href.replace( + `?nav=${!window.SIDEBARS_OPEN}`, + `?nav=${window.SIDEBARS_OPEN}`, + ); + } + + const notes_list = document.getElementById(\"notes_list\"); + + if (document.body.classList.contains(\"sidebars_shown\")) { + // hide + document.body.classList.remove(\"sidebars_shown\"); + notes_list.style.left = \"-200%\"; + } else { + // show + document.body.classList.add(\"sidebars_shown\"); + notes_list.style.left = \"0\"; + } + }"))) + +(text "{% if journal -%}") +; note mover +(dialog + ("id" "note_mover_dialog") + (div + ("class" "inner flex flex-col gap-2") + (p (text "Select a directory to move this note into:")) + (text "{{ components::note_mover_dirs(dirs=journal.dirs) }}") + (div + ("class" "flex justify-between") + (div) + (div + ("class" "flex gap-2") + (button + ("class" "bold red lowered") + ("onclick" "document.getElementById('note_mover_dialog').close()") + ("type" "button") + (text "{{ icon \"x\" }} {{ text \"dialog:action.close\" }}")))))) +(text "{%- endif %}") +(text "{% endblock %}") diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index f544f5e..b2e8863 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -48,7 +48,7 @@ (a ("href" "/requests") ("class" "button {% if selected == 'requests' -%}active{%- endif %}") - ("title" "Chats") + ("title" "Requests") (icon (text "inbox")) (span ("class" "notification tr {% if user.request_count <= 0 -%}hidden{%- endif %}") @@ -58,7 +58,7 @@ (a ("href" "/notifs") ("class" "button {% if selected == 'notifications' -%}active{%- endif %}") - ("title" "Chats") + ("title" "Notifications") (icon (text "bell")) (span ("class" "notification tr {% if user.notification_count <= 0 -%}hidden{%- endif %}") @@ -112,7 +112,19 @@ ("class" "button") ("data-turbo" "false") (icon (text "rabbit")) - (str (text "general:link.reference")))))) + (str (text "general:link.reference"))) + + (a + ("href" "{{ config.policies.terms_of_service }}") + ("class" "button") + (icon (text "heart-handshake")) + (text "Terms of service")) + + (a + ("href" "{{ config.policies.privacy }}") + ("class" "button") + (icon (text "cookie")) + (text "Privacy policy"))))) (text "{%- endif %}"))) (text "{%- endmacro %}") @@ -124,7 +136,7 @@ ("class" "dropdown") ("style" "width: max-content") (button - ("class" "camo raised small") + ("class" "raised small") ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") (icon (text "sliders-horizontal")) @@ -144,7 +156,9 @@ (text "{{ macros::timelines_nav_options(selected=selected) }}") ; secondary nav desktop only - (text "{{ macros::timelines_secondary_nav(posts=posts, questions=questions, selected=secondary_selected) }}")) + (text "{% if posts and questions -%}") + (text "{{ macros::timelines_secondary_nav(posts=posts, questions=questions, selected=secondary_selected) }}") + (text "{%- endif %}")) (text "{%- endmacro %}") (text "{% macro timelines_nav_options(selected=\"\") -%}") diff --git a/crates/app/src/public/html/misc/achievements.lisp b/crates/app/src/public/html/misc/achievements.lisp new file mode 100644 index 0000000..4b21b5d --- /dev/null +++ b/crates/app/src/public/html/misc/achievements.lisp @@ -0,0 +1,42 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "Achievements - {{ config.name }}")) + +(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"achievements\") }}") +(main + ("class" "flex flex-col gap-2") + (div + ("class" "card-nest") + (div + ("class" "card small flex items-center gap-2") + (icon (text "coffee")) + (span (text "Welcome to {{ config.name }}!"))) + (div + ("class" "card no_p_margin") + (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!")))) + + (div + ("class" "card-nest") + (div + ("class" "card small flex items-center justify-between gap-2") + (span + ("class" "flex items-center gap-2") + (icon (text "award")) + (span (str (text "general:link.achievements"))))) + (div + ("class" "card lowered flex flex-col gap-4") + (text "{% for achievement in achievements %}") + (div + ("class" "w-full card-nest") + (div + ("class" "card small flex items-center gap-2 {% if achievement[2] == 'Uncommon' -%} green {%- elif achievement[2] == 'Rare' -%} purple {%- endif %}") + (icon (text "award")) + (text "{{ achievement[0] }}")) + (div + ("class" "card flex flex-col gap-2") + (span ("class" "no_p_margin") (text "{{ achievement[1]|markdown|safe }}")) + (hr) + (span ("class" "fade") (text "Unlocked: ") (span ("class" "date") (text "{{ achievement[3].unlocked }}"))))) + (text "{% endfor %}")))) +(text "{% endblock %}") diff --git a/crates/app/src/public/html/misc/requests.lisp b/crates/app/src/public/html/misc/requests.lisp index 5655e16..9ba68d2 100644 --- a/crates/app/src/public/html/misc/requests.lisp +++ b/crates/app/src/public/html/misc/requests.lisp @@ -113,21 +113,24 @@ ("id" "files_list") ("class" "flex gap-2 flex-wrap")) (div - ("class" "flex flex-wrap w-full gap-2") - (text "{{ components::create_post_options() }}") + ("class" "flex w-full justify-between flex-collapse gap-2") + (div + ("class" "flex flex-wrap w-full gap-2") + (text "{{ components::create_post_options() }}") + (button + ("type" "button") + ("class" "red lowered") + ("onclick" "trigger('me::remove_question', ['{{ question[0].id }}'])") + (text "{{ text \"general:action.delete\" }}")) + (button + ("type" "button") + ("class" "red lowered") + ("onclick" "trigger('me::ip_block_question', ['{{ question[0].id }}'])") + (text "{{ text \"auth:action.ip_block\" }}"))) + (button ("class" "primary") - (text "{{ text \"requests:label.answer\" }}")) - (button - ("type" "button") - ("class" "red lowered") - ("onclick" "trigger('me::remove_question', ['{{ question[0].id }}'])") - (text "{{ text \"general:action.delete\" }}")) - (button - ("type" "button") - ("class" "red lowered") - ("onclick" "trigger('me::ip_block_question', ['{{ question[0].id }}'])") - (text "{{ text \"auth:action.ip_block\" }}"))))) + (text "{{ text \"requests:label.answer\" }}"))))) (text "{% endfor %}"))) (text "{{ components::pagination(page=page, items=requests|length, key=\"&id=\", value=profile.id) }}")) diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index 65813b2..e64ec63 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -68,8 +68,8 @@ (text "Unban")) (text "{%- endif %}"))) (script - (text "setTimeout(() => { - const ui = ns(\"ui\"); + (text "setTimeout(async () => { + const ui = await ns(\"ui\"); const element = document.getElementById(\"mod_options\"); async function profile_request(do_confirm, path, body) { @@ -202,6 +202,20 @@ (text "{% for user in associations -%}") (text "{{ components::user_plate(user=user, show_menu=false) }}") (text "{%- endfor %}"))) + (text "{% if invite -%}") + (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 \"ticket\" }}") + (span + (text "{{ text \"mod_panel:label.invited_by\" }}")))) + (div + ("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 @@ -221,8 +235,8 @@ ("class" "card lowered flex flex-col gap-2") ("id" "permission_builder"))) (script - (text "setTimeout(() => { - const get_permissions_html = trigger( + (text "setTimeout(async () => { + const get_permissions_html = await trigger( \"ui::generate_permissions_ui\", [ { @@ -256,6 +270,8 @@ MANAGE_STACKS: 1 << 26, STAFF_BADGE: 1 << 27, MANAGE_APPS: 1 << 28, + MANAGE_JOURNALS: 1 << 29, + MANAGE_NOTES: 1 << 30, }, ], ); diff --git a/crates/app/src/public/html/mod/stats.lisp b/crates/app/src/public/html/mod/stats.lisp index 65e895d..d9ac9f5 100644 --- a/crates/app/src/public/html/mod/stats.lisp +++ b/crates/app/src/public/html/mod/stats.lisp @@ -29,6 +29,15 @@ (b (text "Socket tasks: ")) (span - (text "{{ (active_users_chats + active_users) * 3 }}"))))))) + (text "{{ (active_users_chats + active_users) * 3 }}")))) + + (hr) + (ul + (li (b (text "Users: ")) (span (text "{{ table_users }}"))) + (li (b (text "IP bans: ")) (span (text "{{ table_ipbans }}"))) + (li (b (text "Invite codes: ")) (span (text "{{ table_invite_codes }}"))) + (li (b (text "Posts: ")) (span (text "{{ table_posts }}"))) + (li (b (text "Uploads: ")) (span (text "{{ table_uploads }}"))) + (li (b (text "Communities: ")) (span (text "{{ table_communities }}"))))))) (text "{% endblock %}") diff --git a/crates/app/src/public/html/post/post.lisp b/crates/app/src/public/html/post/post.lisp index 705cec2..11a5156 100644 --- a/crates/app/src/public/html/post/post.lisp +++ b/crates/app/src/public/html/post/post.lisp @@ -2,6 +2,41 @@ (title (text "Post - {{ config.name }}")) +(meta + ("name" "og:title") + ("content" "{% if owner.settings.display_name -%} {{ owner.settings.display_name }} {%- else -%} {{ owner.username }} {%- endif %}'s post")) + +(meta + ("name" "description") + ("content" "View this post from @{{ owner.username }} on {{ config.name }}!")) + +(meta + ("name" "og:description") + ("content" "View this post from @{{ owner.username }} on {{ config.name }}!")) + +(meta + ("property" "og:type") + ("content" "website")) + +(meta + ("name" "og:image") + ("content" "{{ config.host|safe }}/api/v1/auth/user/{{ owner.username }}/avatar?selector_type=username")) + +(meta + ("name" "twitter:image") + ("content" "{{ config.host|safe }}/api/v1/auth/user/{{ owner.username }}/avatar?selector_type=usernamev")) + +(meta + ("name" "twitter:card") + ("content" "summary")) + +(meta + ("name" "twitter:title") + ("content" "{% if owner.settings.display_name -%} {{ owner.settings.display_name }} {%- else -%} {{ owner.username }} {%- endif %}'s post")) + +(meta + ("name" "twitter:description") + ("content" "View this post from @{{ owner.username }} on {{ config.name }}!")) (text "{% endblock %} {% block body %} {{ macros::nav() }}") (main ("class" "flex flex-col gap-2") @@ -110,8 +145,8 @@ (span (text "{{ text \"general:action.save\" }}"))) (script - (text "setTimeout(() => { - const ui = ns(\"ui\"); + (text "setTimeout(async () => { + const ui = await ns(\"ui\"); const element = document.getElementById(\"post_context\"); const settings = JSON.parse(\"{{ post_context_serde|safe }}\"); @@ -298,6 +333,7 @@ JSON.stringify({ content: e.target.content.value, community: \"{{ community.id }}\", + stack: \"{{ post.stack }}\", replying_to: \"{{ post.id }}\", poll: poll_data[1], }), diff --git a/crates/app/src/public/html/profile/base.lisp b/crates/app/src/public/html/profile/base.lisp index 5e846ff..7718eea 100644 --- a/crates/app/src/public/html/profile/base.lisp +++ b/crates/app/src/public/html/profile/base.lisp @@ -132,9 +132,11 @@ (text "{{ profile.settings.biography|markdown|safe }}")) (div ("class" "card flex flex-col gap-2") + (text "{% if user -%}") (div ("style" "display: contents;") (text "{% if profile.connections.Spotify and profile.connections.Spotify[0].data.name -%} {{ components::spotify_playing(state=profile.connections.Spotify[1]) }} {% elif profile.connections.LastFm and profile.connections.LastFm[0].data.name %} {{ components::last_fm_playing(state=profile.connections.LastFm[1]) }} {%- endif %}")) + (text "{%- endif %}") (div ("class" "w-full flex justify-between items-center") (span diff --git a/crates/app/src/public/html/profile/media.lisp b/crates/app/src/public/html/profile/media.lisp index a276c90..a3888dc 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) }}")) + (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}")) (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 316aa62..d77d314 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) }}")) + (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}")) (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 325f3ac..6c417a6 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) }}")) + (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}")) (text "{%- endif %} {% if not tag and pinned|length != 0 -%}") (div @@ -27,7 +27,7 @@ (text "{{ text \"auth:label.recent_posts\" }}")) (text "{% else %} {{ icon \"tag\" }}") (span - (text "{{ text \"auth:label.recent_with_tag\" }}:") + (text "{{ text \"auth:label.recent_with_tag\" }}: ") (b (text "{{ tag }}"))) (text "{%- endif %}")) @@ -40,7 +40,15 @@ (text "{{ text \"general:link.search\" }}"))) (text "{%- endif %}")) (div - ("class" "card flex flex-col gap-4") - (text "{% for post in posts %} {% 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 %} {{ components::pagination(page=page, items=posts|length, key=\"&tag=\", value=tag) }}"))) + ("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 }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]); + (await ns(\"ui\")).IO_DATA_DISABLE_RELOAD = true; + }, 500);")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/profile/replies.lisp b/crates/app/src/public/html/profile/replies.lisp index ff54816..4afe348 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) }}")) + (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}")) (text "{%- endif %} {{ macros::profile_nav(selected=\"replies\") }}") (div diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 8514e34..2b12aa0 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -20,7 +20,7 @@ ("class" "dropdown") ("style" "width: max-content") (button - ("class" "camo raised small") + ("class" "raised small") ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") (icon (text "sliders-horizontal")) @@ -61,21 +61,35 @@ ("href" "#/account/blocks") (text "{{ icon \"shield\" }}") (span - (text "{{ text \"settings:tab.blocks\" }}"))) + (text "{{ text \"settings:tab.blocks\" }}")))) + + (text "{% if config.stripe -%}") + ; stripe menu + (div + ("class" "pillmenu") + ("ui_ident" "account_settings_tabs") (a ("data-tab-button" "account/uploads") ("href" "?page=0#/account/uploads") (text "{{ icon \"image-up\" }}") (span (text "{{ text \"settings:tab.uploads\" }}"))) - (text "{% if config.stripe -%}") + (text "{% if config.security.enable_invite_codes -%}") + (a + ("data-tab-button" "account/invites") + ("href" "?page=0#/account/invites") + (text "{{ icon \"ticket\" }}") + (span + (text "{{ text \"settings:tab.invites\" }}"))) + (text "{%- endif %}") (a ("data-tab-button" "account/billing") ("href" "#/account/billing") (text "{{ icon \"credit-card\" }}") (span - (text "{{ text \"settings:tab.billing\" }}"))) - (text "{%- endif %}")) + (text "{{ text \"settings:tab.billing\" }}")))) + (text "{%- endif %}") + (div ("class" "card-nest") ("ui_ident" "home_timeline") @@ -388,6 +402,22 @@ (text "{{ icon \"arrow-left\" }}") (span (text "{{ text \"general:action.back\" }}"))) + + ; stack blocks + (div + ("class" "card-nest") + (div + ("class" "card flex items-center gap-2 small") + (text "{{ icon \"layers\" }}") + (span + (text "{{ text \"stacks:link.stacks\" }}"))) + (div + ("class" "card flex flex-col gap-2") + (text "{% for stack in stackblocks %}") + (text "{{ components::stack_listing(stack=stack) }}") + (text "{% endfor %}"))) + + ; user blocks (div ("class" "card-nest") (div @@ -403,12 +433,19 @@ (div ("class" "flex gap-2") (text "{{ components::avatar(username=user.username) }} {{ components::full_username(user=user) }}")) - (a - ("href" "/@{{ user.username }}") - ("class" "button lowered small") - (text "{{ icon \"external-link\" }}") - (span - (text "{{ text \"requests:action.view_profile\" }}")))) + (div + ("class" "flex gap-2") + (a + ("href" "/stacks/add_user/{{ user.id }}") + ("target" "_blank") + ("class" "button lowered small") + (icon (text "plus")) + (span (str (text "settings:label.add_to_stack")))) + (a + ("href" "/@{{ user.username }}") + ("class" "button lowered small") + (icon (text "external-link")) + (span (str (text "requests:action.view_profile")))))) (text "{% endfor %}"))))) (div ("class" "w-full flex flex-col gap-2 hidden") @@ -479,6 +516,88 @@ ]); }); };")))))) + + (text "{% if config.security.enable_invite_codes -%}") + (div + ("class" "w-full flex flex-col gap-2 hidden") + ("data-tab" "account/invites") + (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 \"ticket\" }}") + (span + (text "{{ text \"settings:tab.invites\" }}"))) + (div + ("class" "card flex flex-col gap-2 secondary") + (pre ("id" "invite_codes_output") ("class" "hidden") (code)) + (pre ("id" "invite_codes_error_output") ("class" "hidden") (code ("class" "red"))) + + (button + ("onclick" "generate_invite_codes()") + (icon (text "plus")) + (str (text "settings:label.generate_invites"))) + + (text "{{ components::supporter_ad(body=\"Become a supporter to generate up to 48 invite codes! You can currently have 2 maximum.\") }} {% for code in invites %}") + (div + ("class" "card flex flex-col gap-2") + (text "{% if code[1].is_used -%}") + ; used + (b ("class" "{% if code[1].is_used -%} fade {%- endif %}") (s (text "{{ code[1].code }}"))) + (text "{{ components::full_username(user=code[0]) }}") + (text "{% else %}") + ; unused + (b (text "{{ code[1].code }}")) + (text "{%- endif %}")) + (text "{% endfor %}") + (text "{{ components::pagination(page=page, items=invites|length, key=\"#/account/invites\") }}") + (script + (text "globalThis.generate_invite_codes = async () => { + await trigger(\"atto::debounce\", [\"invites::create\"]); + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this? This action is permanent.\", + ])) + ) { + return; + } + + const count = Number.parseInt(await trigger(\"atto::prompt\", [\"Count (1-48):\"])); + + if (!count) { + return; + } + + document.getElementById(\"invite_codes_output\").classList.remove(\"hidden\"); + document.getElementById(\"invite_codes_error_output\").classList.remove(\"hidden\"); + document.getElementById(\"invite_codes_output\").children[0].innerText = \"Working... expect to wait 50ms per invite code\"; + + fetch(`/api/v1/invites/${count}`, { + method: \"POST\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + document.getElementById(\"invite_codes_output\").children[0].innerText = res.payload[0]; + document.getElementById(\"invite_codes_error_output\").children[0].innerText = res.payload[1]; + } + }); + };")))))) + (text "{%- endif %}") + (div ("class" "w-full flex flex-col gap-2 hidden") ("data-tab" "account/billing") @@ -548,14 +667,14 @@ (li (text "Use custom CSS on your profile")) (li - (text "Ability to use community emojis outside of + (text "Use community emojis outside of their community")) (li - (text "Ability to upload and use gif emojis")) + (text "Upload and use gif emojis")) (li (text "Create infinite stack timelines")) (li - (text "Ability to upload images to posts")) + (text "Upload images to posts")) (li (text "Save infinite post drafts")) (li @@ -563,7 +682,24 @@ (li (text "Ability to create forges")) (li - (text "Ability to create more than 1 app"))) + (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") @@ -901,8 +1037,8 @@ ("id" "settings_json") (text "{{ profile.settings|json_encode()|remove_script_tags|safe }}")) (script - (text "setTimeout(() => { - const ui = ns(\"ui\"); + (text "setTimeout(async () => { + const ui = await ns(\"ui\"); const settings = JSON.parse( document.getElementById(\"settings_json\").innerHTML, ); @@ -1333,6 +1469,16 @@ \"{{ profile.settings.show_nsfw }}\", \"checkbox\", ], + [ + [\"auto_unlist\", \"Automatically mark my posts as NSFW\"], + \"{{ profile.settings.auto_unlist }}\", + \"checkbox\", + ], + [ + [\"all_timeline_hide_answers\", 'Hide posts answering questions from the \"All\" timeline'], + \"{{ profile.settings.all_timeline_hide_answers }}\", + \"checkbox\", + ], [[], \"Questions\", \"title\"], [ [ @@ -1350,6 +1496,14 @@ \"{{ profile.settings.allow_anonymous_questions }}\", \"checkbox\", ], + [ + [ + \"enable_drawings\", + \"Allow users to create drawings and submit them with questions\", + ], + \"{{ profile.settings.enable_drawings }}\", + \"checkbox\", + ], [ [\"motivational_header\", \"Motivational header\"], settings.motivational_header, @@ -1374,9 +1528,19 @@ ], [ [], - \"Hides dislikes on all posts. Users can still dislike your posts, you just won't be able to see it.\", + \"Hides dislikes on all posts. Users will also no longer be able to dislike your posts.\", \"text\", ], + [ + [\"paged_timelines\", \"Make timelines paged instead of infinitely scrolled\"], + \"{{ profile.settings.paged_timelines }}\", + \"checkbox\", + ], + [ + [\"auto_clear_notifs\", \"Automatically clear all notifications when you open the notifications page\"], + \"{{ profile.settings.auto_clear_notifs }}\", + \"checkbox\", + ], [[], \"Fun\", \"title\"], [ [\"disable_gpa_fun\", \"Disable GPA\"], diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp index c7867b1..a4288b3 100644 --- a/crates/app/src/public/html/root.lisp +++ b/crates/app/src/public/html/root.lisp @@ -7,10 +7,9 @@ (meta ("charset" "UTF-8")) (meta ("name" "viewport") ("content" "width=device-width, initial-scale=1.0")) (meta ("http-equiv" "X-UA-Compatible") ("content" "ie=edge")) - (meta ("http-equiv" "content-security-policy") ("content" "default-src 'self' blob: *.spotify.com musicbrainz.org; 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: *")) (link ("rel" "icon") ("href" "/public/favicon.svg")) - (link ("rel" "stylesheet") ("href" "/css/style.css")) + (link ("rel" "stylesheet") ("href" "/css/style.css?v=tetratto-{{ random_cache_breaker }}")) (text "{% if user -%} ") - (script ("src" "/js/loader.js" )) - (script ("src" "/js/atto.js" )) + (script ("src" "/js/loader.js?v=tetratto-{{ random_cache_breaker }}" )) + (script ("src" "/js/atto.js?v=tetratto-{{ random_cache_breaker }}" )) (meta ("name" "theme-color") ("content" "{{ config.color }}")) (meta ("name" "description") ("content" "{{ config.description }}")) diff --git a/crates/app/src/public/html/stacks/add_user.lisp b/crates/app/src/public/html/stacks/add_user.lisp new file mode 100644 index 0000000..214decd --- /dev/null +++ b/crates/app/src/public/html/stacks/add_user.lisp @@ -0,0 +1,49 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "Add user to stack - {{ config.name }}")) + +(text "{% endblock %} {% block body %} {{ macros::nav(selected=\"\") }}") +(main + ("class" "flex flex-col gap-2") + (div + ("class" "card-nest") + (div + ("class" "card small flex items-center gap-2") + (text "{{ components::avatar(username=add_user.username, size=\"24px\") }}") + (text "{{ components::full_username(user=add_user) }}")) + (div + ("class" "card flex flex-col gap-2") + (span (text "Select a stack to add this user to:")) + (text "{% for stack in stacks %}") + (button + ("class" "justify-start lowered w-full") + ("onclick" "choose_stack('{{ stack.id }}')") + (icon (text "layers")) + (text "{{ stack.name }}")) + (text "{% endfor %}")))) + +(script + (text "function choose_stack(id) { + fetch(`/api/v1/stacks/${id}/users`, { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + username: \"{{ add_user.username }}\", + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.close(); + } + }); + }")) + +(text "{% endblock %}") diff --git a/crates/app/src/public/html/stacks/feed.lisp b/crates/app/src/public/html/stacks/feed.lisp new file mode 100644 index 0000000..5002856 --- /dev/null +++ b/crates/app/src/public/html/stacks/feed.lisp @@ -0,0 +1,118 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "{{ stack.name }} - {{ config.name }}")) + +(text "{% endblock %} {% block body %} {{ macros::nav() }}") +(main + ("class" "flex flex-col gap-2") + (text "{{ macros::timelines_nav(selected=\"stacks\") }}") + (div + ("class" "card-nest w-full") + (div + ("class" "card small flex items-center justify-between gap-2") + (div + ("class" "flex items-center gap-2") + (a + ("href" "/api/v1/auth/user/find/{{ stack.owner }}") + (text "{{ components::avatar(username=stack.owner, selector_type=\"id\") }}")) + (span + (text "{{ stack.name }}"))) + (div + ("class" "flex gap-2") + (text "{% if stack.mode == 'Circle' -%}") + ; post button for circle stacks + (a + ("href" "/communities/intents/post?stack={{ stack.id }}") + ("class" "button lowered small") + (text "{{ icon \"plus\" }}") + (span + (text "{{ text \"general:action.post\" }}"))) + (text "{%- endif %}") + + (text "{% if user and user.id == stack.owner -%}") + ; manage button for stack owner only + (a + ("href" "/stacks/{{ stack.id }}/manage") + ("class" "button lowered small") + (text "{{ icon \"pencil\" }}") + (span + (text "{{ text \"general:action.manage\" }}"))) + (text "{%- endif %}"))) + (div + ("class" "card w-full flex flex-col gap-2") + (text "{% if stack.users|length == 0 -%}") + (p + (text "No users included yet! Maybe ") + (a + ("href" "/stacks/{{ stack.id }}/manage#/users") + (text "add a user to this stack")) + (text "!")) + (text "{%- endif %}") + + (text "{% if stack.mode == 'BlockList' -%}") + ; block button + user list for blocklist only + (text "{% if not is_blocked -%}") + (button + ("onclick" "block_all()") + (str (text "stacks:label.block_all"))) + (text "{% else %}") + (button + ("onclick" "block_all(false)") + (str (text "stacks:label.unblock_all"))) + (text "{%- endif %}") + (div + ("class" "flex gap-2 flex-wrap w-full") + (text "{% for user in list %} {{ components::user_plate(user=user, secondary=true) }} {% endfor %}")) + (text "{{ components::pagination(page=page, items=list|length) }}") + (text "{% else %}") + ; user icons for circle stack + (text "{% if stack.mode == 'Circle' -%}") + (div + ("class" "flex w-full gap-2 flex-wrap") + (text "{% for user in stack.users %}") + (a + ("href" "/api/v1/auth/user/find/{{ user }}") + ("class" "flush") + (text "{{ components::avatar(username=user, selector_type=\"id\", size=\"24px\") }}")) + (text "{% endfor %}")) + (text "{%- endif %}") + + ; posts for all stacks except blocklist + (div + ("class" "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(() => { + trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?stack_id={{ stack.id }}&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]); + });")) + (text "{%- endif %}")))) + +(script + (text "async function block_all(block = true) { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/stacks/{{ stack.id }}/block\", { + method: block ? \"POST\" : \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.href = \"/settings#/account/blocks\"; + } + }); + }")) +(text "{% endblock %}") diff --git a/crates/app/src/public/html/stacks/list.lisp b/crates/app/src/public/html/stacks/list.lisp index 6ef2cab..50246ef 100644 --- a/crates/app/src/public/html/stacks/list.lisp +++ b/crates/app/src/public/html/stacks/list.lisp @@ -44,20 +44,7 @@ (div ("class" "card flex flex-col gap-2") (text "{% for item in list %}") - (a - ("href" "/stacks/{{ item.id }}") - ("class" "card secondary flex flex-col gap-2") - (div - ("class" "flex items-center gap-2") - (text "{{ icon \"list\" }}") - (b - (text "{{ item.name }}"))) - (span - (text "Created ") - (span - ("class" "date") - (text "{{ item.created }}")) - (text "; {{ item.privacy }}; {{ item.users|length }} users"))) + (text "{{ components::stack_listing(stack=item) }}") (text "{% endfor %}")))) (script diff --git a/crates/app/src/public/html/stacks/manage.lisp b/crates/app/src/public/html/stacks/manage.lisp index a05e680..450c027 100644 --- a/crates/app/src/public/html/stacks/manage.lisp +++ b/crates/app/src/public/html/stacks/manage.lisp @@ -63,7 +63,15 @@ (option ("value" "Exclude") ("selected" "{% if stack.mode == 'Exclude' -%}true{% else %}false{%- endif %}") - (text "Exclude"))))) + (text "Exclude")) + (option + ("value" "BlockList") + ("selected" "{% if stack.mode == 'BlockList' -%}true{% else %}false{%- endif %}") + (text "Block list")) + (option + ("value" "Circle") + ("selected" "{% if stack.mode == 'Circle' -%}true{% else %}false{%- endif %}") + (text "Circle"))))) (div ("class" "card-nest") ("ui_ident" "sort") @@ -96,7 +104,7 @@ (div ("class" "flex flex-col gap-1") (label - ("for" "new_title") + ("for" "name") (text "{{ text \"communities:label.name\" }}")) (input ("type" "text") @@ -165,7 +173,7 @@ return; } - fetch(`/api/v1/stacks/{{ stack.id }}/users`, { + fetch(\"/api/v1/stacks/{{ stack.id }}/users\", { method: \"POST\", headers: { \"Content-Type\": \"application/json\", diff --git a/crates/app/src/public/html/stacks/posts.lisp b/crates/app/src/public/html/stacks/posts.lisp deleted file mode 100644 index cddf9c7..0000000 --- a/crates/app/src/public/html/stacks/posts.lisp +++ /dev/null @@ -1,37 +0,0 @@ -(text "{% extends \"root.html\" %} {% block head %}") -(title - (text "{{ stack.name }} - {{ config.name }}")) - -(text "{% endblock %} {% block body %} {{ macros::nav() }}") -(main - ("class" "flex flex-col gap-2") - (text "{{ macros::timelines_nav(selected=\"stacks\") }}") - (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 \"list\" }}") - (span - (text "{{ stack.name }}"))) - (text "{% if user and user.id == stack.owner -%}") - (a - ("href" "/stacks/{{ stack.id }}/manage") - ("class" "button lowered small") - (text "{{ icon \"pencil\" }}") - (span - (text "{{ text \"general:action.manage\" }}"))) - (text "{%- endif %}")) - (div - ("class" "card w-full flex flex-col gap-2") - (text "{% if list|length == 0 -%}") - (p - (text "No posts yet! Maybe ") - (a - ("href" "/stacks/{{ stack.id }}/manage#/users") - (text "add a user to this stack")) - (text "!")) - (text "{%- endif %} {% 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 %} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=list|length) }}")))) - -(text "{% endblock %}") diff --git a/crates/app/src/public/html/timelines/all.lisp b/crates/app/src/public/html/timelines/all.lisp index ab3e688..7cced78 100644 --- a/crates/app/src/public/html/timelines/all.lisp +++ b/crates/app/src/public/html/timelines/all.lisp @@ -30,6 +30,13 @@ (text "{%- endif %}") (div ("class" "card w-full flex flex-col gap-2") - (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 %} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=list|length) }}"))) + ("ui_ident" "io_data_load") + (div ("ui_ident" "io_data_marker")))) + +(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\"]); + });")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/timelines/following.lisp b/crates/app/src/public/html/timelines/following.lisp index 642ab63..ef23a55 100644 --- a/crates/app/src/public/html/timelines/following.lisp +++ b/crates/app/src/public/html/timelines/following.lisp @@ -8,6 +8,13 @@ (text "{{ macros::timelines_nav(selected=\"following\", posts=\"/following\", questions=\"/following/questions\") }}") (div ("class" "card w-full flex flex-col gap-2") - (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 %} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=list|length) }}"))) + ("ui_ident" "io_data_load") + (div ("ui_ident" "io_data_marker")))) + +(text "{% set paged = user and user.settings.paged_timelines %}") +(script + (text "setTimeout(() => { + trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=FollowingPosts&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 4d1ce9d..5a5658b 100644 --- a/crates/app/src/public/html/timelines/home.lisp +++ b/crates/app/src/public/html/timelines/home.lisp @@ -27,7 +27,14 @@ (text "{% else %}") (div ("class" "card w-full flex flex-col gap-2") - (text "{% for post in list %} {% 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 %} {{ components::pagination(page=page, items=list|length) }}")) + ("ui_ident" "io_data_load") + (div ("ui_ident" "io_data_marker"))) (text "{%- endif %}")) +(text "{% set paged = user and user.settings.paged_timelines %}") +(script + (text "setTimeout(() => { + trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=MyCommunities&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]); + });")) + (text "{% endblock %}") diff --git a/crates/app/src/public/html/timelines/popular.lisp b/crates/app/src/public/html/timelines/popular.lisp index dfaef71..d0223df 100644 --- a/crates/app/src/public/html/timelines/popular.lisp +++ b/crates/app/src/public/html/timelines/popular.lisp @@ -8,6 +8,13 @@ (text "{{ macros::timelines_nav(selected=\"popular\", posts=\"/popular\", questions=\"/popular/questions\") }}") (div ("class" "card w-full flex flex-col gap-2") - (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 %} {%- endif %} {% endfor %} {{ components::pagination(page=page, items=list|length) }}"))) + ("ui_ident" "io_data_load") + (div ("ui_ident" "io_data_marker")))) + +(text "{% set paged = user and user.settings.paged_timelines %}") +(script + (text "setTimeout(() => { + trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=PopularPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]); + });")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/timelines/search.lisp b/crates/app/src/public/html/timelines/search.lisp index dc6c42d..ef739fe 100644 --- a/crates/app/src/public/html/timelines/search.lisp +++ b/crates/app/src/public/html/timelines/search.lisp @@ -53,7 +53,7 @@ ("title" "Search help") ("href" "{{ config.manuals.search_help }}") ("target" "_blank") - (text "{{ icon \"circle-help\" }}")) + (text "{{ icon \"circle-question-mark\" }}")) (text "{%- endif %}")))) (text "{%- endif %}") (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 %} {%- endif %} {% endfor %} {% if profile -%} {{ components::pagination(page=page, items=list|length, key=\"&profile=\" ~ profile.id, value=\"&query=\" ~ query) }} {% else %} {{ components::pagination(page=page, items=list|length, key=\"&query=\" ~ query) }} {%- endif %}")))) diff --git a/crates/app/src/public/html/timelines/swiss_army.lisp b/crates/app/src/public/html/timelines/swiss_army.lisp new file mode 100644 index 0000000..23243ce --- /dev/null +++ b/crates/app/src/public/html/timelines/swiss_army.lisp @@ -0,0 +1,36 @@ +(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 %} + {%- endif %} + {% endfor %}") +(datalist + ("ui_ident" "list_posts_{{ page }}") + (text "{% for post in list -%}") + (option ("value" "{{ post[0].id }}")) + (text "{%- endfor %}")) +(text "{% if list|length == 0 -%}") +(div + ("class" "card lowered green flex justify-between items-center gap-2") + (div + ("class" "flex items-center gap-2") + (text "{{ icon \"shell\" }}") + (span + (str (text "general:label.timeline_end")) + (text ""))) + (text "{% if page > 0 -%}") + (a + ("class" "button") + ("href" "?page=0") + (icon (text "arrow-up")) + (str (text "chats:label.go_back"))) + (text "{%- endif %}")) +(text "{%- endif %}") + +(text "{% if paginated -%}") +(text "{{ components::pagination(page=page, items=list|length) }}") +(text "{%- endif %}") diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 1e34ef2..0bd8d62 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -25,10 +25,10 @@ function media_theme_pref() { } } -function set_theme(theme) { +window.set_theme = (theme) => { window.localStorage.setItem("tetratto:theme", theme); document.documentElement.className = theme; -} +}; media_theme_pref(); @@ -39,6 +39,7 @@ media_theme_pref(); // init use("me", () => {}); use("streams", () => {}); + use("carp", () => {}); // env self.DEBOUNCE = []; @@ -91,7 +92,7 @@ media_theme_pref(); self.define("rel_date", (_, date) => { // stolen and slightly modified because js dates suck - const diff = Math.abs((new Date().getTime() - date.getTime()) / 1000); + const diff = Math.abs((Date.now() - date.getTime()) / 1000); const day_diff = Math.floor(diff / 86400); if (Number.isNaN(day_diff) || day_diff < 0 || day_diff >= 31) { @@ -116,7 +117,7 @@ media_theme_pref(); ); }); - self.define("clean_date_codes", ({ $ }) => { + self.define("clean_date_codes", async ({ $ }) => { for (const element of Array.from(document.querySelectorAll(".date"))) { if (element.getAttribute("data-unix")) { // this allows us to run the function twice on the same page @@ -133,7 +134,7 @@ media_theme_pref(); element.setAttribute("title", then.toLocaleString()); - let pretty = $.rel_date(then) || ""; + let pretty = (await $.rel_date(then)) || ""; if ( (screen.width < 900 && pretty !== undefined) | @@ -287,7 +288,7 @@ media_theme_pref(); const goals = [150, 250, 500, 1000]; track_element.setAttribute("data-scroll", "0"); - scroll_element.addEventListener("scroll", (e) => { + scroll_element.addEventListener("scroll", (_) => { track_element.setAttribute("data-scroll", scroll_element.scrollTop); for (const goal of goals) { @@ -396,7 +397,7 @@ media_theme_pref(); counter.innerText = `${target.value.length}/${target.getAttribute("maxlength")}`; }); - self.define("hooks::character_counter.init", (_, event) => { + self.define("hooks::character_counter.init", (_) => { for (const element of Array.from( document.querySelectorAll("[hook=counter]") || [], )) { @@ -413,7 +414,7 @@ media_theme_pref(); element.innerHTML = full_text; }); - self.define("hooks::long_text.init", (_, event) => { + self.define("hooks::long_text.init", (_) => { for (const element of Array.from( document.querySelectorAll("[hook=long]") || [], )) { @@ -453,18 +454,18 @@ media_theme_pref(); } }); - self.define("hooks::spotify_time_text", (_) => { + self.define("hooks::spotify_time_text", async (_) => { for (const element of Array.from( document.querySelectorAll("[hook=spotify_time_text]") || [], )) { - function render() { + async function render() { const updated = element.getAttribute("hook-arg:updated"); const progress = element.getAttribute("hook-arg:progress"); const duration = element.getAttribute("hook-arg:duration"); const display = element.getAttribute("hook-arg:display") || "full"; - element.innerHTML = trigger("spotify::timestamp", [ + element.innerHTML = await trigger("spotify::timestamp", [ updated, progress, duration, @@ -472,7 +473,7 @@ media_theme_pref(); ]); } - setInterval(() => { + setInterval(async () => { element.setAttribute( "hook-arg:updated", Number.parseInt(element.getAttribute("hook-arg:updated")) + @@ -485,21 +486,21 @@ media_theme_pref(); 1000, ); - render(); + await render(); }, 1000); - render(); + await render(); } }); self.define("last_seen_just_now", (_, last_seen) => { - const now = new Date().getTime(); + const now = Date.now(); const maximum_time_to_be_considered_online = 60000 * 2; // 2 minutes return now - last_seen <= maximum_time_to_be_considered_online; }); self.define("last_seen_recently", (_, last_seen) => { - const now = new Date().getTime(); + const now = Date.now(); const maximum_time_to_be_considered_idle = 60000 * 5; // 5 minutes return now - last_seen <= maximum_time_to_be_considered_idle; }); @@ -585,8 +586,8 @@ media_theme_pref(); self.define( "hooks::attach_to_partial", - ({ $ }, partial, full, attach, wrapper, page, run_on_load) => { - return new Promise((resolve, reject) => { + ({ $ }, partial, full, attach, wrapper, page) => { + return new Promise((resolve, _) => { async function load_partial() { const url = `${partial}${partial.includes("?") ? "&" : "?"}page=${page}`; history.replaceState( @@ -618,7 +619,6 @@ media_theme_pref(); .catch(() => { // done scrolling, no more pages (http error) wrapper.removeEventListener("scroll", event); - resolve(); }); } @@ -635,7 +635,6 @@ media_theme_pref(); return; } - // biome-ignore lint/style/noParameterAssign: no it isn't page += 1; await load_partial(); }) @@ -651,7 +650,7 @@ media_theme_pref(); ); self.define("hooks::check_reactions", async ({ $ }) => { - const observer = $.offload_work_to_client_when_in_view( + const observer = await $.offload_work_to_client_when_in_view( async (element) => { const like = element.querySelector( '[hook_element="reaction.like"]', @@ -687,7 +686,39 @@ media_theme_pref(); $.OBSERVERS.push(observer); }); + self.define("hooks::check_message_reactions", async ({ $ }) => { + const observer = $.offload_work_to_client_when_in_view( + async (element) => { + const reactions = await ( + await fetch( + `/api/v1/message_reactions/${element.getAttribute("hook-arg:id")}`, + ) + ).json(); + + if (reactions.ok) { + for (const reaction of reactions.payload) { + element + .querySelector( + `[ui_ident=emoji_${reaction.emoji.replaceAll(":", "\\:")}]`, + ) + .classList.remove("lowered"); + } + } + }, + ); + + for (const element of Array.from( + document.querySelectorAll("[hook=check_message_reactions]") || [], + )) { + observer.observe(element); + } + + $.OBSERVERS.push(observer); + }); + self.define("hooks::tabs:switch", (_, tab) => { + tab = tab.split("?")[0]; + // tab for (const element of Array.from( document.querySelectorAll("[data-tab]"), @@ -805,7 +836,6 @@ media_theme_pref(); }, time_until_remove * 1000); const count_interval = setInterval(() => { - // biome-ignore lint/style/noParameterAssign: no it isn't time_until_remove -= 1; timer.innerText = time_until_remove; }, 1000); @@ -848,7 +878,7 @@ media_theme_pref(); })(); // ui ns -(() => { +(async () => { const self = reg_ns("ui"); window.SETTING_SET_FUNCTIONS = []; @@ -886,7 +916,7 @@ media_theme_pref(); } if (option.input_element_type === "checkbox") { - into_element.innerHTML += `
+ into_element.innerHTML += `
` : ""} window.update_field_with_color = (key, value) => { console.log("sync_color_text", key); document.getElementById(key).value = value; - set_setting_field(key, value); + window.SETTING_SET_FUNCTIONS[0](key, value); preview_color(key, value); }; @@ -1104,24 +1134,9 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} ); // lightbox - self.define("lightbox_open", async (_, src) => { + self.define("lightbox_open", (_, src) => { document.getElementById("lightbox_img").src = src; document.getElementById("lightbox_img_a").href = src; - - await (async () => { - return new Promise((resolve, reject) => { - let idx = 0; - const inter = setInterval(() => { - idx += 1; - if (document.getElementById("lightbox_img").complete) { - console.log(`img loaded (took ${idx})`); - clearInterval(inter); - return resolve(); - } - }, 25); - }); - })(); - document.getElementById("lightbox").classList.remove("hidden"); }); @@ -1134,6 +1149,187 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} document.getElementById("lightbox").classList.add("hidden"); }, 250); }); + + // intersection observer infinite scrolling + const obs = (await ns("ui")).IO_DATA_OBSERVER; + if (obs) { + console.log("get lost old observer"); + obs.disconnect(); + } + + self.IO_DATA_OBSERVER = new IntersectionObserver( + async (entries) => { + for (const entry of entries) { + if (!entry.isIntersecting) { + continue; + } + + await self.io_load_data(); + break; + } + }, + { + root: document.body, + rootMargin: "0px", + threshold: 1, + }, + ); + + self.define("io_data_load", (_, tmpl, page, paginated_mode = false) => { + self.IO_DATA_MARKER = document.querySelector( + "[ui_ident=io_data_marker]", + ); + + self.IO_DATA_ELEMENT = document.querySelector( + "[ui_ident=io_data_load]", + ); + + self.IO_HTML_TMPL = document.getElementById("loading_skeleton"); + + if (!self.IO_DATA_ELEMENT || !self.IO_DATA_MARKER) { + console.warn( + "ui::io_data_load called, but required elements don't exist", + ); + + return; + } + + self.IO_DATA_TMPL = tmpl; + self.IO_DATA_PAGE = page; + self.IO_DATA_SEEN_IDS = []; + self.IO_DATA_WAITING = false; + self.IO_HAS_LOADED_AT_LEAST_ONCE = false; + self.IO_DATA_DISCONNECTED = false; + self.IO_DATA_DISABLE_RELOAD = false; + + if (!paginated_mode) { + self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER); + } else { + // immediately load first page + self.IO_DATA_TMPL = self.IO_DATA_TMPL.replace("&page=", ""); + self.IO_DATA_TMPL += `&paginated=true&page=`; + self.io_load_data(); + } + + setTimeout(() => { + if (self.IO_DATA_DISABLE_RELOAD) { + console.log("missing data reload disabled"); + return; + } + + if (!self.IO_HAS_LOADED_AT_LEAST_ONCE) { + // reload + self.IO_DATA_OBSERVER.disconnect(); + console.log("timeline load fail :("); + window.location.reload(); + } + }, 1500); + + self.IO_PAGINATED = paginated_mode; + }); + + self.define("io_load_data", async () => { + if (self.IO_DATA_WAITING) { + return; + } + + self.IO_HAS_LOADED_AT_LEAST_ONCE = true; + self.IO_DATA_WAITING = true; + self.IO_DATA_PAGE += 1; + console.log("load page", self.IO_DATA_PAGE); + + // show loading component + const loading = self.IO_HTML_TMPL.content.cloneNode(true); + self.IO_DATA_ELEMENT.appendChild(loading); + + // ... + const text = await ( + await fetch(`${self.IO_DATA_TMPL}${self.IO_DATA_PAGE}`) + ).text(); + + self.IO_DATA_WAITING = false; + + const loading_skel = self.IO_DATA_ELEMENT.querySelector( + "[ui_ident=loading_skel]", + ); + + if (loading_skel) { + loading_skel.remove(); + } + + if (self.IO_DATA_DISCONNECTED) { + return; + } + + if ( + text.includes(`!`) + ) { + console.log("io_data_end; disconnect"); + self.IO_DATA_OBSERVER.disconnect(); + self.IO_DATA_ELEMENT.innerHTML += text; + self.IO_DATA_DISCONNECTED = true; + return; + } + + self.IO_DATA_ELEMENT.innerHTML += text; + + setTimeout(() => { + // move marker to bottom of dom hierarchy + self.IO_DATA_ELEMENT.children[ + 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( + `[ui_ident=list_posts_${self.IO_DATA_PAGE}] option`, + ), + )) { + const v = opt.getAttribute("value"); + + if (!self.IO_DATA_SEEN_IDS[v]) { + self.IO_DATA_SEEN_IDS.push(v); + } + } + }, 150); + + // run hooks + const atto = await ns("atto"); + + atto.clean_date_codes(); + atto.clean_poll_date_codes(); + atto.link_filter(); + + atto["hooks::long_text.init"](); + atto["hooks::alt"](); + atto["hooks::online_indicator"](); + atto["hooks::verify_emoji"](); + atto["hooks::check_reactions"](); + }); })(); (() => { @@ -1145,7 +1341,7 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} self.define( "open", - async ({ $ }, warning_id, warning_hash, warning_page = "") => { + async (_, warning_id, warning_hash, warning_page = "") => { // check localStorage for this warning_id if (accepted_warnings[warning_id] !== undefined) { // check hash @@ -1166,7 +1362,7 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} }, ); - self.define("accept", ({ _ }, warning_id, warning_hash) => { + self.define("accept", (_, warning_id, warning_hash) => { accepted_warnings[warning_id] = warning_hash; window.localStorage.setItem( diff --git a/crates/app/src/public/js/carp.js b/crates/app/src/public/js/carp.js new file mode 100644 index 0000000..dbef3ed --- /dev/null +++ b/crates/app/src/public/js/carp.js @@ -0,0 +1,624 @@ +(() => { + const self = reg_ns("carp"); + + const END_OF_HEADER = 0x1a; + const COLOR = 0x1b; + const SIZE = 0x2b; + const LINE = 0x3b; + const POINT = 0x4b; + const EOF = 0x1f; + + function enc(s, as = "guess") { + if ((as === "guess" && typeof s === "number") || as === "u32") { + // encode u32 + const view = new DataView(new ArrayBuffer(16)); + view.setUint32(0, s); + return new Uint8Array(view.buffer).slice(0, 4); + } + + if (as === "u16") { + // encode u16 + const view = new DataView(new ArrayBuffer(16)); + view.setUint16(0, s); + return new Uint8Array(view.buffer).slice(0, 2); + } + + // encode string + const encoder = new TextEncoder(); + return encoder.encode(s); + } + + function dec(as, from) { + if (as === "u32") { + // decode u32 + const view = new DataView(new Uint8Array(from).buffer); + return view.getUint32(0); + } + + if (as === "u16") { + // decode u16 + const view = new DataView(new Uint8Array(from).buffer); + return view.getUint16(0); + } + + // decode string + const decoder = new TextDecoder(); + return decoder.decode(from); + } + + function lpad(size, input) { + if (input.length === size) { + return input; + } + + for (let i = 0; i < size - (input.length - 1); i++) { + input = [0, ...input]; + } + + return input; + } + + self.enc = enc; + self.dec = dec; + self.lpad = lpad; + + self.CARPS = {}; + self.define("new", function ({ $ }, bind_to, read_only = false) { + const canvas = new CarpCanvas(bind_to, read_only); + $.CARPS[bind_to.getAttribute("ui_ident")] = canvas; + return canvas; + }); + + class CarpCanvas { + #element; // HTMLElement + #ctx; // CanvasRenderingContext2D + #pos = { x: 0, y: 0 }; // Vec2 + + STROKE_SIZE = 2; + #stroke_size_old = 2; + COLOR = "#000000"; + #color_old = "#000000"; + + COMMANDS = []; + HISTORY = []; + HISTORY_IDX = 0; + #cmd_store = []; + #undo_clear_future = false; // if we should clear to HISTORY_IDX on next draw + + onedit; + read_only; + + /// Create a new [`CarpCanvas`] + constructor(element, read_only) { + this.#element = element; + this.read_only = read_only; + } + + /// Push #line_store to LINES + push_state() { + this.COMMANDS = [...this.COMMANDS, ...this.#cmd_store]; + this.#cmd_store = []; + + this.HISTORY.push(this.COMMANDS); + this.HISTORY_IDX += 1; + + if (this.#undo_clear_future) { + this.HISTORY = this.HISTORY.slice(0, this.HISTORY_IDX); + this.#undo_clear_future = false; + } + + if (this.onedit) { + this.onedit(this.as_string()); + } + } + + /// Read current position in history and draw it. + draw_from_history() { + this.COMMANDS = this.HISTORY[this.HISTORY_IDX]; + const bytes = this.as_carp2(); + this.from_bytes(bytes); // draw + } + + /// Undo changes. + undo() { + if (this.HISTORY_IDX === 0) { + // cannot undo + return; + } + + this.HISTORY_IDX -= 1; + this.draw_from_history(); + this.#undo_clear_future = false; + } + + /// Redo changes. + redo() { + if (this.HISTORY_IDX === this.HISTORY.length - 1) { + // cannot redo + return; + } + + this.HISTORY_IDX += 1; + this.draw_from_history(); + } + + /// Create canvas and init context + async create_canvas() { + const canvas = document.createElement("canvas"); + + canvas.width = "300"; + canvas.height = "200"; + + this.#ctx = canvas.getContext("2d"); + + if (!this.read_only) { + // desktop + canvas.addEventListener( + "mousemove", + (e) => { + this.draw_event(e); + }, + false, + ); + + canvas.addEventListener( + "mouseup", + (e) => { + this.push_state(); + }, + false, + ); + + canvas.addEventListener( + "mousedown", + (e) => { + this.#cmd_store.push({ + type: "Line", + data: [], + }); + + this.move_event(e); + }, + false, + ); + + canvas.addEventListener( + "mouseenter", + (e) => { + this.move_event(e); + }, + false, + ); + + // mobile + canvas.addEventListener( + "touchmove", + (e) => { + e.preventDefault(); + + e.clientX = e.changedTouches[0].clientX; + e.clientY = e.changedTouches[0].clientY; + + this.draw_event(e, true); + }, + false, + ); + + canvas.addEventListener( + "touchstart", + (e) => { + e.preventDefault(); + + e.clientX = e.changedTouches[0].clientX; + e.clientY = e.changedTouches[0].clientY; + + this.#cmd_store.push({ + type: "Line", + data: [], + }); + + this.move_event(e); + }, + false, + ); + + canvas.addEventListener( + "touchend", + (e) => { + e.preventDefault(); + + e.clientX = e.changedTouches[0].clientX; + e.clientY = e.changedTouches[0].clientY; + + this.push_state(); + this.move_event(e); + }, + false, + ); + + // add controls + const controls_tmpl = document + .getElementById("carp_canvas") + .content.cloneNode(true); + this.#element.appendChild(controls_tmpl); + + const canvas_loc = this.#element.querySelector( + "[ui_ident=canvas_loc]", + ); + canvas_loc.appendChild(canvas); + + const color_picker = this.#element.querySelector( + "[ui_ident=color_picker]", + ); + color_picker.addEventListener("change", (e) => { + this.set_old_color(this.COLOR); + this.COLOR = e.target.value; + }); + + const stroke_range = this.#element.querySelector( + "[ui_ident=stroke_range]", + ); + stroke_range.addEventListener("change", (e) => { + this.set_old_stroke_size(this.STROKE_SIZE); + this.STROKE_SIZE = e.target.value; + }); + + const undo = this.#element.querySelector("[ui_ident=undo]"); + undo.addEventListener("click", () => { + this.undo(); + }); + + const redo = this.#element.querySelector("[ui_ident=redo]"); + redo.addEventListener("click", () => { + this.redo(); + }); + } + } + + /// Resize the canvas + resize(size) { + this.#ctx.canvas.width = size.x; + this.#ctx.canvas.height = size.y; + } + + /// Clear the canvas + clear() { + const canvas = this.#ctx.canvas; + this.#ctx.clearRect(0, 0, canvas.width, canvas.height); + } + + /// Set the old color + set_old_color(value) { + this.#color_old = value; + } + + /// Set the old stroke_size + set_old_stroke_size(value) { + this.#stroke_size_old = value; + } + + /// Update position (from event) + move_event(e) { + const rect = this.#ctx.canvas.getBoundingClientRect(); + + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + this.move({ x, y }); + } + + /// Update position + move(pos) { + this.#pos.x = pos.x; + this.#pos.y = pos.y; + } + + /// Draw on the canvas (from event) + draw_event(e, mobile = false) { + if (e.buttons !== 1 && mobile === false) return; + const rect = this.#ctx.canvas.getBoundingClientRect(); + + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + this.draw({ x, y }); + } + + /// Draw on the canvas + draw(pos, skip_line_store = false) { + this.#ctx.beginPath(); + + this.#ctx.lineWidth = this.STROKE_SIZE; + this.#ctx.strokeStyle = this.COLOR; + this.#ctx.lineCap = "round"; + + this.#ctx.moveTo(this.#pos.x, this.#pos.y); + this.move(pos); + this.#ctx.lineTo(this.#pos.x, this.#pos.y); + + if (!skip_line_store) { + // yes flooring the values will make the image SLIGHTLY different, + // but it also saves THOUSANDS of characters + const point = [ + Math.floor(this.#pos.x), + Math.floor(this.#pos.y), + ]; + + if (this.#color_old !== this.COLOR) { + this.#cmd_store.push({ + type: "Color", + data: enc(this.COLOR.replace("#", "")), + }); + } + + if (this.#stroke_size_old !== this.STROKE_SIZE) { + this.#cmd_store.push({ + type: "Size", + data: lpad(2, enc(this.STROKE_SIZE, "u16")), // u16 + }); + } + + this.#cmd_store.push({ + type: "Point", + data: [ + // u32 + ...lpad(4, enc(point[0])), + ...lpad(4, enc(point[1])), + ], + }); + + if (this.#color_old !== this.COLOR) { + // we've already seen it once, time to update it + this.set_old_color(this.COLOR); + } + + if (this.#stroke_size_old !== this.STROKE_SIZE) { + this.set_old_stroke_size(this.STROKE_SIZE); + } + } + + this.#ctx.stroke(); + } + + /// Create blob and get URL + as_blob() { + const blob = this.#ctx.canvas.toBlob(); + return URL.createObjectURL(blob); + } + + /// Create Carp2 representation of the graph + as_carp2() { + // most stuff should have an lpad of 4 to make sure it's a u32 (4 bytes) + const header = [ + ...enc("CG"), + ...enc("02"), + ...lpad(4, enc(this.#ctx.canvas.width)), + ...lpad(4, enc(this.#ctx.canvas.height)), + END_OF_HEADER, + ]; + + // build commands + const commands = []; + commands.push(COLOR); + commands.push(...enc("000000")); + commands.push(SIZE); + commands.push(...lpad(4, enc(2)).slice(2)); + + for (const command of this.COMMANDS) { + // this is `impl Into> for Command` + switch (command.type) { + case "Point": + commands.push(POINT); + break; + + case "Line": + commands.push(LINE); + break; + + case "Color": + commands.push(COLOR); + break; + + case "Size": + commands.push(SIZE); + break; + } + + commands.push(...command.data); + } + + // this is so fucking stupid the fact that arraybuffers send as a fucking + // concatenated string of the NUMBERS of the bytes is so stupid this is + // actually crazy what the fuck is this shit + // + // didn't expect i'd have to do this shit myself considering it's done + // for you with File prototypes from a file input + const bin = [...header, ...commands, EOF]; + let bin_str = ""; + + for (const byte of bin) { + bin_str += String.fromCharCode(byte); + } + + // return + return bin; + } + + /// Export lines as string + as_string() { + return JSON.stringify(this.COMMANDS); + } + + /// From an array of bytes + from_bytes(input) { + this.clear(); + + let idx = -1; + function next() { + idx += 1; + return [idx, input[idx]]; + } + + function select_bytes(count) { + // select_bytes! macro + const data = []; + let seen_bytes = 0; + + let [_, byte] = next(); + while (byte !== undefined) { + seen_bytes += 1; + data.push(byte); + + if (seen_bytes === count) { + break; + } + + [_, byte] = next(); + } + + return data; + } + + // everything past this is just a reverse implementation of carp2.rs in js + const commands = []; + const dimensions = { x: 0, y: 0 }; + let in_header = true; + let seen_point = false; + let byte_buffer = []; + + let [i, byte] = next(); + while (byte !== undefined) { + switch (byte) { + case END_OF_HEADER: + in_header = false; + break; + + case COLOR: + { + const data = select_bytes(6); + commands.push({ + type: "Color", + data, + }); + this.COLOR = `#${dec("string", new Uint8Array(data))}`; + } + break; + + case SIZE: + { + const data = select_bytes(2); + commands.push({ + type: "Size", + data, + }); + this.STROKE_SIZE = dec("u16", data); + } + break; + + case POINT: + { + const data = select_bytes(8); + commands.push({ + type: "Point", + data, + }); + + const point = { + x: dec("u32", data.slice(0, 4)), + y: dec("u32", data.slice(4, 8)), + }; + + if (!seen_point) { + // this is the FIRST POINT that has been seen... + // we need to start drawing from here to avoid a line + // from 0,0 to the point + this.move(point); + seen_point = true; + } + + this.draw(point, true); + } + break; + + case LINE: + // each line starts at a new place (probably) + seen_point = false; + break; + + case EOF: + break; + + default: + if (in_header) { + if (0 <= i < 2) { + // tag + } else if (2 <= i < 4) { + //version + } else if (4 <= i < 8) { + // width + byte_buffer.push(byte); + + if (i === 7) { + dimensions.x = dec("u32", byte_buffer); + byte_buffer = []; + } + } else if (8 <= i < 12) { + // height + byte_buffer.push(byte); + + if (i === 7) { + dimensions.y = dec("u32", byte_buffer); + byte_buffer = []; + this.resize(dimensions); // update canvas + } + } + } else { + // misc byte + console.log(`extraneous byte at ${i}`); + } + + break; + } + + // ... + [i, byte] = next(); + } + + return commands; + } + + /// Download image as `.carpgraph` + download() { + const blob = new Blob([new Uint8Array(this.as_carp2())], { + type: "image/carpgraph", + }); + + const url = URL.createObjectURL(blob); + + const anchor = document.createElement("a"); + anchor.href = url; + anchor.setAttribute("download", "image.carpgraph"); + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + + URL.revokeObjectURL(url); + } + + /// Download image as `.carpgraph1` + download_json() { + const string = this.as_string(); + const blob = new Blob([string], { type: "application/json" }); + const url = URL.createObjectURL(blob); + + const anchor = document.createElement("a"); + anchor.href = url; + anchor.setAttribute("download", "image.carpgraph_json"); + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + + URL.revokeObjectURL(url); + } + } +})(); diff --git a/crates/app/src/public/js/loader.js b/crates/app/src/public/js/loader.js index 558a9a7..affff76 100644 --- a/crates/app/src/public/js/loader.js +++ b/crates/app/src/public/js/loader.js @@ -16,19 +16,32 @@ function regns_log(level, ...args) { } /// Query an existing namespace -globalThis.ns = (ns) => { +globalThis.ns = async (ns) => { regns_log("info", "namespace query:", ns); // get namespace from app base - const res = globalThis._app_base.ns_store[`$${ns}`]; + let res = globalThis._app_base.ns_store[`$${ns}`]; + let tries = 0; - if (!res) { - return console.error( - "namespace does not exist, please use one of the following:", - Object.keys(globalThis._app_base.ns_store), - ); + while (!res) { + if (tries >= 5) { + return console.error( + `namespace "${ns}" does not exist, please use one of the following:`, + Object.keys(globalThis._app_base.ns_store), + ); + } + + tries += 1; + res = globalThis._app_base.ns_store[`$${ns}`]; + + await new Promise((resolve) => { + setTimeout(() => { + resolve(); + }, 500); + }); } + regns_log("info", `found ns "${ns}" after ${tries} tries`); return res; }; @@ -51,12 +64,12 @@ globalThis.reg_ns = (ns, deps) => { _ident: ns, _deps: deps || [], /// Pull dependencies (other namespaces) as listed in the given `deps` argument - _get_deps: () => { + _get_deps: async () => { const self = globalThis._app_base.ns_store[`$${ns}`]; const deps = {}; for (const dep of self._deps) { - const res = globalThis.ns(dep); + const res = await globalThis.ns(dep); if (!res) { regns_log("warn", "failed to pull dependency:", dep); @@ -72,16 +85,15 @@ globalThis.reg_ns = (ns, deps) => { /// Store the real versions of functions _fn_store: {}, /// Call a function in a namespace and load namespace dependencies - define: (name, func, types) => { - const self = globalThis.ns(ns); + define: async (name, func, types) => { + const self = await globalThis.ns(ns); self._fn_store[name] = func; // store real function - self[name] = function (...args) { + self[name] = async (...args) => { regns_log("info", "namespace call:", ns, name); // js doesn't provide type checking, we do if (types) { for (const i in args) { - // biome-ignore lint: this is incorrect, you do not need a string literal to use typeof if (types[i] && typeof args[i] !== types[i]) { return console.error( "argument does not pass type check:", @@ -94,7 +106,7 @@ globalThis.reg_ns = (ns, deps) => { // ... // we MUST return here, otherwise nothing will work in workers - return self._fn_store[name](self._get_deps(), ...args); // call with deps and arguments + return self._fn_store[name](await self._get_deps(), ...args); // call with deps and arguments }; }, }; @@ -104,11 +116,11 @@ globalThis.reg_ns = (ns, deps) => { }; /// Call a namespace function quickly -globalThis.trigger = (id, args) => { +globalThis.trigger = async (id, args) => { // get namespace const s = id.split("::"); const [namespace, func] = [s[0], s.slice(1, s.length).join("::")]; - const self = ns(namespace); + const self = await ns(namespace); if (!self) { return console.error("namespace does not exist:", namespace); diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index 1a91bcd..31290c9 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -204,6 +204,47 @@ }); }); + self.define("message_react", async (_, element, message, emoji) => { + await trigger("atto::debounce", ["reactions::toggle"]); + fetch("/api/v1/message_reactions", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + message, + emoji, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger("atto::toast", [ + res.ok ? "success" : "error", + res.message, + ]); + + if (res.ok) { + if (res.message.includes("created")) { + const x = element.querySelector( + `[ui_ident=emoji_${emoji.replaceAll(":", "\\:")}]`, + ); + + if (x) { + x.classList.remove("lowered"); + } + } else { + const x = element.querySelector( + `[ui_ident=emoji_${emoji.replaceAll(":", "\\:")}]`, + ); + + if (x) { + x.classList.add("lowered"); + } + } + } + }); + }); + self.define("remove_notification", (_, id) => { fetch(`/api/v1/notifications/${id}`, { method: "DELETE", @@ -259,7 +300,15 @@ self.define( "repost", - (_, id, content, community, do_not_redirect = false) => { + async ( + _, + id, + content, + community, + do_not_redirect = false, + is_stack = false, + ) => { + await trigger("atto::debounce", ["posts::create"]); return new Promise((resolve, _) => { fetch(`/api/v1/posts/${id}/repost`, { method: "POST", @@ -268,7 +317,8 @@ }, body: JSON.stringify({ content, - community, + community: !is_stack ? community : "0", + stack: is_stack ? community : "0", }), }) .then((res) => res.json()) @@ -481,6 +531,78 @@ return out; }); + // share intents + self.define( + "gen_share", + ( + _, + ids = { q: "0", p: "0" }, + target_length = 280, + include_link = true, + ) => { + const part_1 = ( + document.getElementById(`question_content:${ids.q}`) || { + innerText: "", + } + ).innerText; + + const part_2 = document.getElementById( + `post_content:${ids.p}`, + ).innerText; + + // ... + const link = + include_link !== false + ? `${window.location.origin}/post/${ids.p}` + : ""; + + const link_size = link.length; + target_length -= link_size; + + let out = ""; + const separator = " — "; + + const part_2_size = target_length / 2 - 1; + const sep_size = separator.length; + const part_1_size = target_length / 2 - sep_size; + + if (part_1 !== "") { + out += + part_1_size > part_1.length + ? part_1 + : part_1.substring(0, part_1_size); + + out += separator; + } + + if (part_2 !== "") { + out += + part_2_size > part_2.length + ? part_2 + : part_2.substring(0, part_2_size); + } + + out += ` ${link}`; + return out; + }, + ); + + self.define("intent_twitter", async (_, text_promise) => { + window.open( + `https://twitter.com/intent/tweet?text=${encodeURIComponent(await text_promise)}`, + ); + + trigger("atto::toast", ["success", "Opened intent!"]); + }); + + self.define("intent_bluesky", async (_, text_promise) => { + window.open( + `https://bsky.app/intent/compose?text=${encodeURIComponent(await text_promise)}`, + ); + + trigger("atto::toast", ["success", "Opened intent!"]); + }); + // token switcher self.define("append_associations", (_, tokens) => { fetch("/api/v1/auth/user/me/append_associations", { @@ -756,6 +878,25 @@ return [access_token, refresh_token, expires_in]; }); + self.define("refresh", async (_, refresh_token) => { + const [new_token, new_refresh_token, expires_in] = await trigger( + "spotify::refresh_token", + [client_id, refresh_token], + ); + + await trigger("connections::push_con_data", [ + "Spotify", + { + token: new_token, + refresh_token: new_refresh_token, + expires_in: expires_in.toString(), + name: profile.display_name, + }, + ]); + + return [new_token, refresh_token]; + }); + self.define("profile", async (_, token) => { return await ( await fetch("https://api.spotify.com/v1/me", { @@ -843,7 +984,7 @@ return; } - const now = new Date().getTime(); + const now = Date.now(); const updated = Number.parseInt(updated_) + 8000; let elapsed_since_update = now - updated; @@ -993,7 +1134,7 @@ artist: playing.artist.name, album: playing.album["#text"], // times - timestamp: new Date().getTime().toString(), + timestamp: Date.now().toString(), duration_ms: (mb_info.length || 0).toString(), }, }, diff --git a/crates/app/src/public/js/streams.js b/crates/app/src/public/js/streams.js index 1285d06..8b9954d 100644 --- a/crates/app/src/public/js/streams.js +++ b/crates/app/src/public/js/streams.js @@ -42,7 +42,7 @@ }, }; - socket.addEventListener("message", (event) => { + socket.addEventListener("message", async (event) => { if (event.data === "Ping") { return socket.send("Pong"); } @@ -54,14 +54,14 @@ return console.info(`${stream} ${data.data}`); } - return $.sock(stream).events.message(data); + return (await $.sock(stream)).events.message(data); }); return $.STREAMS[stream]; }); - self.define("close", ({ $ }, stream) => { - const socket = $.sock(stream); + self.define("close", async ({ $ }, stream) => { + const socket = await $.sock(stream); if (!socket) { console.warn("no such stream to close"); @@ -72,8 +72,8 @@ socket.socket.close(); }); - self.define("event", ({ $ }, stream, event, handler) => { - const socket = $.sock(stream); + self.define("event", async ({ $ }, stream, event, handler) => { + const socket = await $.sock(stream); if (!socket) { console.warn("no such stream to add event to"); @@ -84,7 +84,7 @@ }); self.define("send_packet", async ({ $ }, stream, method, data) => { - await ( + return await ( await fetch(`/api/v1/auth/user/${$.USER}/_connect/${stream}/send`, { method: "POST", headers: { @@ -97,4 +97,19 @@ }) ).json(); }); + + self.define("send_packet_to", async (_, user, stream, method, data) => { + return await ( + await fetch(`/api/v1/auth/user/${user}/_connect/${stream}/send`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + method, + data: JSON.stringify(data), + }), + }) + ).json(); + }); })(); 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 b0eaee6..6ef6fcd 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -81,7 +81,7 @@ pub async fn stripe_webhook( loop { if retries >= 5 { - // we've already tried 5 times (10 seconds of waiting)... it's not + // we've already tried 5 times (25 seconds of waiting)... it's not // going to happen // // we're going to report this error to the audit log so someone can @@ -111,7 +111,7 @@ pub async fn stripe_webhook( Err(_) => { tracing::info!("checkout session not stored in db yet"); retries += 1; - tokio::time::sleep(Duration::from_secs(2)).await; + tokio::time::sleep(Duration::from_secs(5)).await; continue; } } diff --git a/crates/app/src/routes/api/v1/auth/images.rs b/crates/app/src/routes/api/v1/auth/images.rs index 9a67da8..4619a80 100644 --- a/crates/app/src/routes/api/v1/auth/images.rs +++ b/crates/app/src/routes/api/v1/auth/images.rs @@ -82,14 +82,16 @@ pub async fn avatar_request( } }; + let mime = if user.settings.avatar_mime.is_empty() { + "image/avif" + } else { + &user.settings.avatar_mime + }; + let path = PathBufD::current().extend(&[ data.0.0.dirs.media.as_str(), "avatars", - &format!( - "{}.{}", - &(user.id as i64), - user.settings.avatar_mime.replace("image/", "") - ), + &format!("{}.{}", &(user.id as i64), mime.replace("image/", "")), ]); if !exists(&path).unwrap() { @@ -104,10 +106,7 @@ pub async fn avatar_request( } Ok(( - [( - "Content-Type".to_string(), - user.settings.avatar_mime.clone(), - )], + [("Content-Type".to_string(), mime.to_owned())], Body::from(read_image(path)), )) } @@ -134,14 +133,16 @@ pub async fn banner_request( } }; + let mime = if user.settings.banner_mime.is_empty() { + "image/avif" + } else { + &user.settings.banner_mime + }; + let path = PathBufD::current().extend(&[ data.0.0.dirs.media.as_str(), "banners", - &format!( - "{}.{}", - &(user.id as i64), - user.settings.banner_mime.replace("image/", "") - ), + &format!("{}.{}", &(user.id as i64), mime.replace("image/", "")), ]); if !exists(&path).unwrap() { @@ -156,10 +157,7 @@ pub async fn banner_request( } Ok(( - [( - "Content-Type".to_string(), - user.settings.banner_mime.clone(), - )], + [("Content-Type".to_string(), mime.to_owned())], Body::from(read_image(path)), )) } @@ -211,23 +209,25 @@ pub async fn upload_avatar_request( mime.replace("image/", "") ); - // update user settings - auth_user.settings.avatar_mime = mime.to_string(); - if let Err(e) = data - .update_user_settings(auth_user.id, auth_user.settings) - .await - { - return Json(e.into()); - } - // upload image (gif) if mime == "image/gif" { // gif image, don't encode if img.0.len() > MAXIMUM_GIF_FILE_SIZE { - return Json(Error::DataTooLong("gif".to_string()).into()); + return Json(Error::FileTooLarge.into()); } std::fs::write(&path, img.0).unwrap(); + + // update user settings + auth_user.settings.avatar_mime = "image/gif".to_string(); + if let Err(e) = data + .update_user_settings(auth_user.id, auth_user.settings) + .await + { + return Json(e.into()); + } + + // ... return Json(ApiReturn { ok: true, message: "Avatar uploaded. It might take a bit to update".to_string(), @@ -237,7 +237,16 @@ pub async fn upload_avatar_request( // check file size if img.0.len() > MAXIMUM_FILE_SIZE { - return Json(Error::DataTooLong("image".to_string()).into()); + return Json(Error::FileTooLarge.into()); + } + + // update user settings + auth_user.settings.avatar_mime = "image/avif".to_string(); + if let Err(e) = data + .update_user_settings(auth_user.id, auth_user.settings) + .await + { + return Json(e.into()); } // upload image @@ -247,15 +256,7 @@ pub async fn upload_avatar_request( bytes.push(byte); } - match save_buffer( - &path, - bytes, - if mime == "image/gif" { - image::ImageFormat::Gif - } else { - image::ImageFormat::Avif - }, - ) { + match save_buffer(&path, bytes, image::ImageFormat::Avif) { Ok(_) => Json(ApiReturn { ok: true, message: "Avatar uploaded. It might take a bit to update".to_string(), @@ -309,23 +310,25 @@ pub async fn upload_banner_request( mime.replace("image/", "") ); - // update user settings - auth_user.settings.banner_mime = mime.to_string(); - if let Err(e) = data - .update_user_settings(auth_user.id, auth_user.settings) - .await - { - return Json(e.into()); - } - // upload image (gif) if mime == "image/gif" { // gif image, don't encode if img.0.len() > MAXIMUM_GIF_FILE_SIZE { - return Json(Error::DataTooLong("gif".to_string()).into()); + return Json(Error::FileTooLarge.into()); } std::fs::write(&path, img.0).unwrap(); + + // update user settings + auth_user.settings.banner_mime = "image/gif".to_string(); + if let Err(e) = data + .update_user_settings(auth_user.id, auth_user.settings) + .await + { + return Json(e.into()); + } + + // ... return Json(ApiReturn { ok: true, message: "Banner uploaded. It might take a bit to update".to_string(), @@ -335,7 +338,16 @@ pub async fn upload_banner_request( // check file size if img.0.len() > MAXIMUM_FILE_SIZE { - return Json(Error::DataTooLong("image".to_string()).into()); + return Json(Error::FileTooLarge.into()); + } + + // update user settings + auth_user.settings.avatar_mime = "image/avif".to_string(); + if let Err(e) = data + .update_user_settings(auth_user.id, auth_user.settings) + .await + { + return Json(e.into()); } // upload image @@ -345,15 +357,7 @@ pub async fn upload_banner_request( bytes.push(byte); } - match save_buffer( - &path, - bytes, - if mime == "image/gif" { - image::ImageFormat::Gif - } else { - image::ImageFormat::Avif - }, - ) { + match save_buffer(&path, bytes, image::ImageFormat::Avif) { Ok(_) => Json(ApiReturn { ok: true, message: "Banner uploaded. It might take a bit to update".to_string(), diff --git a/crates/app/src/routes/api/v1/auth/mod.rs b/crates/app/src/routes/api/v1/auth/mod.rs index f8e5b83..a332dd8 100644 --- a/crates/app/src/routes/api/v1/auth/mod.rs +++ b/crates/app/src/routes/api/v1/auth/mod.rs @@ -86,26 +86,81 @@ pub async fn register_request( let mut user = User::new(props.username.to_lowercase(), props.password); user.settings.policy_consent = true; + // 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()), + ); + } + + 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 let (initial_token, t) = User::create_token(&real_ip); user.tokens.push(t); // return match data.create_user(user).await { - Ok(_) => ( - Some([( - "Set-Cookie", - format!( - "__Secure-atto-token={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age={}", - initial_token, - 60 * 60 * 24 * 365 - ), - )]), - Json(ApiReturn { - ok: true, - message: initial_token, - payload: (), - }), - ), + Ok(_) => { + // mark invite as used + if data.0.0.security.enable_invite_codes { + 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 let Err(e) = data.update_invite_code_is_used(invite_code.id, true).await { + return (None, Json(e.into())); + } + } + + // ... + ( + Some([( + "Set-Cookie", + format!( + "__Secure-atto-token={}; SameSite=Lax; Secure; Path=/; HostOnly=true; HttpOnly=true; Max-Age={}", + initial_token, + 60 * 60 * 24 * 365 + ), + )]), + Json(ApiReturn { + ok: true, + message: initial_token, + payload: (), + }), + ) + } Err(e) => (None, Json(e.into())), } } diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index c98a665..baf2f54 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -3,8 +3,8 @@ use crate::{ get_user_from_token, model::{ApiReturn, Error}, routes::api::v1::{ - AppendAssociations, DeleteUser, DisableTotp, RefreshGrantToken, UpdateUserIsVerified, - UpdateUserPassword, UpdateUserRole, UpdateUserUsername, + AppendAssociations, DeleteUser, DisableTotp, RefreshGrantToken, UpdateSecondaryUserRole, + UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, UpdateUserUsername, }, State, }; @@ -21,18 +21,17 @@ use futures_util::{sink::SinkExt, stream::StreamExt}; use tetratto_core::{ cache::Cache, model::{ - auth::{Token, UserSettings}, + auth::{AchievementName, InviteCode, Token, UserSettings}, + moderation::AuditLogEntry, oauth, permissions::FinePermission, socket::{PacketType, SocketMessage, SocketMethod}, }, DataManager, }; - -#[cfg(feature = "redis")] use tetratto_core::cache::redis::Commands; use tetratto_shared::{ - hash::{self, random_id}, + hash::{hash, salt, random_id}, unix_epoch_timestamp, }; @@ -152,6 +151,14 @@ pub async fn update_user_settings_request( req.theme_lit = format!("{}%", req.theme_lit) } + // award achievement + if let Err(e) = data + .add_achievement(&user, AchievementName::EditSettings.into()) + .await + { + return Json(e.into()); + } + // ... match data.update_user_settings(id, req).await { Ok(_) => Json(ApiReturn { @@ -187,7 +194,7 @@ pub async fn append_associations_request( // resolve tokens for token in req.tokens { - let hashed = hash::hash(token); + let hashed = hash(token); let user_from_token = match data.get_user_by_token(&hashed).await { Ok(ua) => ua, Err(_) => continue, @@ -360,6 +367,34 @@ pub async fn update_user_role_request( } } +/// Update the role of the given user. +/// +/// Does not support third-party grants. +pub async fn update_user_secondary_role_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_secondary_role(id, req.role, user, false) + .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; @@ -395,6 +430,16 @@ pub async fn delete_user_request( if user.id != id && !user.permissions.check(FinePermission::MANAGE_USERS) { return Json(Error::NotAllowed.into()); + } else if user.permissions.check(FinePermission::MANAGE_USERS) { + if let Err(e) = data + .create_audit_log_entry(AuditLogEntry::new( + user.id, + format!("invoked `delete_user` with x value `{id}`"), + )) + .await + { + return Json(e.into()); + } } match data @@ -534,7 +579,6 @@ pub async fn has_totp_enabled_request( } /// Handle a subscription to the websocket. -#[cfg(feature = "redis")] pub async fn subscription_handler( jar: CookieJar, ws: WebSocketUpgrade, @@ -557,10 +601,9 @@ pub async fn subscription_handler( })) } -#[cfg(feature = "redis")] pub async fn handle_socket(socket: WebSocket, db: DataManager, user_id: String, stream_id: String) { let (mut sink, mut stream) = socket.split(); - let socket_id = tetratto_shared::hash::salt(); + let socket_id = salt(); db.0.1 .incr("atto.active_connections:users".to_string()) .await; @@ -673,7 +716,7 @@ pub async fn post_to_socket_request( None => return Json(Error::NotAllowed.into()), }; - if user.id.to_string() != user_id { + if user.id.to_string() != user_id && !user.permissions.check(FinePermission::MANAGE_USERS) { return Json(Error::NotAllowed.into()); } @@ -821,3 +864,50 @@ pub async fn refresh_grant_request( Err(e) => Json(e.into()), } } + +/// Generate an invite code. +/// +/// Does not support third-party grants. +pub async fn generate_invite_codes_request( + jar: CookieJar, + Path(count): Path, + Extension(data): Extension, +) -> 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 !data.0.0.security.enable_invite_codes { + return Json(Error::NotAllowed.into()); + } + + if count > 48 { + return Json(Error::DataTooLong("count".to_string()).into()); + } + + let mut out_string = String::new(); + let mut errors_string = String::new(); + + for _ in 0..count { + // ids will quickly collide, so we need to wait a bit so timestamps are different + tokio::time::sleep(Duration::from_millis(50)).await; + match data + .create_invite_code(InviteCode::new(user.id), &user) + .await + { + Ok(x) => out_string += &(x.code + "\n"), + Err(e) => { + errors_string = e.to_string(); + break; + } + } + } + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some((out_string, errors_string)), + }) +} diff --git a/crates/app/src/routes/api/v1/auth/social.rs b/crates/app/src/routes/api/v1/auth/social.rs index cef005c..a507e49 100644 --- a/crates/app/src/routes/api/v1/auth/social.rs +++ b/crates/app/src/routes/api/v1/auth/social.rs @@ -11,7 +11,7 @@ use axum::{ }; use axum_extra::extract::CookieJar; use tetratto_core::model::{ - auth::{FollowResult, IpBlock, Notification, UserBlock, UserFollow}, + auth::{AchievementName, FollowResult, IpBlock, Notification, UserBlock, UserFollow}, oauth, }; @@ -59,6 +59,13 @@ pub async fn follow_request( return Json(e.into()); }; + if let Err(e) = data + .add_achievement(&user, AchievementName::FollowUser.into()) + .await + { + return Json(e.into()); + } + Json(ApiReturn { ok: true, message: "User followed".to_string(), diff --git a/crates/app/src/routes/api/v1/channels/message_reactions.rs b/crates/app/src/routes/api/v1/channels/message_reactions.rs new file mode 100644 index 0000000..b9ccb53 --- /dev/null +++ b/crates/app/src/routes/api/v1/channels/message_reactions.rs @@ -0,0 +1,103 @@ +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 tetratto_core::model::{channels::MessageReaction, oauth, ApiReturn, Error}; + +pub async fn get_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::UserReact) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .get_message_reactions_by_owner_message(user.id, id) + .await + { + Ok(r) => Json(ApiReturn { + ok: true, + message: "Reactions exists".to_string(), + payload: Some(r), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReact) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let message_id = match req.message.parse::() { + Ok(n) => n, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }; + + // check for existing reaction + if let Ok(r) = data + .get_message_reaction_by_owner_message_emoji(user.id, message_id, &req.emoji) + .await + { + if let Err(e) = data.delete_message_reaction(r.id, &user).await { + return Json(e.into()); + } else { + return Json(ApiReturn { + ok: true, + message: "Reaction removed".to_string(), + payload: (), + }); + } + } + + // create reaction + match data + .create_message_reaction(MessageReaction::new(user.id, message_id, req.emoji), &user) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Reaction created".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path((id, emoji)): Path<(usize, String)>, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReact) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let reaction = match data + .get_message_reaction_by_owner_message_emoji(user.id, id, &emoji) + .await + { + Ok(r) => r, + Err(e) => return Json(e.into()), + }; + + match data.delete_message_reaction(reaction.id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Reaction deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/channels/mod.rs b/crates/app/src/routes/api/v1/channels/mod.rs index 345897d..33792c3 100644 --- a/crates/app/src/routes/api/v1/channels/mod.rs +++ b/crates/app/src/routes/api/v1/channels/mod.rs @@ -1,2 +1,3 @@ pub mod channels; +pub mod message_reactions; pub mod messages; diff --git a/crates/app/src/routes/api/v1/communities/emojis.rs b/crates/app/src/routes/api/v1/communities/emojis.rs index 6f0d037..1db4c0c 100644 --- a/crates/app/src/routes/api/v1/communities/emojis.rs +++ b/crates/app/src/routes/api/v1/communities/emojis.rs @@ -16,12 +16,16 @@ use tetratto_core::model::{ /// Expand a unicode emoji into its Gemoji shortcode. pub async fn get_emoji_shortcode(emoji: String) -> impl IntoResponse { - match emojis::get(&emoji) { - Some(e) => match e.shortcode() { - Some(s) => s.to_string(), - None => e.name().replace(" ", "-"), + match emoji.as_str() { + "👍" => "thumbs_up".to_string(), + "👎" => "thumbs_down".to_string(), + _ => match emojis::get(&emoji) { + Some(e) => match e.shortcode() { + Some(s) => s.to_string(), + None => e.name().replace(" ", "-"), + }, + None => String::new(), }, - None => String::new(), } } diff --git a/crates/app/src/routes/api/v1/communities/images.rs b/crates/app/src/routes/api/v1/communities/images.rs index 464dede..3ddee00 100644 --- a/crates/app/src/routes/api/v1/communities/images.rs +++ b/crates/app/src/routes/api/v1/communities/images.rs @@ -136,7 +136,7 @@ pub async fn upload_avatar_request( // check file size if img.0.len() > MAXIMUM_FILE_SIZE { - return Json(Error::DataTooLong("image".to_string()).into()); + return Json(Error::FileTooLarge.into()); } // upload image @@ -191,7 +191,7 @@ pub async fn upload_banner_request( // check file size if img.0.len() > MAXIMUM_FILE_SIZE { - return Json(Error::DataTooLong("image".to_string()).into()); + return Json(Error::FileTooLarge.into()); } // upload image diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index 32f4b77..e1fb89e 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -7,6 +7,7 @@ use axum::{ use axum_extra::extract::CookieJar; use tetratto_core::model::{ addr::RemoteAddr, + auth::AchievementName, communities::{Poll, PollVote, Post}, oauth, permissions::FinePermission, @@ -124,12 +125,16 @@ pub async fn create_request( }; } else { props.title = req.title; + props.stack = match req.stack.parse::() { + Ok(x) => x, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }; } // check sizes for img in &images { if img.len() > MAXIMUM_FILE_SIZE { - return Json(Error::DataTooLong("image".to_string()).into()); + return Json(Error::FileTooLarge.into()); } } @@ -174,6 +179,41 @@ pub async fn create_request( } } + // achievements + if let Err(e) = data + .add_achievement(&user, AchievementName::CreatePost.into()) + .await + { + return Json(e.into()); + } + + if user.post_count >= 49 { + if let Err(e) = data + .add_achievement(&user, AchievementName::Create50Posts.into()) + .await + { + return Json(e.into()); + } + } + + if user.post_count >= 99 { + if let Err(e) = data + .add_achievement(&user, AchievementName::Create100Posts.into()) + .await + { + return Json(e.into()); + } + } + + if user.post_count >= 999 { + if let Err(e) = data + .add_achievement(&user, AchievementName::Create1000Posts.into()) + .await + { + return Json(e.into()); + } + } + // return Json(ApiReturn { ok: true, @@ -197,18 +237,23 @@ pub async fn create_repost_request( None => return Json(Error::NotAllowed.into()), }; - match data - .create_post(Post::repost( - req.content, - match req.community.parse::() { - Ok(x) => x, - Err(e) => return Json(Error::MiscError(e.to_string()).into()), - }, - user.id, - id, - )) - .await - { + let mut props = Post::repost( + req.content, + match req.community.parse::() { + Ok(x) => x, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }, + user.id, + id, + ); + + props.stack = match req.stack.parse::() { + Ok(x) => x, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }; + + // ... + match data.create_post(props).await { Ok(id) => Json(ApiReturn { ok: true, message: "Post reposted".to_string(), @@ -432,10 +477,7 @@ pub async fn posts_request( }; check_user_blocked_or_private!(Some(&user), other_user, data, @api); - match data - .get_posts_by_user(id, 12, props.page, &Some(user.clone())) - .await - { + match data.get_posts_by_user(id, 12, props.page).await { Ok(posts) => { let ignore_users = crate::ignore_users_gen!(user!, #data); Json(ApiReturn { @@ -469,7 +511,10 @@ pub async fn community_posts_request( None => return Json(Error::NotAllowed.into()), }; - match data.get_posts_by_community(id, 12, props.page).await { + match data + .get_posts_by_community(id, 12, props.page, &Some(user.clone())) + .await + { Ok(posts) => { let ignore_users = crate::ignore_users_gen!(user!, #data); Json(ApiReturn { @@ -783,7 +828,10 @@ pub async fn all_request( None => return Json(Error::NotAllowed.into()), }; - match data.get_latest_posts(12, props.page).await { + match data + .get_latest_posts(12, props.page, &Some(user.clone())) + .await + { Ok(posts) => { let ignore_users = crate::ignore_users_gen!(user!, #data); Json(ApiReturn { diff --git a/crates/app/src/routes/api/v1/communities/questions.rs b/crates/app/src/routes/api/v1/communities/questions.rs index 270197f..c469512 100644 --- a/crates/app/src/routes/api/v1/communities/questions.rs +++ b/crates/app/src/routes/api/v1/communities/questions.rs @@ -7,7 +7,7 @@ use axum::{ use axum_extra::extract::CookieJar; use tetratto_core::model::{ addr::RemoteAddr, - auth::IpBlock, + auth::{AchievementName, IpBlock}, communities::{CommunityReadAccess, Question}, oauth, permissions::FinePermission, @@ -15,6 +15,7 @@ use tetratto_core::model::{ }; use crate::{ get_user_from_token, + image::JsonMultipart, routes::{api::v1::CreateQuestion, pages::PaginatedQuery}, State, }; @@ -23,7 +24,7 @@ pub async fn create_request( jar: CookieJar, headers: HeaderMap, Extension(data): Extension, - Json(req): Json, + JsonMultipart(drawings, req): JsonMultipart, ) -> impl IntoResponse { let data = &(data.read().await).0; let user = get_user_from_token!(jar, data, oauth::AppScope::UserCreateQuestions); @@ -49,6 +50,16 @@ pub async fn create_request( return Json(Error::NotAllowed.into()); } + // award achievement + if let Some(ref user) = user { + if let Err(e) = data + .add_achievement(user, AchievementName::CreateQuestion.into()) + .await + { + return Json(e.into()); + } + } + // ... let mut props = Question::new( if let Some(ref ua) = user { ua.id } else { 0 }, @@ -70,7 +81,10 @@ pub async fn create_request( } } - match data.create_question(props).await { + match data + .create_question(props, drawings.iter().map(|x| x.to_vec()).collect()) + .await + { Ok(id) => Json(ApiReturn { ok: true, message: "Question created".to_string(), diff --git a/crates/app/src/routes/api/v1/journals.rs b/crates/app/src/routes/api/v1/journals.rs new file mode 100644 index 0000000..e2f9404 --- /dev/null +++ b/crates/app/src/routes/api/v1/journals.rs @@ -0,0 +1,298 @@ +use axum::{ + response::IntoResponse, + extract::{Json, Path}, + Extension, +}; +use axum_extra::extract::CookieJar; +use tetratto_shared::snow::Snowflake; +use crate::{ + get_user_from_token, + routes::api::v1::{ + AddJournalDir, CreateJournal, RemoveJournalDir, UpdateJournalPrivacy, UpdateJournalTitle, + }, + State, +}; +use tetratto_core::{ + database::NAME_REGEX, + model::{ + auth::AchievementName, + journals::{Journal, JournalPrivacyPermission}, + oauth, + permissions::FinePermission, + ApiReturn, Error, + }, +}; + +pub async fn get_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadJournals) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let journal = match data.get_journal_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if journal.privacy == JournalPrivacyPermission::Private + && user.id != journal.owner + && !user.permissions.contains(FinePermission::MANAGE_JOURNALS) + { + return Json(Error::NotAllowed.into()); + } + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(journal), + }) +} + +pub async fn get_css_request( + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + + let note = match data.get_note_by_journal_title(id, "journal.css").await { + Ok(x) => x, + Err(e) => { + return ( + [("Content-Type", "text/css"), ("Cache-Control", "no-cache")], + format!("/* {e} */"), + ); + } + }; + + ( + [("Content-Type", "text/css"), ("Cache-Control", "no-cache")], + note.content, + ) +} + +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::UserReadJournals) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.get_journals_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(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateJournals) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_journal(Journal::new(user.id, props.title)) + .await + { + Ok(x) => { + // award achievement + if let Err(e) = data + .add_achievement(&user, AchievementName::CreateJournal.into()) + .await + { + return Json(e.into()); + } + + // ... + Json(ApiReturn { + ok: true, + message: "Journal created".to_string(), + payload: Some(x.id.to_string()), + }) + } + Err(e) => Json(e.into()), + } +} + +pub async fn update_title_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(mut props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageJournals) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + props.title = props.title.replace(" ", "_").to_lowercase(); + + // check name + let regex = regex::RegexBuilder::new(NAME_REGEX) + .multi_line(true) + .build() + .unwrap(); + + if regex.captures(&props.title).is_some() { + return Json(Error::MiscError("This title contains invalid characters".to_string()).into()); + } + + // make sure this title isn't already in use + if data + .get_journal_by_owner_title(user.id, &props.title) + .await + .is_ok() + { + return Json(Error::TitleInUse.into()); + } + + // ... + match data.update_journal_title(id, &user, &props.title).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Journal updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_privacy_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageJournals) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_journal_privacy(id, &user, props.privacy).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Journal updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_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::UserManageJournals) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_journal(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Journal deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn add_dir_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if props.name.len() > 32 { + return Json(Error::DataTooLong("name".to_string()).into()); + } + + let mut journal = match data.get_journal_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + // add dir + journal.dirs.push(( + Snowflake::new().to_string().parse::().unwrap(), + match props.parent.parse() { + Ok(p) => p, + Err(_) => return Json(Error::Unknown.into()), + }, + props.name, + )); + + // ... + match data.update_journal_dirs(id, &user, journal.dirs).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Journal updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn remove_dir_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let mut journal = match data.get_journal_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + // add dir + let dir_id: usize = match props.dir.parse() { + Ok(x) => x, + Err(_) => return Json(Error::Unknown.into()), + }; + + journal + .dirs + .remove(match journal.dirs.iter().position(|x| x.0 == dir_id) { + Some(idx) => idx, + None => return Json(Error::GeneralNotFound("directory".to_string()).into()), + }); + + // ... + match data.update_journal_dirs(id, &user, journal.dirs).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Journal updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index ca5f747..9f850af 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -1,6 +1,9 @@ pub mod apps; pub mod auth; +pub mod channels; pub mod communities; +pub mod journals; +pub mod notes; pub mod notifications; pub mod reactions; pub mod reports; @@ -9,9 +12,6 @@ pub mod stacks; pub mod uploads; pub mod util; -#[cfg(feature = "redis")] -pub mod channels; - use axum::{ routing::{any, delete, get, post, put}, Router, @@ -24,8 +24,9 @@ use tetratto_core::model::{ PollOption, PostContext, }, communities_permissions::CommunityPermission, + journals::JournalPrivacyPermission, oauth::AppScope, - permissions::FinePermission, + permissions::{FinePermission, SecondaryPermission}, reactions::AssetType, stacks::{StackMode, StackPrivacy, StackSort}, }; @@ -36,10 +37,27 @@ pub fn routes() -> Router { .route("/util/proxy", get(util::proxy_request)) .route("/util/lang", get(util::set_langfile_request)) .route("/util/ip", get(util::ip_test_request)) + .route( + "/invites/{count}", + post(auth::profile::generate_invite_codes_request), + ) // reactions .route("/reactions", post(reactions::create_request)) .route("/reactions/{id}", get(reactions::get_request)) .route("/reactions/{id}", delete(reactions::delete_request)) + // message reactions + .route( + "/message_reactions", + post(channels::message_reactions::create_request), + ) + .route( + "/message_reactions/{id}", + get(channels::message_reactions::get_request), + ) + .route( + "/message_reactions/{id}/{emoji}", + delete(channels::message_reactions::delete_request), + ) // communities .route( "/communities/find/{id}", @@ -278,6 +296,10 @@ pub fn routes() -> Router { "/auth/user/{id}/role", post(auth::profile::update_user_role_request), ) + .route( + "/auth/user/{id}/role/2", + post(auth::profile::update_user_secondary_role_request), + ) .route( "/auth/user/{id}", delete(auth::profile::delete_user_request), @@ -532,14 +554,48 @@ pub fn routes() -> Router { delete(communities::emojis::delete_request), ) // stacks + .route("/stacks", get(stacks::list_request)) .route("/stacks", post(stacks::create_request)) + .route("/stacks/{id}", get(stacks::get_request)) .route("/stacks/{id}/name", post(stacks::update_name_request)) .route("/stacks/{id}/privacy", post(stacks::update_privacy_request)) .route("/stacks/{id}/mode", post(stacks::update_mode_request)) .route("/stacks/{id}/sort", post(stacks::update_sort_request)) + .route("/stacks/{id}/users", get(stacks::get_users_request)) .route("/stacks/{id}/users", post(stacks::add_user_request)) .route("/stacks/{id}/users", delete(stacks::remove_user_request)) + .route("/stacks/{id}/block", post(stacks::block_request)) + .route("/stacks/{id}/block", delete(stacks::unblock_request)) .route("/stacks/{id}", delete(stacks::delete_request)) + // journals + .route("/journals", get(journals::list_request)) + .route("/journals", post(journals::create_request)) + .route("/journals/{id}", get(journals::get_request)) + .route("/journals/{id}", delete(journals::delete_request)) + .route("/journals/{id}/journal.css", get(journals::get_css_request)) + .route("/journals/{id}/title", post(journals::update_title_request)) + .route( + "/journals/{id}/privacy", + post(journals::update_privacy_request), + ) + .route("/journals/{id}/dirs", post(journals::add_dir_request)) + .route("/journals/{id}/dirs", delete(journals::remove_dir_request)) + // notes + .route("/notes", post(notes::create_request)) + .route("/notes/{id}", get(notes::get_request)) + .route("/notes/{id}", delete(notes::delete_request)) + .route("/notes/{id}/title", post(notes::update_title_request)) + .route("/notes/{id}/content", post(notes::update_content_request)) + .route("/notes/{id}/dir", post(notes::update_dir_request)) + .route("/notes/{id}/tags", post(notes::update_tags_request)) + .route("/notes/{id}/global", post(notes::publish_request)) + .route("/notes/{id}/global", delete(notes::unpublish_request)) + .route("/notes/from_journal/{id}", get(notes::list_request)) + .route("/notes/preview", post(notes::render_markdown_request)) + .route( + "/notes/{journal}/dir/{dir}", + delete(notes::delete_by_dir_request), + ) // uploads .route("/uploads/{id}", get(uploads::get_request)) .route("/uploads/{id}", delete(uploads::delete_request)) @@ -559,6 +615,8 @@ pub struct RegisterProps { pub password: String, pub policy_consent: bool, pub captcha_response: String, + #[serde(default)] + pub invite_code: String, } #[derive(Deserialize)] @@ -615,12 +673,15 @@ pub struct CreatePost { pub poll: Option, #[serde(default)] pub title: String, + #[serde(default)] + pub stack: String, } #[derive(Deserialize)] pub struct CreateRepost { pub content: String, pub community: String, + pub stack: String, } #[derive(Deserialize)] @@ -683,6 +744,11 @@ pub struct UpdateUserRole { pub role: FinePermission, } +#[derive(Deserialize)] +pub struct UpdateSecondaryUserRole { + pub role: SecondaryPermission, +} + #[derive(Deserialize)] pub struct DeleteUser { pub password: String, @@ -842,3 +908,67 @@ pub struct CreateGrant { pub struct RefreshGrantToken { pub verifier: String, } + +#[derive(Deserialize)] +pub struct CreateJournal { + pub title: String, +} + +#[derive(Deserialize)] +pub struct CreateNote { + pub title: String, + pub content: String, + pub journal: String, +} + +#[derive(Deserialize)] +pub struct UpdateJournalTitle { + pub title: String, +} + +#[derive(Deserialize)] +pub struct UpdateJournalPrivacy { + pub privacy: JournalPrivacyPermission, +} + +#[derive(Deserialize)] +pub struct UpdateNoteTitle { + pub title: String, +} + +#[derive(Deserialize)] +pub struct UpdateNoteContent { + pub content: String, +} + +#[derive(Deserialize)] +pub struct RenderMarkdown { + pub content: String, +} + +#[derive(Deserialize)] +pub struct CreateMessageReaction { + pub message: String, + pub emoji: String, +} + +#[derive(Deserialize)] +pub struct UpdateNoteDir { + pub dir: String, +} + +#[derive(Deserialize)] +pub struct AddJournalDir { + pub name: String, + #[serde(default)] + pub parent: String, +} + +#[derive(Deserialize)] +pub struct RemoveJournalDir { + pub dir: String, +} +#[derive(Deserialize)] +pub struct UpdateNoteTags { + pub tags: Vec, +} diff --git a/crates/app/src/routes/api/v1/notes.rs b/crates/app/src/routes/api/v1/notes.rs new file mode 100644 index 0000000..b6bc986 --- /dev/null +++ b/crates/app/src/routes/api/v1/notes.rs @@ -0,0 +1,419 @@ +use axum::{ + response::IntoResponse, + extract::{Json, Path}, + Extension, +}; +use axum_extra::extract::CookieJar; +use tetratto_shared::unix_epoch_timestamp; +use crate::{ + get_user_from_token, + routes::api::v1::{ + CreateNote, RenderMarkdown, UpdateNoteContent, UpdateNoteDir, UpdateNoteTags, + UpdateNoteTitle, + }, + State, +}; +use tetratto_core::{ + database::NAME_REGEX, + model::{ + journals::{JournalPrivacyPermission, Note}, + oauth, + permissions::FinePermission, + uploads::CustomEmoji, + ApiReturn, Error, + }, +}; + +pub async fn get_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadJournals) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let note = match data.get_note_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + let journal = match data.get_journal_by_id(note.id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if journal.privacy == JournalPrivacyPermission::Private + && user.id != journal.owner + && !user.permissions.contains(FinePermission::MANAGE_JOURNALS) + { + return Json(Error::NotAllowed.into()); + } + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(note), + }) +} + +pub async fn list_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::UserReadJournals) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let journal = match data.get_journal_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if journal.privacy == JournalPrivacyPermission::Private + && user.id != journal.owner + && !user.permissions.contains(FinePermission::MANAGE_JOURNALS) + { + return Json(Error::NotAllowed.into()); + } + + match data.get_notes_by_journal(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(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_note(Note::new( + user.id, + props.title, + match props.journal.parse() { + Ok(x) => x, + Err(_) => return Json(Error::Unknown.into()), + }, + props.content, + )) + .await + { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Note created".to_string(), + payload: Some(x.id.to_string()), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_title_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(mut props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let note = match data.get_note_by_id(id).await { + Ok(n) => n, + Err(e) => return Json(e.into()), + }; + + props.title = props.title.replace(" ", "_").to_lowercase(); + + // check name + let regex = regex::RegexBuilder::new(NAME_REGEX) + .multi_line(true) + .build() + .unwrap(); + + if regex.captures(&props.title).is_some() { + return Json(Error::MiscError("This title contains invalid characters".to_string()).into()); + } + + // make sure this title isn't already in use + if data + .get_note_by_journal_title(note.journal, &props.title) + .await + .is_ok() + { + return Json(Error::TitleInUse.into()); + } + + // ... + match data.update_note_title(id, &user, &props.title).await { + Ok(_) => { + // update note global status + if note.is_global { + if let Err(e) = data.update_note_is_global(id, 0).await { + return Json(e.into()); + } + } + + // ... + Json(ApiReturn { + ok: true, + message: "Note updated".to_string(), + payload: (), + }) + } + Err(e) => Json(e.into()), + } +} + +pub async fn update_content_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_note_content(id, &user, &props.content).await { + Ok(_) => { + if let Err(e) = data + .update_note_edited(id, unix_epoch_timestamp() as i64) + .await + { + return Json(e.into()); + } + + Json(ApiReturn { + ok: true, + message: "Note updated".to_string(), + payload: (), + }) + } + Err(e) => Json(e.into()), + } +} + +pub async fn delete_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::UserManageNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_note(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Note deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_by_dir_request( + jar: CookieJar, + Path((journal, id)): Path<(usize, usize)>, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_notes_by_journal_dir(journal, id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Notes deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn render_markdown_request(Json(req): Json) -> impl IntoResponse { + tetratto_shared::markdown::render_markdown(&CustomEmoji::replace(&req.content)) + .replace("\\@", "@") + .replace("%5C@", "@") +} + +pub async fn update_dir_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let note = match data.get_note_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + let journal = match data.get_journal_by_id(note.journal).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + // make sure dir exists + let dir = match props.dir.parse::() { + Ok(d) => d, + Err(_) => return Json(Error::Unknown.into()), + }; + + if dir != 0 { + if journal.dirs.iter().find(|x| x.0 == dir).is_none() { + return Json(Error::GeneralNotFound("directory".to_string()).into()); + } + } + + // ... + match data.update_note_dir(id, &user, dir as i64).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Note updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_tags_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_note_tags(id, &user, props.tags).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Note updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn publish_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::UserManageNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let note = match data.get_note_by_id(id).await { + Ok(n) => n, + Err(e) => return Json(e.into()), + }; + + if user.id != note.owner { + return Json(Error::NotAllowed.into()); + } + + // check count + if data.get_user_global_notes_count(user.id).await.unwrap_or(0) + >= if user.permissions.check(FinePermission::SUPPORTER) { + 10 + } else { + 5 + } + { + return Json( + Error::MiscError( + "You already have the maximum number of global notes you can have".to_string(), + ) + .into(), + ); + } + + // make sure note doesn't already exist globally + if data.get_global_note_by_title(¬e.title).await.is_ok() { + return Json( + Error::MiscError( + "Note name is already in use globally. Please change the name and try again" + .to_string(), + ) + .into(), + ); + } + + // ... + match data.update_note_is_global(id, 1).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Note updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn unpublish_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::UserManageNotes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let note = match data.get_note_by_id(id).await { + Ok(n) => n, + Err(e) => return Json(e.into()), + }; + + if user.id != note.owner { + return Json(Error::NotAllowed.into()); + } + + // ... + match data.update_note_is_global(id, 0).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Note updated".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 8faab1f..1fe5c87 100644 --- a/crates/app/src/routes/api/v1/stacks.rs +++ b/crates/app/src/routes/api/v1/stacks.rs @@ -1,12 +1,72 @@ -use crate::{State, get_user_from_token}; -use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use crate::{get_user_from_token, routes::pages::PaginatedQuery, State}; +use axum::{ + extract::{Path, Query}, + response::IntoResponse, + Extension, Json, +}; use axum_extra::extract::CookieJar; -use tetratto_core::model::{oauth, stacks::UserStack, ApiReturn, Error}; +use tetratto_core::{ + model::{ + oauth, + permissions::FinePermission, + stacks::{StackBlock, StackMode, StackPrivacy, UserStack}, + ApiReturn, Error, + }, + DataManager, +}; use super::{ AddOrRemoveStackUser, CreateStack, UpdateStackMode, UpdateStackName, UpdateStackPrivacy, UpdateStackSort, }; +pub async fn get_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadStacks) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let stack = match data.get_stack_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if stack.privacy == StackPrivacy::Private + && user.id != stack.owner + && ((stack.mode != StackMode::Circle) | stack.users.contains(&user.id)) + && !user.permissions.check(FinePermission::MANAGE_STACKS) + { + return Json(Error::NotAllowed.into()); + } + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(stack), + }) +} + +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::UserReadStacks) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.get_stacks_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, @@ -151,7 +211,30 @@ pub async fn add_user_request( Err(e) => return Json(e.into()), }; + if stack.users.contains(&other_user.id) { + return Json(Error::MiscError("This user is already in this stack".to_string()).into()); + } + stack.users.push(other_user.id); + + // check number of stacks + let owner = match data.get_user_by_id(stack.owner).await { + Ok(ua) => ua, + Err(e) => return Json(e.into()), + }; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + if stack.users.len() >= DataManager::MAXIMUM_FREE_STACK_USERS { + return Json( + Error::MiscError( + "This stack already has the maximum users it can have".to_string(), + ) + .into(), + ); + } + } + + // ... match data.update_stack_users(id, &user, stack.users).await { Ok(_) => Json(ApiReturn { ok: true, @@ -221,3 +304,94 @@ pub async fn delete_request( Err(e) => Json(e.into()), } } + +pub async fn get_users_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Query(props): Query, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadProfiles) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let stack = match data.get_stack_by_id(id).await { + Ok(s) => s, + Err(e) => return Json(e.into()), + }; + + if stack.privacy == StackPrivacy::Private + && user.id != stack.owner + && !(stack.mode == StackMode::Circle && stack.users.contains(&user.id)) + && !user.permissions.check(FinePermission::MANAGE_STACKS) + { + return Json(Error::NotAllowed.into()); + } + + match data.get_stack_users(id, 12, props.page).await { + Ok(users) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some({ + let mut out = Vec::new(); + + for mut u in users.clone() { + u.clean(); + out.push(u) + } + + out + }), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn 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::UserReadProfiles) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.create_stackblock(StackBlock::new(user.id, id)).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn unblock_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::UserReadProfiles) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let block = match data.get_stackblock_by_initiator_stack(user.id, id).await { + Ok(b) => b, + Err(e) => return Json(e.into()), + }; + + match data.delete_stackblock(block.id, user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/uploads.rs b/crates/app/src/routes/api/v1/uploads.rs index c90c427..0e7d6ab 100644 --- a/crates/app/src/routes/api/v1/uploads.rs +++ b/crates/app/src/routes/api/v1/uploads.rs @@ -4,7 +4,7 @@ use axum_extra::extract::CookieJar; use pathbufd::PathBufD; use crate::{get_user_from_token, State}; use super::auth::images::read_image; -use tetratto_core::model::{oauth, ApiReturn, Error}; +use tetratto_core::model::{carp::CarpGraph, oauth, uploads::MediaType, ApiReturn, Error}; pub async fn get_request( Path(id): Path, @@ -12,7 +12,20 @@ pub async fn get_request( ) -> impl IntoResponse { let data = &(data.read().await).0; - let upload = data.get_upload_by_id(id).await.unwrap(); + let upload = match data.get_upload_by_id(id).await { + Ok(u) => u, + Err(_) => { + return Err(( + [("Content-Type", "image/svg+xml")], + Body::from(read_image(PathBufD::current().extend(&[ + data.0.0.dirs.media.as_str(), + "images", + "default-avatar.svg", + ]))), + )); + } + }; + let path = upload.path(&data.0.0); if !exists(&path).unwrap() { @@ -26,10 +39,17 @@ pub async fn get_request( )); } - Ok(( - [("Content-Type", upload.what.mime())], - Body::from(read_image(path)), - )) + let bytes = read_image(path); + + if upload.what == MediaType::Carpgraph { + // conver to svg and return + return Ok(( + [("Content-Type", "image/svg+xml".to_string())], + Body::from(CarpGraph::from_bytes(bytes).to_svg()), + )); + } + + Ok(([("Content-Type", upload.what.mime())], Body::from(bytes))) } pub async fn delete_request( diff --git a/crates/app/src/routes/api/v1/util.rs b/crates/app/src/routes/api/v1/util.rs index f76060a..8714968 100644 --- a/crates/app/src/routes/api/v1/util.rs +++ b/crates/app/src/routes/api/v1/util.rs @@ -1,5 +1,5 @@ use super::auth::images::read_image; -use crate::State; +use crate::{get_user_from_token, State}; use axum::{ body::Body, extract::Query, @@ -7,10 +7,13 @@ use axum::{ response::IntoResponse, Extension, }; +use axum_extra::extract::CookieJar; use pathbufd::PathBufD; use serde::Deserialize; +use tetratto_core::model::permissions::FinePermission; -pub const MAXIMUM_PROXY_FILE_SIZE: u64 = 4194304; // 4 MiB +pub const MAXIMUM_PROXY_FILE_SIZE: u64 = 4_194_304; // 4 MiB +pub const MAXIMUM_SUPPORTER_PROXY_FILE_SIZE: u64 = 10_485_760; // 4 MiB #[derive(Deserialize)] pub struct ProxyQuery { @@ -19,10 +22,22 @@ pub struct ProxyQuery { /// Proxy an external url pub async fn proxy_request( + jar: CookieJar, Query(props): Query, Extension(data): Extension, ) -> impl IntoResponse { let data = &(data.read().await); + let user = get_user_from_token!(jar, data.0); + let maximum_size = if let Some(ref ua) = user { + if ua.permissions.check(FinePermission::SUPPORTER) { + MAXIMUM_SUPPORTER_PROXY_FILE_SIZE + } else { + MAXIMUM_PROXY_FILE_SIZE + } + } else { + MAXIMUM_PROXY_FILE_SIZE + }; + let http = &data.2; let data = &data.0.0; @@ -60,7 +75,7 @@ pub async fn proxy_request( match http.get(image_url).send().await { Ok(stream) => { let size = stream.content_length(); - if size.unwrap_or_default() > MAXIMUM_PROXY_FILE_SIZE { + if size.unwrap_or_default() > maximum_size { // return defualt image (content too big) return ( [("Content-Type", "image/svg+xml")], diff --git a/crates/app/src/routes/assets.rs b/crates/app/src/routes/assets.rs index 82a759e..4a450c5 100644 --- a/crates/app/src/routes/assets.rs +++ b/crates/app/src/routes/assets.rs @@ -12,8 +12,10 @@ serve_asset!(favicon_request: FAVICON("image/svg+xml")); serve_asset!(style_css_request: STYLE_CSS("text/css")); serve_asset!(root_css_request: ROOT_CSS("text/css")); serve_asset!(utility_css_request: UTILITY_CSS("text/css")); +serve_asset!(chats_css_request: CHATS_CSS("text/css")); serve_asset!(loader_js_request: LOADER_JS("text/javascript")); 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")); diff --git a/crates/app/src/routes/mod.rs b/crates/app/src/routes/mod.rs index 9538133..d67dc0c 100644 --- a/crates/app/src/routes/mod.rs +++ b/crates/app/src/routes/mod.rs @@ -14,14 +14,20 @@ pub fn routes(config: &Config) -> Router { .route("/css/style.css", get(assets::style_css_request)) .route("/css/root.css", get(assets::root_css_request)) .route("/css/utility.css", get(assets::utility_css_request)) + .route("/css/chats.css", get(assets::chats_css_request)) .route("/js/loader.js", get(assets::loader_js_request)) .route("/js/atto.js", get(assets::atto_js_request)) .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)) .nest_service( "/public", get_service(tower_http::services::ServeDir::new(&config.dirs.assets)), ) + .nest_service( + "/icons", + get_service(tower_http::services::ServeDir::new(&config.dirs.icons)), + ) .nest_service( "/reference", get_service(tower_http::services::ServeDir::new(&config.dirs.rustdoc)), diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs index d2363b8..e11b685 100644 --- a/crates/app/src/routes/pages/communities.rs +++ b/crates/app/src/routes/pages/communities.rs @@ -11,8 +11,12 @@ use axum_extra::extract::CookieJar; use serde::Deserialize; use tera::Context; use tetratto_core::model::{ - auth::User, communities::Community, communities_permissions::CommunityPermission, - permissions::FinePermission, Error, + auth::User, + communities::Community, + communities_permissions::CommunityPermission, + permissions::FinePermission, + stacks::{StackMode, UserStack}, + Error, }; #[macro_export] @@ -245,6 +249,8 @@ pub struct CreatePostProps { #[serde(default)] pub community: usize, #[serde(default)] + pub stack: usize, + #[serde(default)] pub from_draft: usize, #[serde(default)] pub quote: usize, @@ -286,6 +292,16 @@ pub async fn create_post_request( communities.push(community) } + let stacks = match data.0.get_stacks_by_user(user.id).await { + Ok(s) => s, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let stacks: Vec<&UserStack> = stacks + .iter() + .filter(|x| x.mode == StackMode::Circle) + .collect(); + // get draft let draft = if props.from_draft != 0 { match data.0.get_draft_by_id(props.from_draft).await { @@ -326,8 +342,10 @@ pub async fn create_post_request( context.insert("draft", &draft); context.insert("drafts", &drafts); + context.insert("stacks", &stacks); context.insert("quoting", "ing); context.insert("communities", &communities); + context.insert("selected_stack", &props.stack); context.insert("selected_community", &props.community); // return @@ -399,7 +417,7 @@ pub async fn feed_request( let feed = match data .0 - .get_posts_by_community(community.id, 12, props.page) + .get_posts_by_community(community.id, 12, props.page, &user) .await { Ok(p) => match data.0.fill_posts(p, &ignore_users, &user).await { @@ -663,6 +681,28 @@ pub async fn post_request( } } + // check stack + if post.stack != 0 { + let stack = match data.0.get_stack_by_id(post.stack).await { + Ok(c) => c, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + if let Some(ref ua) = user { + if (stack.owner != ua.id) && !stack.users.contains(&ua.id) { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } else { + // we MUST be authenticated to view posts in a stack + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } + + // ... let community = match data.0.get_community_by_id(post.community).await { Ok(c) => c, Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), @@ -712,7 +752,7 @@ pub async fn post_request( } // check repost - let reposting = data.0.get_post_reposting(&post, &ignore_users, &user).await; + 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 { diff --git a/crates/app/src/routes/pages/forge.rs b/crates/app/src/routes/pages/forge.rs index 404455c..be1769c 100644 --- a/crates/app/src/routes/pages/forge.rs +++ b/crates/app/src/routes/pages/forge.rs @@ -161,7 +161,7 @@ pub async fn tickets_request( let feed = match data .0 - .get_posts_by_community(community.id, 12, props.page) + .get_posts_by_community(community.id, 12, props.page, &user) .await { Ok(p) => match data.0.fill_posts(p, &ignore_users, &user).await { diff --git a/crates/app/src/routes/pages/journals.rs b/crates/app/src/routes/pages/journals.rs new file mode 100644 index 0000000..0c35e04 --- /dev/null +++ b/crates/app/src/routes/pages/journals.rs @@ -0,0 +1,366 @@ +use axum::{ + extract::{Path, Query}, + response::{Html, IntoResponse, Redirect}, + Extension, +}; +use axum_extra::extract::CookieJar; +use crate::{ + assets::initial_context, + check_user_blocked_or_private, get_lang, get_user_from_token, + routes::pages::{render_error, JournalsAppQuery}, + State, +}; +use tetratto_core::model::{journals::JournalPrivacyPermission, Error}; + +pub async fn redirect_request() -> impl IntoResponse { + Redirect::to("/journals/0/0") +} + +/// `/journals/{journal}/{note}` +pub async fn app_request( + jar: CookieJar, + Extension(data): Extension, + Path((selected_journal, selected_note)): Path<(usize, usize)>, + 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 journals = match data.0.get_journals_by_user(user.id).await { + Ok(p) => Some(p), + Err(e) => { + return Err(Html( + render_error(e, &jar, &data, &Some(user.to_owned())).await, + )); + } + }; + + let notes = match data.0.get_notes_by_journal(selected_journal).await { + Ok(p) => Some(p), + Err(e) => { + return Err(Html(render_error(e, &jar, &data, &Some(user)).await)); + } + }; + + // get journal and check privacy settings + let journal = if selected_journal != 0 { + match data.0.get_journal_by_id(selected_journal).await { + Ok(p) => Some(p), + Err(e) => { + return Err(Html(render_error(e, &jar, &data, &Some(user)).await)); + } + } + } else { + None + }; + + if let Some(ref j) = journal { + // if we're not the owner, we shouldn't be viewing this journal from this endpoint + if user.id != j.owner { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &Some(user.to_owned())).await, + )); + } + } + + // ... + let note = if selected_note != 0 { + match data.0.get_note_by_id(selected_note).await { + Ok(p) => Some(p), + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + } + } else { + None + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &Some(user.clone())).await; + + context.insert("selected_journal", &selected_journal); + context.insert("selected_note", &selected_note); + + context.insert("journal", &journal); + context.insert("note", ¬e); + + context.insert("owner", &user); + context.insert("journals", &journals); + context.insert("notes", ¬es); + + context.insert("view_mode", &props.view); + context.insert("is_editor", &true); + + // return + Ok(Html(data.1.render("journals/app.html", &context).unwrap())) +} + +/// `/@{owner}/{journal}/{note}` +pub async fn view_request( + jar: CookieJar, + Extension(data): Extension, + Path((owner, selected_journal, mut selected_note)): Path<(String, String, String)>, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => Some(ua), + None => None, + }; + + if selected_note == "index" { + selected_note = String::new(); + } + + // if we don't have a selected journal, we shouldn't be here probably + if selected_journal.is_empty() | (selected_note == "journal.css") { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + + // get owner + let owner = match data.0.get_user_by_username(&owner).await { + Ok(ua) => ua, + Err(e) => { + return Err(Html(render_error(e, &jar, &data, &user).await)); + } + }; + + check_user_blocked_or_private!(user, owner, data, jar); + + // get journal and check privacy settings + let journal = match data + .0 + .get_journal_by_owner_title(owner.id, &selected_journal) + .await + { + Ok(p) => p, + Err(e) => { + return Err(Html(render_error(e, &jar, &data, &user).await)); + } + }; + + if journal.privacy == JournalPrivacyPermission::Private { + if let Some(ref user) = user { + if user.id != journal.owner { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &Some(user.to_owned())).await, + )); + } + } else { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } + + // ... + let note = if !selected_note.is_empty() { + match data + .0 + .get_note_by_journal_title(journal.id, &selected_note) + .await + { + Ok(p) => Some(p), + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + } + } else { + None + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &user).await; + + if selected_journal.is_empty() { + context.insert("selected_journal", &0); + } else { + context.insert("selected_journal", &selected_journal); + } + + if selected_note.is_empty() { + context.insert("selected_note", &0); + } else { + context.insert("selected_note", &selected_note); + context.insert( + "redis_views", + &data.0.get_note_views(note.as_ref().unwrap().id).await, + ); + } + + context.insert("journal", &journal); + context.insert("note", ¬e); + + context.insert("owner", &owner); + context.insert::<[i8; 0], &str>("notes", &[]); + + context.insert("view_mode", &true); + context.insert("is_editor", &false); + + // return + Ok(Html(data.1.render("journals/app.html", &context).unwrap())) +} + +/// `/@{owner}/{journal}` +pub async fn index_view_request( + jar: CookieJar, + Extension(data): Extension, + Path((owner, selected_journal)): Path<(String, String)>, + Query(props): Query, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => Some(ua), + None => None, + }; + + // get owner + let owner = match data.0.get_user_by_username(&owner).await { + Ok(ua) => ua, + Err(e) => { + return Err(Html(render_error(e, &jar, &data, &user).await)); + } + }; + + check_user_blocked_or_private!(user, owner, data, jar); + + // get journal and check privacy settings + let journal = match data + .0 + .get_journal_by_owner_title(owner.id, &selected_journal) + .await + { + Ok(p) => p, + Err(e) => { + return Err(Html(render_error(e, &jar, &data, &user).await)); + } + }; + + if journal.privacy == JournalPrivacyPermission::Private { + if let Some(ref user) = user { + if user.id != journal.owner { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &Some(user.to_owned())).await, + )); + } + } else { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } + + // ... + let notes = if props.tag.is_empty() { + match data.0.get_notes_by_journal(journal.id).await { + Ok(p) => Some(p), + Err(e) => { + return Err(Html(render_error(e, &jar, &data, &user).await)); + } + } + } else { + match data + .0 + .get_notes_by_journal_tag(journal.id, &props.tag) + .await + { + Ok(p) => Some(p), + Err(e) => { + return Err(Html(render_error(e, &jar, &data, &user).await)); + } + } + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &user).await; + + if selected_journal.is_empty() { + context.insert("selected_journal", &0); + } else { + context.insert("selected_journal", &selected_journal); + } + + context.insert("selected_note", &0); + context.insert("journal", &journal); + + context.insert("owner", &owner); + context.insert("notes", ¬es); + + context.insert("view_mode", &true); + context.insert("is_editor", &false); + context.insert("tag", &props.tag); + + // return + Ok(Html(data.1.render("journals/app.html", &context).unwrap())) +} + +/// `/x/{note}` +pub async fn global_view_request( + jar: CookieJar, + Extension(data): Extension, + Path(mut selected_note): Path, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => Some(ua), + None => None, + }; + + if selected_note == "index" { + selected_note = String::new(); + } + + // if we don't have a selected journal, we shouldn't be here probably + if selected_note == "journal.css" { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + + // ... + let note = match data.0.get_global_note_by_title(&selected_note).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + let journal = match data.0.get_journal_by_id(note.journal).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + // get owner + let owner = match data.0.get_user_by_id(note.owner).await { + Ok(ua) => ua, + Err(e) => { + return Err(Html(render_error(e, &jar, &data, &user).await)); + } + }; + + check_user_blocked_or_private!(user, owner, data, jar); + + // ... + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &user).await; + data.0.incr_note_views(note.id).await; + + context.insert("selected_journal", ¬e.journal); + context.insert("selected_note", &selected_note); + context.insert("redis_views", &data.0.get_note_views(note.id).await); + + context.insert("journal", &journal); + context.insert("note", ¬e); + + context.insert("owner", &owner); + context.insert::<[i8; 0], &str>("notes", &[]); + + context.insert("view_mode", &true); + context.insert("is_editor", &false); + context.insert("global_mode", &true); + + // return + Ok(Html(data.1.render("journals/app.html", &context).unwrap())) +} diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs index 122d82b..44f54aa 100644 --- a/crates/app/src/routes/pages/misc.rs +++ b/crates/app/src/routes/pages/misc.rs @@ -9,7 +9,9 @@ use axum::{ }; use axum_extra::extract::CookieJar; use serde::Deserialize; -use tetratto_core::model::{permissions::FinePermission, requests::ActionType, Error}; +use tetratto_core::model::{ + auth::DefaultTimelineChoice, permissions::FinePermission, requests::ActionType, Error, +}; use std::fs::read_to_string; use pathbufd::PathBufD; @@ -40,22 +42,9 @@ pub async fn index_request( return { // all timeline for unauthenticated users // i'm only changing this for stripe - let list = match data.0.get_latest_posts(12, req.page).await { - Ok(l) => match data - .0 - .fill_posts_with_community(l, 0, &Vec::new(), &None) - .await - { - Ok(l) => l, - Err(e) => return Html(render_error(e, &jar, &data, &None).await), - }, - Err(e) => return 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, &None).await; - context.insert("list", &list); context.insert("page", &req.page); Html(data.1.render("timelines/all.html", &context).unwrap()) }; @@ -99,36 +88,9 @@ pub async fn popular_request( let data = data.read().await; let user = get_user_from_token!(jar, data.0); - let ignore_users = crate::ignore_users_gen!(user, data); - - let list = match data.0.get_popular_posts(12, req.page, 604_800_000).await { - Ok(l) => match data - .0 - .fill_posts_with_community( - l, - if let Some(ref ua) = user { ua.id } else { 0 }, - &ignore_users, - &user, - ) - .await - { - Ok(l) => data.0.posts_muted_phrase_filter( - &l, - if let Some(ref ua) = user { - Some(&ua.settings.muted) - } else { - None - }, - ), - Err(e) => return Html(render_error(e, &jar, &data, &user).await), - }, - Err(e) => return Html(render_error(e, &jar, &data, &user).await), - }; - let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &user).await; - context.insert("list", &list); context.insert("page", &req.page); Html(data.1.render("timelines/popular.html", &context).unwrap()) } @@ -149,30 +111,9 @@ pub async fn following_request( } }; - let ignore_users = crate::ignore_users_gen!(user!, data); - - let list = match data - .0 - .get_posts_from_user_following(user.id, 12, req.page) - .await - { - Ok(l) => match data - .0 - .fill_posts_with_community(l, user.id, &ignore_users, &Some(user.clone())) - .await - { - Ok(l) => data - .0 - .posts_muted_phrase_filter(&l, Some(&user.settings.muted)), - Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), - }, - 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("page", &req.page); Ok(Html( data.1.render("timelines/following.html", &context).unwrap(), @@ -188,36 +129,9 @@ pub async fn all_request( let data = data.read().await; let user = get_user_from_token!(jar, data.0); - let ignore_users = crate::ignore_users_gen!(user, data); - - let list = match data.0.get_latest_posts(12, req.page).await { - Ok(l) => match data - .0 - .fill_posts_with_community( - l, - if let Some(ref ua) = user { ua.id } else { 0 }, - &ignore_users, - &user, - ) - .await - { - Ok(l) => data.0.posts_muted_phrase_filter( - &l, - if let Some(ref ua) = user { - Some(&ua.settings.muted) - } else { - None - }, - ), - Err(e) => return Html(render_error(e, &jar, &data, &user).await), - }, - Err(e) => return Html(render_error(e, &jar, &data, &user).await), - }; - let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &user).await; - context.insert("list", &list); context.insert("page", &req.page); Html(data.1.render("timelines/all.html", &context).unwrap()) } @@ -417,6 +331,14 @@ pub async fn notifications_request( Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), }; + // check and clear + if profile.settings.auto_clear_notifs { + if let Err(e) = data.0.delete_all_notifications(&user).await { + 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; @@ -519,6 +441,34 @@ pub async fn requests_request( Ok(Html(data.1.render("misc/requests.html", &context).unwrap())) } +/// `/achievements` +pub async fn achievements_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 achievements = data.0.fill_achievements(user.achievements.clone()); + + // ... + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + context.insert("achievements", &achievements); + + // return + Ok(Html( + data.1.render("misc/achievements.html", &context).unwrap(), + )) +} + /// `/doc/{file_name}` pub async fn markdown_document_request( jar: CookieJar, @@ -649,3 +599,146 @@ pub async fn search_request( data.1.render("timelines/search.html", &context).unwrap(), )) } + +#[derive(Deserialize)] +pub struct TimelineQuery { + #[serde(default)] + pub tl: DefaultTimelineChoice, + #[serde(default)] + pub page: usize, + #[serde(default)] + pub stack_id: usize, + #[serde(default)] + pub user_id: usize, + #[serde(default)] + pub tag: String, + #[serde(default)] + pub paginated: bool, +} + +/// `/_swiss_army_timeline` +pub async fn swiss_army_timeline_request( + jar: CookieJar, + Extension(data): Extension, + Query(req): Query, +) -> impl IntoResponse { + let data = data.read().await; + let user = get_user_from_token!(jar, data.0); + + let ignore_users = crate::ignore_users_gen!(user, data); + + let list = if req.stack_id != 0 { + // stacks + if let Some(ref ua) = user { + match data + .0 + .get_stack_posts( + ua.id, + req.stack_id, + 12, + req.page, + &ignore_users, + &Some(ua.to_owned()), + ) + .await + { + Ok(l) => l, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + } + } else { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } else { + match if req.user_id != 0 { + // users + let other_user = match data.0.get_user_by_id(req.user_id).await { + Ok(ua) => ua, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }; + + 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 + } 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 + } + DefaultTimelineChoice::PopularPosts => { + data.0.get_popular_posts(12, req.page, 604_800_000).await + } + DefaultTimelineChoice::FollowingPosts => { + if let Some(ref ua) = user { + data.0 + .get_posts_from_user_following(ua.id, 12, req.page) + .await + } else { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } + DefaultTimelineChoice::MyCommunities => { + if let Some(ref ua) = user { + data.0 + .get_posts_from_user_communities(ua.id, 12, req.page) + .await + } else { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } + // questions bad + _ => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &user).await, + )); + } + } + } { + Ok(l) => match data + .0 + .fill_posts_with_community( + l, + if let Some(ref ua) = user { ua.id } else { 0 }, + &ignore_users, + &user, + ) + .await + { + Ok(l) => data.0.posts_muted_phrase_filter( + &l, + if let Some(ref ua) = user { + Some(&ua.settings.muted) + } else { + None + }, + ), + Err(e) => return Ok(Html(render_error(e, &jar, &data, &user).await)), + }, + Err(e) => return Ok(Html(render_error(e, &jar, &data, &user).await)), + } + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &user).await; + + context.insert("list", &list); + context.insert("page", &req.page); + context.insert("paginated", &req.paginated); + Ok(Html( + data.1 + .render("timelines/swiss_army.html", &context) + .unwrap(), + )) +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index f7d542f..909fa2d 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -1,15 +1,14 @@ pub mod auth; +pub mod chats; pub mod communities; pub mod developer; pub mod forge; +pub mod journals; pub mod misc; pub mod mod_panel; pub mod profile; pub mod stacks; -#[cfg(feature = "redis")] -pub mod chats; - use axum::{ routing::{get, post}, Router, @@ -31,6 +30,10 @@ pub fn routes() -> Router { .route("/following", get(misc::following_request)) .route("/all", get(misc::all_request)) .route("/search", get(misc::search_request)) + .route( + "/_swiss_army_timeline", + get(misc::swiss_army_timeline_request), + ) // question timelines .route("/questions", get(misc::index_questions_request)) .route("/popular/questions", get(misc::popular_questions_request)) @@ -42,6 +45,7 @@ pub fn routes() -> Router { // misc .route("/notifs", get(misc::notifications_request)) .route("/requests", get(misc::requests_request)) + .route("/achievements", get(misc::achievements_request)) .route("/doc/{*file_name}", get(misc::markdown_document_request)) .fallback_service(get(misc::not_found)) // mod @@ -126,8 +130,15 @@ pub fn routes() -> Router { .route("/developer/app/{id}", get(developer::app_request)) // stacks .route("/stacks", get(stacks::list_request)) - .route("/stacks/{id}", get(stacks::posts_request)) + .route("/stacks/{id}", get(stacks::feed_request)) .route("/stacks/{id}/manage", get(stacks::manage_request)) + .route("/stacks/add_user/{id}", get(stacks::add_user_request)) + // journals + .route("/journals", get(journals::redirect_request)) + .route("/journals/{journal}/{note}", get(journals::app_request)) + .route("/@{owner}/{journal}", get(journals::index_view_request)) + .route("/@{owner}/{journal}/{note}", get(journals::view_request)) + .route("/x/{note}", get(journals::global_view_request)) } pub async fn render_error( @@ -183,3 +194,11 @@ pub struct RepostsQuery { #[serde(default)] pub page: usize, } + +#[derive(Deserialize)] +pub struct JournalsAppQuery { + #[serde(default)] + pub view: bool, + #[serde(default)] + pub tag: String, +} diff --git a/crates/app/src/routes/pages/mod_panel.rs b/crates/app/src/routes/pages/mod_panel.rs index 860bf0e..7a9b6f7 100644 --- a/crates/app/src/routes/pages/mod_panel.rs +++ b/crates/app/src/routes/pages/mod_panel.rs @@ -194,10 +194,23 @@ pub async fn manage_profile_request( out }; + let invite_code = if profile.invite_code != 0 { + match data.0.get_invite_code_by_id(profile.invite_code).await { + Ok(i) => match data.0.get_user_by_id(i.owner).await { + Ok(u) => Some((u, i)), + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + } + } else { + None + }; + let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; context.insert("profile", &profile); + context.insert("invite", &invite_code); context.insert("associations", &associations); // return @@ -298,6 +311,35 @@ pub async fn stats_request(jar: CookieJar, Extension(data): Extension) -> .unwrap(), ); + context.insert( + "table_users", + &data.0.get_table_row_count("users").await.unwrap_or(0), + ); + context.insert( + "table_posts", + &data.0.get_table_row_count("posts").await.unwrap_or(0), + ); + context.insert( + "table_invite_codes", + &data + .0 + .get_table_row_count("invite_codes") + .await + .unwrap_or(0), + ); + context.insert( + "table_uploads", + &data.0.get_table_row_count("uploads").await.unwrap_or(0), + ); + context.insert( + "table_communities", + &data.0.get_table_row_count("communities").await.unwrap_or(0), + ); + context.insert( + "table_ipbans", + &data.0.get_table_row_count("ipbans").await.unwrap_or(0), + ); + // return Ok(Html(data.1.render("mod/stats.html", &context).unwrap())) } diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index f559b3c..17d0d1f 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -1,6 +1,7 @@ use super::{render_error, PaginatedQuery, ProfileQuery}; use crate::{ - assets::initial_context, check_user_blocked_or_private, get_lang, get_user_from_token, State, + assets::initial_context, check_user_blocked_or_private, get_lang, get_user_from_token, + ignore_users_gen, State, }; use axum::{ Extension, @@ -50,7 +51,7 @@ pub async fn settings_request( } }; - let stacks = match data.0.get_stacks_by_owner(profile.id).await { + let stacks = match data.0.get_stacks_by_user(profile.id).await { Ok(ua) => ua, Err(e) => { return Err(Html(render_error(e, &jar, &data, &None).await)); @@ -80,6 +81,19 @@ pub async fn settings_request( Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), }; + let stackblocks = { + let mut out = Vec::new(); + + for block in data.0.get_stackblocks_by_initiator(profile.id).await { + out.push(match data.0.get_stack_by_id(block.stack).await { + Ok(s) => s, + Err(_) => continue, + }); + } + + out + }; + let uploads = match data.0.get_uploads_by_owner(profile.id, 12, req.page).await { Ok(ua) => ua, Err(e) => { @@ -87,6 +101,22 @@ pub async fn settings_request( } }; + let invites = match data + .0 + .get_invite_codes_by_owner(profile.id, 12, req.page) + .await + { + Ok(l) => match data.0.fill_invite_codes(l).await { + Ok(l) => l, + Err(e) => { + return Err(Html(render_error(e, &jar, &data, &None).await)); + } + }, + Err(e) => { + return Err(Html(render_error(e, &jar, &data, &None).await)); + } + }; + let tokens = profile.tokens.clone(); let lang = get_lang!(jar, data.0); @@ -98,6 +128,8 @@ pub async fn settings_request( context.insert("stacks", &stacks); context.insert("following", &following); context.insert("blocks", &blocks); + context.insert("stackblocks", &stackblocks); + context.insert("invites", &invites); context.insert( "user_tokens_serde", &serde_json::to_string(&tokens) @@ -128,59 +160,37 @@ pub async fn settings_request( ) .unwrap(); - let light = serde_json::Value::from("Light"); - let mut profile_theme = settings_map - .get("profile_theme") - .unwrap_or(&light) - .as_str() - .unwrap(); + if let Some(color_surface) = settings_map.get("theme_color_surface") { + let color_surface = color_surface.as_str().unwrap(); + for setting in &settings_map { + if !setting.0.starts_with("theme_color_text") + | (setting.0 == "theme_color_text_primary") + | (setting.0 == "theme_color_text_secondary") + { + continue; + } - if profile_theme.is_empty() | (profile_theme == "Auto") { - profile_theme = "Light"; - } + let value = setting.1.as_str().unwrap(); - let default_surface = serde_json::Value::from(if profile_theme == "Light" { - "#f3f2f1" + if !value.starts_with("#") { + // we can only parse hex right now + continue; + } + + let c1 = Color::from(color_surface); + let c2 = Color::from(value); + let contrast = c1.contrast(&c2); + + if contrast < MINIMUM_CONTRAST_THRESHOLD { + failing_color_keys.push((setting.0, contrast)); + } + } + + context.insert("failing_color_keys", &failing_color_keys); } else { - "#19171c" - }); - - let mut color_surface = settings_map - .get("theme_color_surface") - .unwrap_or(&default_surface) - .as_str() - .unwrap(); - - if color_surface.is_empty() { - color_surface = default_surface.as_str().unwrap(); + context.insert("failing_color_keys", &Vec::<&str>::new()); } - for setting in &settings_map { - if !setting.0.starts_with("theme_color_text") - | (setting.0 == "theme_color_text_primary") - | (setting.0 == "theme_color_text_secondary") - { - continue; - } - - let value = setting.1.as_str().unwrap(); - - if !value.starts_with("#") { - // we can only parse hex right now - continue; - } - - let c1 = Color::from(color_surface); - let c2 = Color::from(value); - let contrast = c1.contrast(&c2); - - if contrast < MINIMUM_CONTRAST_THRESHOLD { - failing_color_keys.push((setting.0, contrast)); - } - } - - context.insert("failing_color_keys", &failing_color_keys); - // return Ok(Html( data.1.render("profile/settings.html", &context).unwrap(), @@ -249,67 +259,8 @@ pub async fn posts_request( )); } - // fetch data - let ignore_users = crate::ignore_users_gen!(user, data); - - let posts = if props.tag.is_empty() { - match data - .0 - .get_posts_by_user(other_user.id, 12, props.page, &user) - .await - { - Ok(p) => match data - .0 - .fill_posts_with_community( - p, - if let Some(ref ua) = user { ua.id } else { 0 }, - &ignore_users, - &user, - ) - .await - { - Ok(p) => data.0.posts_muted_phrase_filter( - &p, - if let Some(ref ua) = user { - Some(&ua.settings.muted) - } else { - None - }, - ), - Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), - }, - Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), - } - } else { - match data - .0 - .get_posts_by_user_tag(other_user.id, &props.tag, 12, props.page, &user) - .await - { - Ok(p) => match data - .0 - .fill_posts_with_community( - p, - if let Some(ref ua) = user { ua.id } else { 0 }, - &ignore_users, - &user, - ) - .await - { - Ok(p) => data.0.posts_muted_phrase_filter( - &p, - if let Some(ref ua) = user { - Some(&ua.settings.muted) - } else { - None - }, - ), - Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), - }, - Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), - } - }; - + // fetch pinned + let ignore_users = ignore_users_gen!(user, data); let pinned = if props.tag.is_empty() { match data.0.get_pinned_posts_by_user(other_user.id).await { Ok(p) => match data @@ -383,7 +334,6 @@ pub async fn posts_request( false }; - context.insert("posts", &posts); context.insert("pinned", &pinned); context.insert("page", &props.page); context.insert("tag", &props.tag); diff --git a/crates/app/src/routes/pages/stacks.rs b/crates/app/src/routes/pages/stacks.rs index 48bfdb1..e8285e9 100644 --- a/crates/app/src/routes/pages/stacks.rs +++ b/crates/app/src/routes/pages/stacks.rs @@ -4,7 +4,12 @@ use axum::{ Extension, }; use axum_extra::extract::CookieJar; -use tetratto_core::model::{permissions::FinePermission, stacks::StackPrivacy, Error, auth::User}; +use tetratto_core::model::{ + auth::User, + permissions::FinePermission, + stacks::{StackMode, StackPrivacy}, + Error, +}; use crate::{assets::initial_context, get_lang, get_user_from_token, State}; use super::{render_error, PaginatedQuery}; @@ -20,7 +25,7 @@ pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> } }; - let list = match data.0.get_stacks_by_owner(user.id).await { + let list = match data.0.get_stacks_by_user(user.id).await { Ok(p) => p, Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), }; @@ -35,7 +40,7 @@ pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> } /// `/stacks/{id}` -pub async fn posts_request( +pub async fn feed_request( jar: CookieJar, Extension(data): Extension, Path(id): Path, @@ -58,6 +63,7 @@ pub async fn posts_request( if stack.privacy == StackPrivacy::Private && user.id != stack.owner + && !(stack.mode == StackMode::Circle && stack.users.contains(&user.id)) && !user.permissions.check(FinePermission::MANAGE_STACKS) { return Err(Html( @@ -65,32 +71,31 @@ pub async fn posts_request( )); } - let ignore_users = crate::ignore_users_gen!(user!, data); - let list = match data - .0 - .get_stack_posts( - user.id, - stack.id, - 12, - req.page, - &ignore_users, - &Some(user.clone()), - ) - .await - { - Ok(l) => l, - 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; + let mut context = initial_context(&data.0.0.0, lang, &Some(user.clone())).await; context.insert("page", &req.page); context.insert("stack", &stack); - context.insert("list", &list); + + if stack.mode == StackMode::BlockList { + let list = match data.0.get_stack_users(stack.id, 12, req.page).await { + Ok(l) => l, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + context.insert("list", &list); + context.insert( + "is_blocked", + &data + .0 + .get_stackblock_by_initiator_stack(user.id, stack.id) + .await + .is_ok(), + ); + } // return - Ok(Html(data.1.render("stacks/posts.html", &context).unwrap())) + Ok(Html(data.1.render("stacks/feed.html", &context).unwrap())) } /// `/stacks/{id}/manage` @@ -152,3 +157,41 @@ pub async fn manage_request( // return Ok(Html(data.1.render("stacks/manage.html", &context).unwrap())) } + +/// `/stacks/add_user` +pub async fn add_user_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 add_user = match data.0.get_user_by_id(id).await { + Ok(ua) => ua, + Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), + }; + + let stacks = match data.0.get_stacks_by_user(user.id).await { + Ok(p) => p, + 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("stacks", &stacks); + context.insert("add_user", &add_user); + + // return + Ok(Html( + data.1.render("stacks/add_user.html", &context).unwrap(), + )) +} diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 01e09c0..b2fd135 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,14 +1,8 @@ [package] name = "tetratto-core" -version = "7.0.0" +version = "9.0.0" edition = "2024" -[features] -postgres = ["oiseau/postgres"] -sqlite = ["oiseau/sqlite"] -redis = ["oiseau/redis"] -default = ["sqlite", "redis"] - [dependencies] pathbufd = "0.1.4" serde = { version = "1.0.219", features = ["derive"] } @@ -17,7 +11,7 @@ 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.19", features = ["json"] } +reqwest = { version = "0.12.20", features = ["json"] } bitflags = "2.9.1" async-recursion = "1.1.1" md-5 = "0.10.6" @@ -25,4 +19,4 @@ base16ct = { version = "0.2.0", features = ["alloc"] } base64 = "0.22.1" emojis = "0.6.4" regex = "1.11.1" -oiseau = { version = "0.1.2", default-features = false } +oiseau = { version = "0.1.2", default-features = false, features = ["postgres", "redis"] } diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 7e2713b..7de4cfb 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -13,6 +13,9 @@ pub struct SecurityConfig { /// The name of the header which will contain the real IP of the connecting user. #[serde(default = "default_real_ip_header")] pub real_ip_header: String, + /// If users require an invite code to register. Invite codes can be generated by supporters. + #[serde(default = "default_enable_invite_codes")] + pub enable_invite_codes: bool, } fn default_security_registration_enabled() -> bool { @@ -23,11 +26,16 @@ fn default_real_ip_header() -> String { "CF-Connecting-IP".to_string() } +fn default_enable_invite_codes() -> bool { + false +} + impl Default for SecurityConfig { fn default() -> Self { Self { registration_enabled: default_security_registration_enabled(), real_ip_header: default_real_ip_header(), + enable_invite_codes: default_enable_invite_codes(), } } } @@ -341,6 +349,8 @@ fn default_banned_usernames() -> Vec { "stacks".to_string(), "stack".to_string(), "search".to_string(), + "journals".to_string(), + "links".to_string(), ] } diff --git a/crates/core/src/database/apps.rs b/crates/core/src/database/apps.rs index 9e5e56d..9faf6d4 100644 --- a/crates/core/src/database/apps.rs +++ b/crates/core/src/database/apps.rs @@ -8,20 +8,13 @@ use crate::model::{ }; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] use oiseau::PostgresRow; use oiseau::{execute, get, query_rows, params}; impl DataManager { /// Get a [`ThirdPartyApp`] from an SQL row. - pub(crate) fn get_app_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> ThirdPartyApp { + pub(crate) fn get_app_from_row(x: &PostgresRow) -> ThirdPartyApp { ThirdPartyApp { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/audit_log.rs b/crates/core/src/database/audit_log.rs index f6f46de..d0c3147 100644 --- a/crates/core/src/database/audit_log.rs +++ b/crates/core/src/database/audit_log.rs @@ -2,20 +2,13 @@ use oiseau::cache::Cache; use crate::model::{Error, Result, auth::User, moderation::AuditLogEntry, permissions::FinePermission}; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] use oiseau::PostgresRow; use oiseau::{execute, get, query_rows, params}; impl DataManager { /// Get an [`AuditLogEntry`] from an SQL row. - pub(crate) fn get_audit_log_entry_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> AuditLogEntry { + pub(crate) fn get_audit_log_entry_from_row(x: &PostgresRow) -> AuditLogEntry { AuditLogEntry { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 39626f3..4baeef0 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -1,8 +1,9 @@ use super::common::NAME_REGEX; use oiseau::cache::Cache; -use crate::model::auth::UserConnections; +use crate::model::auth::{Achievement, AchievementRarity, Notification, UserConnections}; use crate::model::moderation::AuditLogEntry; use crate::model::oauth::AuthGrant; +use crate::model::permissions::SecondaryPermission; use crate::model::{ Error, Result, auth::{Token, User, UserSettings}, @@ -15,21 +16,77 @@ use tetratto_shared::{ unix_epoch_timestamp, }; use crate::{auto_method, DataManager}; +use oiseau::{PostgresRow, execute, get, query_row, params}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; +macro_rules! update_role_fn { + ($name:ident, $role_ty:ty, $col:literal) => { + pub async fn $name( + &self, + id: usize, + role: $role_ty, + user: User, + force: bool, + ) -> Result<()> { + let other_user = self.get_user_by_id(id).await?; -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; + if !force { + // check permission + if !user.permissions.check(FinePermission::MANAGE_USERS) { + return Err(Error::NotAllowed); + } -use oiseau::{execute, get, query_row, params}; + if other_user.permissions.check_manager() && !user.permissions.check_admin() { + return Err(Error::MiscError( + "Cannot manage the role of other managers".to_string(), + )); + } + + if other_user.permissions == user.permissions { + return Err(Error::MiscError( + "Cannot manage users of equal level to you".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, + &format!("UPDATE users SET {} = $1 WHERE id = $2", $col), + params![&(role.bits() as i32), &(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 `{}` with x value `{}` and y value `{}`", + $col, + other_user.id, + role.bits() + ), + )) + .await?; + + // ... + Ok(()) + } + }; +} impl DataManager { /// Get a [`User`] from an SQL row. - pub(crate) fn get_user_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> User { + pub(crate) fn get_user_from_row(x: &PostgresRow) -> User { User { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, @@ -52,6 +109,9 @@ impl DataManager { stripe_id: get!(x->18(String)), grants: serde_json::from_str(&get!(x->19(String)).to_string()).unwrap(), associated: serde_json::from_str(&get!(x->20(String)).to_string()).unwrap(), + 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(), } } @@ -207,7 +267,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)", + "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)", params![ &(data.id as i64), &(data.created as i64), @@ -223,13 +283,16 @@ impl DataManager { &0_i32, &(data.last_seen as i64), &String::new(), - &"[]", + "[]", &0_i32, &0_i32, &serde_json::to_string(&data.connections).unwrap(), &"", &serde_json::to_string(&data.grants).unwrap(), &serde_json::to_string(&data.associated).unwrap(), + &(data.invite_code as i64), + &(SecondaryPermission::DEFAULT.bits() as i32), + &serde_json::to_string(&data.achievements).unwrap(), ] ); @@ -396,6 +459,57 @@ impl DataManager { return Err(Error::DatabaseError(e.to_string())); } + // delete stackblocks + let res = execute!( + &conn, + "DELETE FROM stackblocks WHERE initiator = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // delete journals + let res = execute!( + &conn, + "DELETE FROM journals WHERE owner = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // delete notes + let res = execute!(&conn, "DELETE FROM notes WHERE owner = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // delete invite codes + let res = execute!( + &conn, + "DELETE FROM invite_codes WHERE owner = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // delete message reactions + let res = execute!( + &conn, + "DELETE FROM message_reactions WHERE owner = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + // delete user follows... individually since it requires updating user counts for follow in self.get_userfollows_by_receiver_all(id).await? { self.delete_userfollow(follow.id, &user, true).await?; @@ -444,6 +558,16 @@ impl DataManager { self.delete_poll(poll.id, &user).await?; } + // free up invite code + if self.0.0.security.enable_invite_codes { + if user.invite_code != 0 && self.get_invite_code_by_id(user.invite_code).await.is_ok() { + // we're checking if the code is ok because the owner might've deleted their account, + // deleting all of their invite codes as well + self.update_invite_code_is_used(user.invite_code, false) + .await?; + } + } + // ... Ok(()) } @@ -564,67 +688,6 @@ impl DataManager { Ok(()) } - pub async fn update_user_role( - &self, - id: usize, - role: FinePermission, - user: User, - force: bool, - ) -> Result<()> { - let other_user = self.get_user_by_id(id).await?; - - if !force { - // check permission - if !user.permissions.check(FinePermission::MANAGE_USERS) { - return Err(Error::NotAllowed); - } - - if other_user.permissions.check_manager() && !user.permissions.check_admin() { - return Err(Error::MiscError( - "Cannot manage the role of other managers".to_string(), - )); - } - - if other_user.permissions == user.permissions { - return Err(Error::MiscError( - "Cannot manage users of equal level to you".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, - "UPDATE users SET permissions = $1 WHERE id = $2", - params![&(role.bits() as i32), &(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_role` with x value `{}` and y value `{}`", - other_user.id, - role.bits() - ), - )) - .await?; - - // ... - Ok(()) - } - pub async fn seen_user(&self, user: &User) -> Result<()> { let conn = match self.0.connect().await { Ok(c) => c, @@ -646,6 +709,66 @@ impl DataManager { Ok(()) } + /// Add an achievement to a user. + /// + /// Still returns `Ok` if the user already has the achievement. + pub async fn add_achievement(&self, user: &User, achievement: Achievement) -> Result<()> { + if user + .achievements + .iter() + .find(|x| x.name == achievement.name) + .is_some() + { + return Ok(()); + } + + // send notif + self.create_notification(Notification::new( + "You've earned a new achievement!".to_string(), + format!( + "You've earned the \"{}\" [achievement](/achievements)!", + achievement.name.title() + ), + user.id, + )) + .await?; + + // add achievement + let mut user = user.clone(); + user.achievements.push(achievement); + self.update_user_achievements(user.id, user.achievements) + .await?; + + Ok(()) + } + + /// Fill achievements with their title and description. + /// + /// # Returns + /// `(name, description, rarity, achievement)` + pub fn fill_achievements( + &self, + mut list: Vec, + ) -> Vec<(String, String, AchievementRarity, Achievement)> { + let mut out = Vec::new(); + + // sort by unlocked desc + list.sort_by(|a, b| a.unlocked.cmp(&b.unlocked)); + list.reverse(); + + // ... + for x in list { + out.push(( + x.name.title().to_string(), + x.name.description().to_string(), + x.name.rarity(), + x, + )) + } + + out + } + /// Validate a given TOTP code for the given profile. pub fn check_totp(&self, ua: &User, code: &str) -> bool { let totp = ua.totp(Some( @@ -784,11 +907,19 @@ impl DataManager { .await; } + update_role_fn!(update_user_role, FinePermission, "permissions"); + update_role_fn!( + update_user_secondary_role, + SecondaryPermission, + "secondary_permissions" + ); + auto_method!(update_user_tokens(Vec)@get_user_by_id -> "UPDATE users SET tokens = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_grants(Vec)@get_user_by_id -> "UPDATE users SET grants = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_settings(UserSettings)@get_user_by_id -> "UPDATE users SET settings = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); 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!(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); @@ -809,4 +940,6 @@ impl DataManager { auto_method!(update_user_request_count(i32)@get_user_by_id -> "UPDATE users SET request_count = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); auto_method!(incr_user_request_count()@get_user_by_id -> "UPDATE users SET request_count = request_count + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --incr); auto_method!(decr_user_request_count()@get_user_by_id -> "UPDATE users SET request_count = request_count - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --decr=request_count); + + auto_method!(get_user_by_invite_code(i64)@get_user_from_row -> "SELECT * FROM users WHERE invite_code = $1" --name="user" --returns=User); } diff --git a/crates/core/src/database/channels.rs b/crates/core/src/database/channels.rs index d258fc6..b3dc31b 100644 --- a/crates/core/src/database/channels.rs +++ b/crates/core/src/database/channels.rs @@ -6,20 +6,11 @@ use crate::model::{ }; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_row, query_rows, params}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { /// Get a [`Channel`] from an SQL row. - pub(crate) fn get_channel_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> Channel { + pub(crate) fn get_channel_from_row(x: &PostgresRow) -> Channel { Channel { id: get!(x->0(i64)) as usize, community: get!(x->1(i64)) as usize, @@ -30,6 +21,7 @@ impl DataManager { position: get!(x->6(i32)) as usize, members: serde_json::from_str(&get!(x->7(String))).unwrap(), title: get!(x->8(String)), + last_message: get!(x->9(i64)) as usize, } } @@ -90,7 +82,7 @@ impl DataManager { let res = query_rows!( &conn, - "SELECT * FROM channels WHERE (owner = $1 OR members LIKE $2) AND community = 0 ORDER BY created DESC", + "SELECT * FROM channels WHERE (owner = $1 OR members LIKE $2) AND community = 0 ORDER BY last_message DESC", params![&(user as i64), &format!("%{user}%")], |x| { Self::get_channel_from_row(x) } ); @@ -171,7 +163,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO channels VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + "INSERT INTO channels VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", params![ &(data.id as i64), &(data.community as i64), @@ -181,7 +173,8 @@ impl DataManager { &(data.minimum_role_write as i32), &(data.position as i32), &serde_json::to_string(&data.members).unwrap(), - &data.title + &data.title, + &(data.last_message as i64) ] ); @@ -329,4 +322,5 @@ impl DataManager { 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_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 cc62849..45111db 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -1,6 +1,6 @@ use crate::model::{Error, Result}; use super::{DataManager, drivers::common}; -use oiseau::{cache::Cache, execute}; +use oiseau::{cache::Cache, execute, query_row, params}; pub const NAME_REGEX: &str = r"[^\w_\-\.,!]+"; @@ -35,6 +35,11 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_POLLS).unwrap(); execute!(&conn, common::CREATE_TABLE_POLLVOTES).unwrap(); execute!(&conn, common::CREATE_TABLE_APPS).unwrap(); + execute!(&conn, common::CREATE_TABLE_STACKBLOCKS).unwrap(); + execute!(&conn, common::CREATE_TABLE_JOURNALS).unwrap(); + execute!(&conn, common::CREATE_TABLE_NOTES).unwrap(); + execute!(&conn, common::CREATE_TABLE_MESSAGE_REACTIONS).unwrap(); + execute!(&conn, common::CREATE_TABLE_INVITE_CODES).unwrap(); self.0 .1 @@ -47,6 +52,26 @@ impl DataManager { Ok(()) } + + pub async fn get_table_row_count(&self, table: &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 {}", table), + params![], + |x| Ok(x.get::(0)) + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(res.unwrap()) + } } #[macro_export] @@ -111,7 +136,8 @@ macro_rules! auto_method { Err(e) => return Err(Error::DatabaseConnection(e.to_string())), }; - let res = query_row!(&conn, $query, &[&selector], |x| { Ok(Self::$select_fn(x)) }); + let res = + oiseau::query_row!(&conn, $query, &[&selector], |x| { Ok(Self::$select_fn(x)) }); if res.is_err() { return Err(Error::GeneralNotFound($name_.to_string())); @@ -163,7 +189,15 @@ macro_rules! auto_method { .get(format!($cache_key_tmpl, selector.to_string())) .await { - return Ok(serde_json::from_str(&cached).unwrap()); + match serde_json::from_str(&cached) { + Ok(x) => return Ok(x), + Err(_) => { + self.0 + .1 + .remove(format!($cache_key_tmpl, selector.to_string())) + .await + } + }; } let conn = match self.0.connect().await { @@ -363,7 +397,10 @@ macro_rules! auto_method { } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( user.id, - format!("invoked `{}` with x value `{x:?}`", stringify!($name)), + format!( + "invoked `{}` with x value `{id}` and y value `{x:?}`", + stringify!($name) + ), )) .await? } diff --git a/crates/core/src/database/communities.rs b/crates/core/src/database/communities.rs index 52362f5..2642f37 100644 --- a/crates/core/src/database/communities.rs +++ b/crates/core/src/database/communities.rs @@ -14,20 +14,11 @@ use pathbufd::PathBufD; use std::fs::{exists, remove_file}; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_row, query_rows, params}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { /// Get a [`Community`] from an SQL row. - pub(crate) fn get_community_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> Community { + pub(crate) fn get_community_from_row(x: &PostgresRow) -> Community { Community { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, @@ -139,15 +130,10 @@ impl DataManager { Err(e) => return Err(Error::DatabaseConnection(e.to_string())), }; - #[cfg(feature = "sqlite")] - let empty = []; - #[cfg(feature = "postgres")] - let empty = &[]; - let res = query_rows!( &conn, "SELECT * FROM communities WHERE NOT context LIKE '%\"is_nsfw\":true%' ORDER BY member_count DESC LIMIT 12", - empty, + params![], |x| { Self::get_community_from_row(x) } ); diff --git a/crates/core/src/database/drafts.rs b/crates/core/src/database/drafts.rs index 53a7abc..95c2acf 100644 --- a/crates/core/src/database/drafts.rs +++ b/crates/core/src/database/drafts.rs @@ -3,20 +3,13 @@ use crate::model::moderation::AuditLogEntry; use crate::model::{Error, Result, auth::User, communities::PostDraft, permissions::FinePermission}; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] use oiseau::PostgresRow; use oiseau::{execute, get, query_rows, params}; impl DataManager { /// Get a [`PostDraft`] from an SQL row. - pub(crate) fn get_draft_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> PostDraft { + pub(crate) fn get_draft_from_row(x: &PostgresRow) -> PostDraft { PostDraft { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, @@ -102,7 +95,7 @@ impl DataManager { let owner = self.get_user_by_id(data.owner).await?; if !owner.permissions.check(FinePermission::SUPPORTER) { - let stacks = self.get_stacks_by_owner(data.owner).await?; + let stacks = self.get_stacks_by_user(data.owner).await?; if stacks.len() >= Self::MAXIMUM_FREE_DRAFTS { return Err(Error::MiscError( diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index 9d8df41..e1cfad7 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -22,3 +22,8 @@ pub const CREATE_TABLE_DRAFTS: &str = include_str!("./sql/create_drafts.sql"); pub const CREATE_TABLE_POLLS: &str = include_str!("./sql/create_polls.sql"); pub const CREATE_TABLE_POLLVOTES: &str = include_str!("./sql/create_pollvotes.sql"); pub const CREATE_TABLE_APPS: &str = include_str!("./sql/create_apps.sql"); +pub const CREATE_TABLE_STACKBLOCKS: &str = include_str!("./sql/create_stackblocks.sql"); +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"); diff --git a/crates/core/src/database/drivers/mod.rs b/crates/core/src/database/drivers/mod.rs index 57d4cb7..60e7435 100644 --- a/crates/core/src/database/drivers/mod.rs +++ b/crates/core/src/database/drivers/mod.rs @@ -2,13 +2,7 @@ pub mod common; use std::collections::HashMap; use tetratto_l10n::{read_langs, LangFile}; - -#[cfg(feature = "sqlite")] -use oiseau::sqlite::{DataManager as OiseauManager, Result}; - -#[cfg(feature = "postgres")] use oiseau::postgres::{DataManager as OiseauManager, Result}; - use crate::config::Config; #[derive(Clone)] diff --git a/crates/core/src/database/drivers/sql/create_channels.sql b/crates/core/src/database/drivers/sql/create_channels.sql index 6b8a29b..83f7ff6 100644 --- a/crates/core/src/database/drivers/sql/create_channels.sql +++ b/crates/core/src/database/drivers/sql/create_channels.sql @@ -7,5 +7,6 @@ CREATE TABLE IF NOT EXISTS channels ( minimum_role_write INT NOT NULL, position INT NOT NULL, members TEXT NOT NULL, - title TEXT NOT NULL + title TEXT NOT NULL, + last_message BIGINT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/create_invite_codes.sql b/crates/core/src/database/drivers/sql/create_invite_codes.sql new file mode 100644 index 0000000..5272745 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_invite_codes.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS invite_codes ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + code TEXT NOT NULL, + is_used INT NOT NULL +) diff --git a/crates/core/src/database/drivers/sql/create_journals.sql b/crates/core/src/database/drivers/sql/create_journals.sql new file mode 100644 index 0000000..47f4a6e --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_journals.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS journals ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + title TEXT NOT NULL, + privacy TEXT NOT NULL, + dirs TEXT NOT NUll +) diff --git a/crates/core/src/database/drivers/sql/create_message_reactions.sql b/crates/core/src/database/drivers/sql/create_message_reactions.sql new file mode 100644 index 0000000..f13a033 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_message_reactions.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS message_reactions ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + message BIGINT NOT NULL, + emoji TEXT NOT NULL, + UNIQUE (owner, message, emoji) +) diff --git a/crates/core/src/database/drivers/sql/create_messages.sql b/crates/core/src/database/drivers/sql/create_messages.sql index 24096b6..235d8dc 100644 --- a/crates/core/src/database/drivers/sql/create_messages.sql +++ b/crates/core/src/database/drivers/sql/create_messages.sql @@ -5,5 +5,6 @@ CREATE TABLE IF NOT EXISTS messages ( created BIGINT NOT NULL, edited BIGINT NOT NULL, content TEXT NOT NULL, - context TEXT NOT NULL + context TEXT NOT NULL, + reactions TEXT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/create_notes.sql b/crates/core/src/database/drivers/sql/create_notes.sql new file mode 100644 index 0000000..5018f91 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_notes.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS notes ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + title TEXT NOT NULL, + journal BIGINT NOT NULL, + content TEXT NOT NULL, + edited BIGINT NOT NULL, + dir BIGINT NOT NULL, + tags TEXT NOT NULL, + is_global INT NOT NULL +) diff --git a/crates/core/src/database/drivers/sql/create_posts.sql b/crates/core/src/database/drivers/sql/create_posts.sql index 58c3eea..b200901 100644 --- a/crates/core/src/database/drivers/sql/create_posts.sql +++ b/crates/core/src/database/drivers/sql/create_posts.sql @@ -17,5 +17,6 @@ CREATE TABLE IF NOT EXISTS posts ( tsvector_content tsvector GENERATED ALWAYS AS (to_tsvector ('english', coalesce(content, ''))) STORED, poll_id BIGINT NOT NULL, title TEXT NOT NULL, - is_open INT NOT NULL DEFAULT 1 + is_open INT NOT NULL DEFAULT 1, + circle BIGINT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/create_questions.sql b/crates/core/src/database/drivers/sql/create_questions.sql index ab23661..d314664 100644 --- a/crates/core/src/database/drivers/sql/create_questions.sql +++ b/crates/core/src/database/drivers/sql/create_questions.sql @@ -12,5 +12,6 @@ CREATE TABLE IF NOT EXISTS questions ( dislikes INT NOT NULL, -- ... context TEXT NOT NULL, - ip TEXT NOT NULL + ip TEXT NOT NULL, + drawings TEXT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/create_stackblocks.sql b/crates/core/src/database/drivers/sql/create_stackblocks.sql new file mode 100644 index 0000000..6e30beb --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_stackblocks.sql @@ -0,0 +1,6 @@ +CREATE TABLE IF NOT EXISTS stackblocks ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + initiator BIGINT NOT NULL, + stack BIGINT 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 fa95be5..9cb0851 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -19,5 +19,7 @@ CREATE TABLE IF NOT EXISTS users ( connections TEXT NOT NULL, stripe_id TEXT NOT NULL, grants TEXT NOT NULL, - associated TEXT NOT NULL + associated TEXT NOT NULL, + secondary_permissions INT NOT NULL, + achievements TEXT NOT NULL ) diff --git a/crates/core/src/database/emojis.rs b/crates/core/src/database/emojis.rs index 0349d01..c61fed6 100644 --- a/crates/core/src/database/emojis.rs +++ b/crates/core/src/database/emojis.rs @@ -7,20 +7,11 @@ use crate::model::{ }; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_row, query_rows, params}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { /// Get a [`CustomEmoji`] from an SQL row. - pub(crate) fn get_emoji_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> CustomEmoji { + pub(crate) fn get_emoji_from_row(x: &PostgresRow) -> CustomEmoji { CustomEmoji { id: get!(x->0(i64)) as usize, owner: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/invite_codes.rs b/crates/core/src/database/invite_codes.rs new file mode 100644 index 0000000..2c6d950 --- /dev/null +++ b/crates/core/src/database/invite_codes.rs @@ -0,0 +1,206 @@ +use oiseau::{cache::Cache, query_row, query_rows}; +use tetratto_shared::unix_epoch_timestamp; +use crate::model::{ + Error, Result, + auth::{User, InviteCode}, + permissions::FinePermission, +}; +use crate::{auto_method, DataManager}; +use oiseau::{PostgresRow, execute, get, params}; + +impl DataManager { + /// Get a [`InviteCode`] from an SQL row. + pub(crate) fn get_invite_code_from_row(x: &PostgresRow) -> InviteCode { + InviteCode { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + code: get!(x->3(String)), + is_used: get!(x->4(i32)) as i8 == 1, + } + } + + 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( + &self, + owner: 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 invite_codes WHERE owner = $1 ORDER BY created DESC LIMIT $2 OFFSET $3", + &[&(owner as i64), &(batch as i64), &((page * batch) as i64)], + |x| { Self::get_invite_code_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("invite_code".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get invite_codes by `owner`. + pub async fn get_invite_codes_by_owner_count(&self, owner: usize) -> 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 COUNT(*)::int FROM invite_codes WHERE owner = $1", + &[&(owner as i64)], + |x| Ok(x.get::(0)) + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("invite_code".to_string())); + } + + Ok(res.unwrap()) + } + + /// Fill a vector of invite codes with the user that used them. + pub async fn fill_invite_codes( + &self, + codes: Vec, + ) -> Result, InviteCode)>> { + let mut out = Vec::new(); + + for code in codes { + if code.is_used { + out.push(( + match self.get_user_by_invite_code(code.id as i64).await { + Ok(u) => Some(u), + Err(_) => None, + }, + code, + )) + } else { + out.push((None, code)) + } + } + + Ok(out) + } + + 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 + + /// 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 + { + return Err(Error::MiscError( + "Your account is too young to do this".to_string(), + )); + } + + // 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) + >= Self::MAXIMUM_FREE_INVITE_CODES + { + return Err(Error::MiscError( + "You already have the maximum number of invite codes you can create" + .to_string(), + )); + } + } else if !user.permissions.check(FinePermission::MANAGE_USERS) { + // check count since we're also not a moderator with MANAGE_USERS + if (self.get_invite_codes_by_owner_count(user.id).await? as usize) + >= Self::MAXIMUM_SUPPORTER_INVITE_CODES + { + return Err(Error::MiscError( + "You already have the maximum number of invite codes you can create" + .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 invite_codes VALUES ($1, $2, $3, $4, $5)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &data.code, + &{ if data.is_used { 1 } else { 0 } } + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_invite_code(&self, id: usize, user: &User) -> Result<()> { + if !user.permissions.check(FinePermission::MANAGE_USERS) { + 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 invite_codes WHERE id = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.0.1.remove(format!("atto.invite_code:{}", id)).await; + + Ok(()) + } + + pub async fn update_invite_code_is_used(&self, id: usize, new_is_used: bool) -> Result<()> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "UPDATE invite_codes SET is_used = $1 WHERE id = $2", + params![&{ if new_is_used { 1 } else { 0 } }, &(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.0.1.remove(format!("atto.invite_code:{}", id)).await; + Ok(()) + } +} diff --git a/crates/core/src/database/ipbans.rs b/crates/core/src/database/ipbans.rs index 4c9e09d..550570d 100644 --- a/crates/core/src/database/ipbans.rs +++ b/crates/core/src/database/ipbans.rs @@ -4,20 +4,11 @@ use crate::model::moderation::AuditLogEntry; use crate::model::{Error, Result, auth::IpBan, auth::User, permissions::FinePermission}; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_row, query_rows, params}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { /// Get a [`IpBan`] from an SQL row. - pub(crate) fn get_ipban_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> IpBan { + pub(crate) fn get_ipban_from_row(x: &PostgresRow) -> IpBan { IpBan { ip: get!(x->0(String)), created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/ipblocks.rs b/crates/core/src/database/ipblocks.rs index 8c2a8f2..f94ed51 100644 --- a/crates/core/src/database/ipblocks.rs +++ b/crates/core/src/database/ipblocks.rs @@ -2,20 +2,13 @@ use oiseau::cache::Cache; use crate::model::{Error, Result, auth::User, auth::IpBlock, permissions::FinePermission}; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] use oiseau::PostgresRow; use oiseau::{execute, get, query_row, params}; impl DataManager { /// Get an [`IpBlock`] from an SQL row. - pub(crate) fn get_ipblock_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> IpBlock { + pub(crate) fn get_ipblock_from_row(x: &PostgresRow) -> IpBlock { IpBlock { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/journals.rs b/crates/core/src/database/journals.rs new file mode 100644 index 0000000..2ad4078 --- /dev/null +++ b/crates/core/src/database/journals.rs @@ -0,0 +1,189 @@ +use oiseau::{cache::Cache, query_row}; +use crate::{ + database::common::NAME_REGEX, + model::{ + auth::User, + journals::{Journal, JournalPrivacyPermission}, + permissions::FinePermission, + Error, Result, + }, +}; +use crate::{auto_method, DataManager}; +use oiseau::{PostgresRow, execute, get, query_rows, params}; + +impl DataManager { + /// Get a [`Journal`] from an SQL row. + pub(crate) fn get_journal_from_row(x: &PostgresRow) -> Journal { + Journal { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + title: get!(x->3(String)), + privacy: serde_json::from_str(&get!(x->4(String))).unwrap(), + dirs: serde_json::from_str(&get!(x->5(String))).unwrap(), + } + } + + auto_method!(get_journal_by_id(usize as i64)@get_journal_from_row -> "SELECT * FROM journals WHERE id = $1" --name="journal" --returns=Journal --cache-key-tmpl="atto.journal:{}"); + + /// Get a journal by `owner` and `title`. + pub async fn get_journal_by_owner_title(&self, owner: usize, title: &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, + "SELECT * FROM journals WHERE owner = $1 AND title = $2", + params![&(owner as i64), &title], + |x| { Ok(Self::get_journal_from_row(x)) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("journal".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get all journals by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch journals for + pub async fn get_journals_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 journals WHERE owner = $1 ORDER BY title ASC", + &[&(id as i64)], + |x| { Self::get_journal_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("journal".to_string())); + } + + Ok(res.unwrap()) + } + + const MAXIMUM_FREE_JOURNALS: usize = 5; + + /// Create a new journal in the database. + /// + /// # Arguments + /// * `data` - a mock [`Journal`] object to insert + pub async fn create_journal(&self, mut data: Journal) -> Result { + // check values + if data.title.len() < 2 { + return Err(Error::DataTooShort("title".to_string())); + } else if data.title.len() > 32 { + return Err(Error::DataTooLong("title".to_string())); + } + + data.title = data.title.replace(" ", "_").to_lowercase(); + + // check name + let regex = regex::RegexBuilder::new(NAME_REGEX) + .multi_line(true) + .build() + .unwrap(); + + if regex.captures(&data.title).is_some() { + return Err(Error::MiscError( + "This title contains invalid characters".to_string(), + )); + } + + // make sure this title isn't already in use + if self + .get_journal_by_owner_title(data.owner, &data.title) + .await + .is_ok() + { + return Err(Error::TitleInUse); + } + + // check number of journals + let owner = self.get_user_by_id(data.owner).await?; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + let journals = self.get_journals_by_user(data.owner).await?; + + if journals.len() >= Self::MAXIMUM_FREE_JOURNALS { + return Err(Error::MiscError( + "You already have the maximum number of journals 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 journals VALUES ($1, $2, $3, $4, $5, $6)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &data.title, + &serde_json::to_string(&data.privacy).unwrap(), + &serde_json::to_string(&data.dirs).unwrap(), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_journal(&self, id: usize, user: &User) -> Result<()> { + let journal = self.get_journal_by_id(id).await?; + + // check user permission + if user.id != journal.owner && !user.permissions.check(FinePermission::MANAGE_JOURNALS) { + 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 journals WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // delete notes + let res = execute!( + &conn, + "DELETE FROM notes WHERE journal = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... + self.0.1.remove(format!("atto.journal:{}", id)).await; + 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:{}"); +} diff --git a/crates/core/src/database/memberships.rs b/crates/core/src/database/memberships.rs index 76fbe2b..4ae7094 100644 --- a/crates/core/src/database/memberships.rs +++ b/crates/core/src/database/memberships.rs @@ -10,20 +10,11 @@ use crate::model::{ }; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_row, query_rows, params}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { /// Get a [`CommunityMembership`] from an SQL row. - pub(crate) fn get_membership_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> CommunityMembership { + pub(crate) fn get_membership_from_row(x: &PostgresRow) -> CommunityMembership { CommunityMembership { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/message_reactions.rs b/crates/core/src/database/message_reactions.rs new file mode 100644 index 0000000..4134049 --- /dev/null +++ b/crates/core/src/database/message_reactions.rs @@ -0,0 +1,183 @@ +use oiseau::{cache::Cache, query_rows}; +use crate::model::{ + Error, Result, + auth::{Notification, User}, + permissions::FinePermission, + channels::MessageReaction, +}; +use crate::{auto_method, DataManager}; + +use oiseau::{PostgresRow, execute, get, query_row, params}; + +impl DataManager { + /// Get a [`MessageReaction`] from an SQL row. + pub(crate) fn get_message_reaction_from_row(x: &PostgresRow) -> MessageReaction { + MessageReaction { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + message: get!(x->3(i64)) as usize, + emoji: get!(x->4(String)), + } + } + + auto_method!(get_message_reaction_by_id()@get_message_reaction_from_row -> "SELECT * FROM message_reactions WHERE id = $1" --name="message_reaction" --returns=MessageReaction --cache-key-tmpl="atto.message_reaction:{}"); + + /// Get message_reactions by `owner` and `message`. + pub async fn get_message_reactions_by_owner_message( + &self, + owner: usize, + message: 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 message_reactions WHERE owner = $1 AND message = $2", + &[&(owner as i64), &(message as i64)], + |x| { Self::get_message_reaction_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("message_reaction".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get a message_reaction by `owner`, `message`, and `emoji`. + pub async fn get_message_reaction_by_owner_message_emoji( + &self, + owner: usize, + message: usize, + emoji: &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, + "SELECT * FROM message_reactions WHERE owner = $1 AND message = $2 AND emoji = $3", + params![&(owner as i64), &(message as i64), &emoji], + |x| { Ok(Self::get_message_reaction_from_row(x)) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("message_reaction".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new message_reaction in the database. + /// + /// # Arguments + /// * `data` - a mock [`MessageReaction`] object to insert + pub async fn create_message_reaction(&self, data: MessageReaction, user: &User) -> Result<()> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let mut message = self.get_message_by_id(data.message).await?; + let channel = self.get_channel_by_id(message.channel).await?; + + // ... + let res = execute!( + &conn, + "INSERT INTO message_reactions VALUES ($1, $2, $3, $4, $5)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &(data.message as i64), + &data.emoji + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // incr corresponding + if let Some(x) = message.reactions.get(&data.emoji) { + message.reactions.insert(data.emoji.clone(), x + 1); + } else { + message.reactions.insert(data.emoji.clone(), 1); + } + + self.update_message_reactions(message.id, message.reactions) + .await?; + + // send notif + if message.owner != user.id { + self + .create_notification(Notification::new( + "Your message has received a reaction!".to_string(), + format!( + "[@{}](/api/v1/auth/user/find/{}) has reacted \"{}\" to your [message](/chats/{}/{}?message={})!", + user.username, user.id, data.emoji, channel.community, channel.id, message.id + ), + message.owner, + )) + .await?; + } + + // return + Ok(()) + } + + pub async fn delete_message_reaction(&self, id: usize, user: &User) -> Result<()> { + let message_reaction = self.get_message_reaction_by_id(id).await?; + + if user.id != message_reaction.owner + && !user.permissions.check(FinePermission::MANAGE_REACTIONS) + { + return Err(Error::NotAllowed); + } + + let mut message = self.get_message_by_id(message_reaction.message).await?; + + 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 message_reactions WHERE id = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.0 + .1 + .remove(format!("atto.message_reaction:{}", id)) + .await; + + // decr message reaction count + if let Some(x) = message.reactions.get(&message_reaction.emoji) { + if *x == 1 { + // there are no 0 of this reaction + message.reactions.remove(&message_reaction.emoji); + } else { + // decr 1 + message.reactions.insert(message_reaction.emoji, x - 1); + } + } + + self.update_message_reactions(message.id, message.reactions) + .await?; + + // return + Ok(()) + } +} diff --git a/crates/core/src/database/messages.rs b/crates/core/src/database/messages.rs index 17ec914..64157f0 100644 --- a/crates/core/src/database/messages.rs +++ b/crates/core/src/database/messages.rs @@ -11,14 +11,7 @@ use serde::Serialize; use tetratto_shared::unix_epoch_timestamp; use crate::{auto_method, DataManager}; -#[cfg(feature = "redis")] -use oiseau::cache::redis::Commands; - -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; +use oiseau::{PostgresRow, cache::redis::Commands}; use oiseau::{execute, get, query_rows, params}; @@ -29,10 +22,7 @@ struct DeleteMessageEvent { impl DataManager { /// Get a [`Message`] from an SQL row. - pub(crate) fn get_message_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> Message { + pub(crate) fn get_message_from_row(x: &PostgresRow) -> Message { Message { id: get!(x->0(i64)) as usize, channel: get!(x->1(i64)) as usize, @@ -41,6 +31,7 @@ impl DataManager { edited: get!(x->4(i64)) as usize, content: get!(x->5(String)), context: serde_json::from_str(&get!(x->6(String))).unwrap(), + reactions: serde_json::from_str(&get!(x->7(String))).unwrap(), } } @@ -228,7 +219,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO messages VALUES ($1, $2, $3, $4, $5, $6, $7)", + "INSERT INTO messages VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", params![ &(data.id as i64), &(data.channel as i64), @@ -236,7 +227,8 @@ impl DataManager { &(data.created as i64), &(data.edited as i64), &data.content, - &serde_json::to_string(&data.context).unwrap() + &serde_json::to_string(&data.context).unwrap(), + &serde_json::to_string(&data.reactions).unwrap(), ] ); @@ -264,6 +256,10 @@ impl DataManager { return Err(Error::MiscError(e.to_string())); } + // update channel position + self.update_channel_last_message(channel.id, unix_epoch_timestamp() as i64) + .await?; + // ... Ok(()) } @@ -363,4 +359,6 @@ impl DataManager { // return Ok(()) } + + auto_method!(update_message_reactions(HashMap) -> "UPDATE messages SET reactions = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.message:{}"); } diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index c7290bf..5f81259 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -1,15 +1,21 @@ mod apps; mod audit_log; mod auth; +mod channels; mod common; mod communities; pub mod connections; mod drafts; mod drivers; mod emojis; +mod invite_codes; mod ipbans; mod ipblocks; +mod journals; mod memberships; +mod message_reactions; +mod messages; +mod notes; mod notifications; mod polls; mod pollvotes; @@ -18,15 +24,12 @@ mod questions; mod reactions; mod reports; mod requests; +mod stackblocks; mod stacks; mod uploads; mod user_warnings; mod userblocks; mod userfollows; -#[cfg(feature = "redis")] -mod channels; -#[cfg(feature = "redis")] -mod messages; - pub use drivers::DataManager; +pub use common::NAME_REGEX; diff --git a/crates/core/src/database/notes.rs b/crates/core/src/database/notes.rs new file mode 100644 index 0000000..2754baf --- /dev/null +++ b/crates/core/src/database/notes.rs @@ -0,0 +1,298 @@ +use oiseau::cache::Cache; +use crate::database::common::NAME_REGEX; +use crate::model::{auth::User, journals::Note, permissions::FinePermission, Error, Result}; +use crate::{auto_method, DataManager}; +use oiseau::{execute, get, params, query_row, query_rows, PostgresRow}; + +impl DataManager { + /// Get a [`Note`] from an SQL row. + pub(crate) fn get_note_from_row(x: &PostgresRow) -> Note { + Note { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + title: get!(x->3(String)), + journal: get!(x->4(i64)) as usize, + content: get!(x->5(String)), + edited: get!(x->6(i64)) as usize, + dir: get!(x->7(i64)) as usize, + tags: serde_json::from_str(&get!(x->8(String))).unwrap(), + is_global: get!(x->9(i32)) as i8 == 1, + } + } + + auto_method!(get_note_by_id(usize as i64)@get_note_from_row -> "SELECT * FROM notes WHERE id = $1" --name="note" --returns=Note --cache-key-tmpl="atto.note:{}"); + auto_method!(get_global_note_by_title(&str)@get_note_from_row -> "SELECT * FROM notes WHERE title = $1 AND is_global = 1" --name="note" --returns=Note --cache-key-tmpl="atto.note:{}"); + + /// Get the number of global notes a user has. + pub async fn get_user_global_notes_count(&self, owner: usize) -> 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 COUNT(*)::int FROM notes WHERE owner = $1 AND is_global = 1", + &[&(owner as i64)], + |x| Ok(x.get::(0)) + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(res.unwrap()) + } + + /// Get a note by `journal` and `title`. + pub async fn get_note_by_journal_title(&self, journal: usize, title: &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, + "SELECT * FROM notes WHERE journal = $1 AND title = $2", + params![&(journal as i64), &title], + |x| { Ok(Self::get_note_from_row(x)) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("note".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get all notes by journal. + /// + /// # Arguments + /// * `id` - the ID of the journal to fetch notes for + pub async fn get_notes_by_journal(&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 notes WHERE journal = $1 ORDER BY edited DESC", + &[&(id as i64)], + |x| { Self::get_note_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("note".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get all notes by journal with the given tag. + /// + /// # Arguments + /// * `id` - the ID of the journal to fetch notes for + /// * `tag` + pub async fn get_notes_by_journal_tag(&self, id: usize, tag: &str) -> 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 notes WHERE journal = $1 AND tags::jsonb ? $2 ORDER BY edited DESC", + params![&(id as i64), tag], + |x| { Self::get_note_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("note".to_string())); + } + + Ok(res.unwrap()) + } + + const MAXIMUM_FREE_NOTES_PER_JOURNAL: usize = 10; + + pub const MAXIMUM_FREE_GLOBAL_NOTES: usize = 10; + pub const MAXIMUM_SUPPORTER_GLOBAL_NOTES: usize = 50; + + /// Create a new note in the database. + /// + /// # Arguments + /// * `data` - a mock [`Note`] object to insert + pub async fn create_note(&self, mut data: Note) -> Result { + // check values + if data.title.len() < 2 { + return Err(Error::DataTooShort("title".to_string())); + } else if data.title.len() > 64 { + return Err(Error::DataTooLong("title".to_string())); + } + + if data.content.len() < 2 { + return Err(Error::DataTooShort("content".to_string())); + } else if data.content.len() > 262144 { + return Err(Error::DataTooLong("content".to_string())); + } + + data.title = data.title.replace(" ", "_").to_lowercase(); + + // check number of notes + let owner = self.get_user_by_id(data.owner).await?; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + let journals = self.get_notes_by_journal(data.owner).await?; + + if journals.len() >= Self::MAXIMUM_FREE_NOTES_PER_JOURNAL { + return Err(Error::MiscError( + "You already have the maximum number of notes you can have in this journal" + .to_string(), + )); + } + } + + // check name + let regex = regex::RegexBuilder::new(NAME_REGEX) + .multi_line(true) + .build() + .unwrap(); + + if regex.captures(&data.title).is_some() { + return Err(Error::MiscError( + "This title contains invalid characters".to_string(), + )); + } + + // make sure this title isn't already in use + if self + .get_note_by_journal_title(data.journal, &data.title) + .await + .is_ok() + { + return Err(Error::TitleInUse); + } + + // check permission + let journal = self.get_journal_by_id(data.journal).await?; + + if data.owner != journal.owner { + 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, + "INSERT INTO notes 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.title, + &(data.journal as i64), + &data.content, + &(data.edited as i64), + &(data.dir as i64), + &serde_json::to_string(&data.tags).unwrap(), + &if data.is_global { 1 } else { 0 } + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_note(&self, id: usize, user: &User) -> Result<()> { + let note = self.get_note_by_id(id).await?; + + // check user permission + if user.id != note.owner && !user.permissions.check(FinePermission::MANAGE_NOTES) { + 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 notes WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... + self.cache_clear_note(¬e).await; + Ok(()) + } + + /// Delete all notes by dir ID. + /// + /// # Arguments + /// * `journal` + /// * `dir` + pub async fn delete_notes_by_journal_dir( + &self, + journal: usize, + dir: usize, + user: &User, + ) -> Result<()> { + let journal = self.get_journal_by_id(journal).await?; + + if journal.owner != user.id && !user.permissions.check(FinePermission::MANAGE_NOTES) { + 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 notes WHERE dir = $1 AND journal = $2 ORDER BY edited DESC", + &[&(dir as i64), &(journal.id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(()) + } + + /// Incremenet note views. Views are only stored in the cache. + /// + /// This should only be done for global notes. + pub async fn incr_note_views(&self, id: usize) { + self.0.1.incr(format!("atto.note:{id}/views")).await; + } + + pub async fn get_note_views(&self, id: usize) -> Option { + self.0.1.get(format!("atto.note:{id}/views")).await + } + + pub async fn cache_clear_note(&self, x: &Note) { + self.0.1.remove(format!("atto.note:{}", x.id)).await; + 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_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/notifications.rs b/crates/core/src/database/notifications.rs index 60659db..75d7d34 100644 --- a/crates/core/src/database/notifications.rs +++ b/crates/core/src/database/notifications.rs @@ -3,23 +3,13 @@ use crate::model::socket::{CrudMessageType, PacketType, SocketMessage, SocketMet use crate::model::{Error, Result, auth::Notification, auth::User, permissions::FinePermission}; use crate::{auto_method, DataManager}; -#[cfg(feature = "redis")] -use oiseau::cache::redis::Commands; - -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; +use oiseau::{PostgresRow, cache::redis::Commands}; use oiseau::{execute, get, query_rows, params}; impl DataManager { /// Get a [`Notification`] from an SQL row. - pub(crate) fn get_notification_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> Notification { + pub(crate) fn get_notification_from_row(x: &PostgresRow) -> Notification { Notification { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, @@ -204,34 +194,39 @@ impl DataManager { } pub async fn delete_all_notifications(&self, user: &User) -> Result<()> { - let notifications = self.get_notifications_by_owner(user.id).await?; + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; - for notification in notifications { - if user.id != notification.owner - && !user.permissions.check(FinePermission::MANAGE_NOTIFICATIONS) - { - return Err(Error::NotAllowed); - } + let res = execute!( + &conn, + "DELETE FROM notifications WHERE owner = $1", + &[&(user.id as i64)] + ); - self.delete_notification(notification.id, user).await? + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); } self.update_user_notification_count(user.id, 0).await?; - Ok(()) } pub async fn delete_all_notifications_by_tag(&self, user: &User, tag: &str) -> Result<()> { - let notifications = self.get_notifications_by_tag(tag).await?; + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; - for notification in notifications { - if user.id != notification.owner - && !user.permissions.check(FinePermission::MANAGE_NOTIFICATIONS) - { - return Err(Error::NotAllowed); - } + let res = execute!( + &conn, + "DELETE FROM notifications WHERE owner = $1 AND tag = $2", + params![&(user.id as i64), tag] + ); - self.delete_notification(notification.id, user).await? + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); } Ok(()) diff --git a/crates/core/src/database/polls.rs b/crates/core/src/database/polls.rs index c683199..72046a4 100644 --- a/crates/core/src/database/polls.rs +++ b/crates/core/src/database/polls.rs @@ -4,20 +4,13 @@ use crate::model::moderation::AuditLogEntry; use crate::model::{Error, Result, auth::User, permissions::FinePermission}; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] use oiseau::PostgresRow; use oiseau::{execute, get, query_rows, params}; impl DataManager { /// Get a [`Poll`] from an SQL row. - pub(crate) fn get_poll_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> Poll { + pub(crate) fn get_poll_from_row(x: &PostgresRow) -> Poll { Poll { id: get!(x->0(i64)) as usize, owner: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/pollvotes.rs b/crates/core/src/database/pollvotes.rs index f354b32..58d8d5f 100644 --- a/crates/core/src/database/pollvotes.rs +++ b/crates/core/src/database/pollvotes.rs @@ -5,20 +5,13 @@ use crate::model::moderation::AuditLogEntry; use crate::model::{Error, Result, auth::User, permissions::FinePermission}; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] use oiseau::PostgresRow; use oiseau::{execute, get, query_row, params}; impl DataManager { /// Get a [`PollVote`] from an SQL row. - pub(crate) fn get_pollvote_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> PollVote { + pub(crate) fn get_pollvote_from_row(x: &PostgresRow) -> PollVote { PollVote { id: get!(x->0(i64)) as usize, owner: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index ac28744..c1f1dca 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -5,7 +5,7 @@ use crate::model::auth::Notification; use crate::model::communities::{Poll, Question}; use crate::model::communities_permissions::CommunityPermission; use crate::model::moderation::AuditLogEntry; -use crate::model::stacks::StackSort; +use crate::model::stacks::{StackMode, StackSort, UserStack}; use crate::model::{ Error, Result, auth::User, @@ -15,15 +15,7 @@ use crate::model::{ use tetratto_shared::unix_epoch_timestamp; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; - -#[cfg(feature = "redis")] -use oiseau::cache::redis::Commands; - +use oiseau::{PostgresRow, cache::redis::Commands}; use oiseau::{execute, get, query_row, query_rows, params}; pub type FullPost = ( @@ -33,6 +25,7 @@ pub type FullPost = ( Option<(User, Post)>, Option<(Question, User)>, Option<(Poll, bool, bool)>, + Option, ); macro_rules! private_post_replying { @@ -101,10 +94,7 @@ macro_rules! private_post_replying { impl DataManager { /// Get a [`Post`] from an SQL row. - pub(crate) fn get_post_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> Post { + pub(crate) fn get_post_from_row(x: &PostgresRow) -> Post { Post { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, @@ -125,6 +115,7 @@ impl DataManager { poll_id: get!(x->13(i64)) as usize, title: get!(x->14(String)), is_open: get!(x->15(i32)) as i8 == 1, + stack: get!(x->16(i64)) as usize, } } @@ -167,26 +158,26 @@ impl DataManager { post: &Post, ignore_users: &[usize], user: &Option, - ) -> Option<(User, Post)> { + ) -> (bool, Option<(User, Post)>) { if let Some(ref repost) = post.context.repost { if let Some(reposting) = repost.reposting { let mut x = match self.get_post_by_id(reposting).await { Ok(p) => p, - Err(_) => return None, + Err(_) => return (true, None), }; if x.is_deleted { - return None; + return (!post.content.is_empty(), None); } if ignore_users.contains(&x.owner) { - return None; + return (!post.content.is_empty(), None); } // check private profile settings let owner = match self.get_user_by_id(x.owner).await { Ok(ua) => ua, - Err(_) => return None, + Err(_) => return (true, None), }; // TODO: maybe check community membership to see if we can MANAGE_POSTS in community @@ -200,29 +191,32 @@ impl DataManager { .is_err() { // owner isn't following us, we aren't the owner, AND we don't have MANAGE_POSTS permission - return None; + return (!post.content.is_empty(), None); } } } else { // private profile, but we're an unauthenticated user - return None; + return (!post.content.is_empty(), None); } } // ... x.mark_as_repost(); - Some(( - match self.get_user_by_id(x.owner).await { - Ok(ua) => ua, - Err(_) => return None, - }, - x, - )) + ( + true, + Some(( + match self.get_user_by_id(x.owner).await { + Ok(ua) => ua, + Err(_) => return (true, None), + }, + x, + )), + ) } else { - None + (true, None) } } else { - None + (true, None) } } @@ -285,6 +279,39 @@ impl DataManager { } } + /// Get the stack of the given post (if some). + /// + /// # Returns + /// `(can view post, stack)` + pub async fn get_post_stack( + &self, + seen_stacks: &mut HashMap, + post: &Post, + as_user_id: usize, + ) -> (bool, Option) { + if post.stack != 0 { + if let Some(s) = seen_stacks.get(&post.stack) { + ( + (s.owner == as_user_id) | s.users.contains(&as_user_id), + Some(s.to_owned()), + ) + } else { + let s = match self.get_stack_by_id(post.stack).await { + Ok(s) => s, + Err(_) => return (true, None), + }; + + seen_stacks.insert(s.id, s.to_owned()); + ( + (s.owner == as_user_id) | s.users.contains(&as_user_id), + Some(s.to_owned()), + ) + } + } else { + (true, None) + } + } + /// Complete a vector of just posts with their owner as well. pub async fn fill_posts( &self, @@ -298,12 +325,14 @@ impl DataManager { Option<(User, Post)>, Option<(Question, User)>, Option<(Poll, bool, bool)>, + Option, )>, > { let mut out = Vec::new(); 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 replying_posts: HashMap = HashMap::new(); for post in posts { @@ -314,16 +343,48 @@ impl DataManager { let owner = post.owner; if let Some(ua) = users.get(&owner) { + // check if owner requires an account to view their posts (and if we have one) + if ua.settings.require_account && user.is_none() { + continue; + } + + // stack + let (can_view, stack) = self + .get_post_stack( + &mut seen_stacks, + &post, + if let Some(ua) = user { ua.id } else { 0 }, + ) + .await; + + if !can_view { + continue; + } + + // reposting + let (can_view, reposting) = + self.get_post_reposting(&post, ignore_users, user).await; + + if !can_view { + continue; + } + + // ... out.push(( post.clone(), ua.clone(), - self.get_post_reposting(&post, ignore_users, user).await, + reposting, self.get_post_question(&post, ignore_users).await?, self.get_post_poll(&post, user).await?, + stack, )); } else { let ua = self.get_user_by_id(owner).await?; + if ua.settings.require_account && user.is_none() { + continue; + } + if ua.permissions.check_banned() | ignore_users.contains(&owner) && !ua.permissions.check(FinePermission::MANAGE_POSTS) { @@ -367,14 +428,36 @@ impl DataManager { } } + // stack + let (can_view, stack) = self + .get_post_stack( + &mut seen_stacks, + &post, + if let Some(ua) = user { ua.id } else { 0 }, + ) + .await; + + if !can_view { + continue; + } + + // reposting + let (can_view, reposting) = + self.get_post_reposting(&post, ignore_users, user).await; + + if !can_view { + continue; + } + // ... users.insert(owner, ua.clone()); out.push(( post.clone(), ua, - self.get_post_reposting(&post, ignore_users, user).await, + reposting, self.get_post_question(&post, ignore_users).await?, self.get_post_poll(&post, user).await?, + stack, )); } } @@ -394,6 +477,7 @@ impl DataManager { let mut seen_before: HashMap<(usize, usize), (User, Community)> = HashMap::new(); let mut seen_user_follow_statuses: HashMap<(usize, usize), bool> = HashMap::new(); + let mut seen_stacks: HashMap = HashMap::new(); let mut replying_posts: HashMap = HashMap::new(); for post in posts { @@ -405,17 +489,48 @@ impl DataManager { let community = post.community; if let Some((ua, community)) = seen_before.get(&(owner, community)) { + if ua.settings.require_account && user.is_none() { + continue; + } + + // stack + let (can_view, stack) = self + .get_post_stack( + &mut seen_stacks, + &post, + if let Some(ua) = user { ua.id } else { 0 }, + ) + .await; + + if !can_view { + continue; + } + + // reposting + let (can_view, reposting) = + self.get_post_reposting(&post, ignore_users, user).await; + + if !can_view { + continue; + } + + // ... out.push(( post.clone(), ua.clone(), community.to_owned(), - self.get_post_reposting(&post, ignore_users, user).await, + reposting, self.get_post_question(&post, ignore_users).await?, self.get_post_poll(&post, user).await?, + stack, )); } else { let ua = self.get_user_by_id(owner).await?; + if ua.settings.require_account && user.is_none() { + continue; + } + if ua.permissions.check_banned() | ignore_users.contains(&owner) && !ua.permissions.check(FinePermission::MANAGE_POSTS) { @@ -450,6 +565,27 @@ impl DataManager { } } + // stack + let (can_view, stack) = self + .get_post_stack( + &mut seen_stacks, + &post, + if let Some(ua) = user { ua.id } else { 0 }, + ) + .await; + + if !can_view { + continue; + } + + // reposting + let (can_view, reposting) = + self.get_post_reposting(&post, ignore_users, user).await; + + if !can_view { + continue; + } + // ... let community = self.get_community_by_id(community).await?; seen_before.insert((owner, community.id), (ua.clone(), community.clone())); @@ -457,9 +593,10 @@ impl DataManager { post.clone(), ua, community, - self.get_post_reposting(&post, ignore_users, user).await, + reposting, self.get_post_question(&post, ignore_users).await?, self.get_post_poll(&post, user).await?, + stack, )); } } @@ -552,31 +689,15 @@ impl DataManager { id: usize, batch: usize, page: usize, - user: &Option, ) -> Result> { let conn = match self.0.connect().await { Ok(c) => c, Err(e) => return Err(Error::DatabaseConnection(e.to_string())), }; - // check if we should hide nsfw posts - let mut hide_nsfw: bool = true; - - if let Some(ua) = user { - hide_nsfw = !ua.settings.show_nsfw; - } - - // ... let res = query_rows!( &conn, - &format!( - "SELECT * FROM posts WHERE owner = $1 AND replying_to = 0 AND NOT (context::json->>'is_profile_pinned')::boolean AND is_deleted = 0 {} ORDER BY created DESC LIMIT $2 OFFSET $3", - if hide_nsfw { - "AND NOT (context::json->>'is_nsfw')::boolean" - } else { - "" - } - ), + "SELECT * FROM posts WHERE owner = $1 AND replying_to = 0 AND NOT (context::json->>'is_profile_pinned')::boolean AND is_deleted = 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) } ); @@ -593,10 +714,10 @@ impl DataManager { /// To be considered a "great post", a post must have a score ((likes - dislikes) / (likes + dislikes)) /// of at least 0.6. /// - /// GPA is calculated based on the user's last 250 posts. + /// GPA is calculated based on the user's last 48 posts. pub async fn calculate_user_gpa(&self, id: usize) -> f32 { // just for note, this is SUPER bad for performance... which is why we - // only calculate this when it expires in the cache (every week) + // only calculate this when it expires in the cache (every day) if let Some(cached) = self.0.1.get(format!("atto.user.gpa:{}", id)).await { if let Ok(c) = cached.parse() { return c; @@ -871,31 +992,15 @@ impl DataManager { tag: &str, batch: usize, page: usize, - user: &Option, ) -> Result> { let conn = match self.0.connect().await { Ok(c) => c, Err(e) => return Err(Error::DatabaseConnection(e.to_string())), }; - // check if we should hide nsfw posts - let mut hide_nsfw: bool = true; - - if let Some(ua) = user { - hide_nsfw = !ua.settings.show_nsfw; - } - - // ... let res = query_rows!( &conn, - &format!( - "SELECT * FROM posts WHERE owner = $1 AND context::json->>'tags' LIKE $2 AND is_deleted = 0 {} ORDER BY created DESC LIMIT $3 OFFSET $4", - if hide_nsfw { - "AND NOT (context::json->>'is_nsfw')::boolean" - } else { - "" - } - ), + "SELECT * FROM posts WHERE owner = $1 AND context::json->>'tags' LIKE $2 AND is_deleted = 0 ORDER BY created DESC LIMIT $3 OFFSET $4", params![ &(id as i64), &format!("%\"{tag}\"%"), @@ -923,6 +1028,53 @@ impl DataManager { id: usize, batch: usize, page: usize, + user: &Option, + ) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + // check if we should hide nsfw posts + let mut hide_nsfw: bool = true; + + if let Some(ua) = user { + hide_nsfw = !ua.settings.show_nsfw; + } + + // ... + let res = query_rows!( + &conn, + &format!( + "SELECT * FROM posts WHERE community = $1 AND replying_to = 0 AND NOT context LIKE '%\"is_pinned\":true%' AND is_deleted = 0 {} ORDER BY created DESC LIMIT $2 OFFSET $3", + if hide_nsfw { + "AND NOT (context::json->>'is_nsfw')::boolean" + } else { + "" + } + ), + &[&(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()) + } + + /// Get all posts from the given stack (from most recent). + /// + /// # Arguments + /// * `id` - the ID of the stack the requested posts belong to + /// * `batch` - the limit of posts in each page + /// * `page` - the page number + pub async fn get_posts_by_stack( + &self, + id: usize, + batch: usize, + page: usize, ) -> Result> { let conn = match self.0.connect().await { Ok(c) => c, @@ -931,7 +1083,7 @@ impl DataManager { let res = query_rows!( &conn, - "SELECT * FROM posts WHERE community = $1 AND replying_to = 0 AND NOT context LIKE '%\"is_pinned\":true%' AND is_deleted = 0 ORDER BY created DESC LIMIT $2 OFFSET $3", + "SELECT * FROM posts WHERE stack = $1 AND replying_to = 0 AND is_deleted = 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) } ); @@ -1168,7 +1320,18 @@ impl DataManager { /// # Arguments /// * `batch` - the limit of posts in each page /// * `page` - the page number - pub async fn get_latest_posts(&self, batch: usize, page: usize) -> Result> { + pub async fn get_latest_posts( + &self, + batch: usize, + page: usize, + as_user: &Option, + ) -> Result> { + let hide_answers: bool = if let Some(user) = as_user { + user.settings.all_timeline_hide_answers + } else { + false + }; + let conn = match self.0.connect().await { Ok(c) => c, Err(e) => return Err(Error::DatabaseConnection(e.to_string())), @@ -1176,7 +1339,14 @@ impl DataManager { let res = query_rows!( &conn, - "SELECT * FROM posts WHERE replying_to = 0 AND NOT context LIKE '%\"is_nsfw\":true%' ORDER BY created DESC LIMIT $1 OFFSET $2", + &format!( + "SELECT * FROM posts WHERE replying_to = 0 AND NOT context LIKE '%\"is_nsfw\":true%'{} ORDER BY created DESC LIMIT $1 OFFSET $2", + if hide_answers { + " AND context::jsonb->>'answering' = '0'" + } else { + "" + } + ), &[&(batch as i64), &((page * batch) as i64)], |x| { Self::get_post_from_row(x) } ); @@ -1380,7 +1550,30 @@ impl DataManager { } } - let community = self.get_community_by_id(data.community).await?; + // check stack + if data.stack != 0 { + let stack = self.get_stack_by_id(data.stack).await?; + + if stack.mode != StackMode::Circle { + return Err(Error::MiscError( + "You must use a \"Circle\" stack for this".to_string(), + )); + } + + if stack.owner != data.owner && !stack.users.contains(&data.owner) { + return Err(Error::NotAllowed); + } + } + + // ... + let community = if data.stack != 0 { + // if we're posting to a stack, the community should always be the town square + data.community = self.0.0.town_square; + self.get_community_by_id(self.0.0.town_square).await? + } else { + // otherwise, load whatever community the post is requesting + self.get_community_by_id(data.community).await? + }; // check values (if this isn't reposting something else) let is_reposting = if let Some(ref repost) = data.context.repost { @@ -1446,7 +1639,14 @@ impl DataManager { // create notification for question owner // (if the current user isn't the owner) - if (question.owner != data.owner) && (question.owner != 0) { + if (question.owner != data.owner) + && (question.owner != 0) + && (!owner.settings.private_profile + | self + .get_userfollow_by_initiator_receiver(data.owner, question.owner) + .await + .is_ok()) + { self.create_notification(Notification::new( "Your question has received a new answer!".to_string(), format!( @@ -1476,6 +1676,10 @@ impl DataManager { }; if let Some(ref rt) = reposting { + if rt.stack != data.stack && rt.stack != 0 { + return Err(Error::MiscError("Cannot repost out of stack".to_string())); + } + if data.content.is_empty() { // reposting but NOT quoting... we shouldn't be able to repost a direct repost data.context.reposts_enabled = false; @@ -1507,13 +1711,17 @@ impl DataManager { .get_userblock_by_initiator_receiver(rt.owner, data.owner) .await .is_ok() + | self + .get_user_stack_blocked_users(rt.owner) + .await + .contains(&data.owner) { return Err(Error::NotAllowed); } // send notification // this would look better if rustfmt didn't give up on this line - if owner.id != rt.owner && !owner.settings.private_profile { + if owner.id != rt.owner && !owner.settings.private_profile && data.stack == 0 { self.create_notification( Notification::new( format!( @@ -1552,6 +1760,10 @@ impl DataManager { .get_userblock_by_initiator_receiver(rt.owner, data.owner) .await .is_ok() + | self + .get_user_stack_blocked_users(rt.owner) + .await + .contains(&data.owner) { return Err(Error::NotAllowed); } @@ -1571,6 +1783,10 @@ impl DataManager { .get_userblock_by_initiator_receiver(user.id, data.owner) .await .is_ok() + | self + .get_user_stack_blocked_users(user.id) + .await + .contains(&data.owner) { return Err(Error::NotAllowed); } @@ -1598,6 +1814,11 @@ impl DataManager { ); } + // auto unlist + if owner.settings.auto_unlist { + data.context.is_nsfw = true; + } + // ... let conn = match self.0.connect().await { Ok(c) => c, @@ -1608,7 +1829,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO posts VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, DEFAULT, $13, $14, $15)", + "INSERT INTO posts VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, DEFAULT, $13, $14, $15, $16)", params![ &(data.id as i64), &(data.created as i64), @@ -1629,6 +1850,7 @@ impl DataManager { &(data.poll_id as i64), &data.title, &{ if data.is_open { 1 } else { 0 } }, + &(data.stack as i64), ] ); @@ -1643,15 +1865,26 @@ impl DataManager { // send notification if data.owner != rt.owner { let owner = self.get_user_by_id(data.owner).await?; - self.create_notification(Notification::new( - "Your post has received a new comment!".to_string(), - format!( - "[@{}](/api/v1/auth/user/find/{}) has commented on your [post](/post/{}).", - owner.username, owner.id, rt.id - ), - rt.owner, - )) - .await?; + + // make sure we're actually following the person we're commenting to + // we shouldn't send the notif if we aren't, because they can't see it + // (only if our profile is private) + if !owner.settings.private_profile + | self + .get_userfollow_by_initiator_receiver(data.owner, rt.owner) + .await + .is_ok() + { + self.create_notification(Notification::new( + "Your post has received a new comment!".to_string(), + format!( + "[@{}](/api/v1/auth/user/find/{}) has commented on your [post](/post/{}).", + owner.username, owner.id, rt.id + ), + rt.owner, + )) + .await?; + } if !rt.context.comments_enabled { return Err(Error::NotAllowed); @@ -1743,6 +1976,15 @@ impl DataManager { self.delete_poll(y.poll_id, &user).await?; } + // delete question (if not global question) + if y.context.answering != 0 { + let question = self.get_question_by_id(y.context.answering).await?; + + if !question.is_global { + self.delete_question(question.id, &user).await?; + } + } + // return Ok(()) } @@ -1778,7 +2020,7 @@ impl DataManager { let res = execute!( &conn, "UPDATE posts SET is_deleted = $1 WHERE id = $2", - params![if is_deleted { 1 } else { 0 }, &(id as i64)] + params![&if is_deleted { 1 } else { 0 }, &(id as i64)] ); if let Err(e) = res { @@ -1790,7 +2032,9 @@ impl DataManager { if is_deleted { // decr parent comment count if let Some(replying_to) = y.replying_to { - self.decr_post_comments(replying_to).await.unwrap(); + if replying_to != 0 { + self.decr_post_comments(replying_to).await.unwrap(); + } } // decr user post count @@ -1820,6 +2064,15 @@ impl DataManager { for upload in y.uploads { self.delete_upload(upload).await?; } + + // delete question (if not global question) + if y.context.answering != 0 { + let question = self.get_question_by_id(y.context.answering).await?; + + if !question.is_global { + self.delete_question(question.id, &user).await?; + } + } } else { // incr parent comment count if let Some(replying_to) = y.replying_to { @@ -1890,7 +2143,7 @@ impl DataManager { let res = execute!( &conn, "UPDATE posts SET is_open = $1 WHERE id = $2", - params![if is_open { 1 } else { 0 }, &(id as i64)] + params![&if is_open { 1 } else { 0 }, &(id as i64)] ); if let Err(e) = res { @@ -2088,5 +2341,5 @@ impl DataManager { auto_method!(decr_post_dislikes() -> "UPDATE posts SET dislikes = dislikes - 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --decr); auto_method!(incr_post_comments() -> "UPDATE posts SET comment_count = comment_count + 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --incr); - auto_method!(decr_post_comments() -> "UPDATE posts SET comment_count = comment_count - 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --decr); + auto_method!(decr_post_comments()@get_post_by_id -> "UPDATE posts SET comment_count = comment_count - 1 WHERE id = $1" --cache-key-tmpl="atto.post:{}" --decr=comment_count); } diff --git a/crates/core/src/database/questions.rs b/crates/core/src/database/questions.rs index f76efc6..a6ca60a 100644 --- a/crates/core/src/database/questions.rs +++ b/crates/core/src/database/questions.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use oiseau::cache::Cache; use tetratto_shared::unix_epoch_timestamp; use crate::model::communities_permissions::CommunityPermission; +use crate::model::uploads::{MediaType, MediaUpload}; use crate::model::{ Error, Result, communities::Question, @@ -10,21 +11,11 @@ use crate::model::{ permissions::FinePermission, }; use crate::{auto_method, DataManager}; - -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_rows, params}; +use oiseau::{execute, get, query_rows, params, PostgresRow}; impl DataManager { /// Get a [`Question`] from an SQL row. - pub(crate) fn get_question_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> Question { + pub(crate) fn get_question_from_row(x: &PostgresRow) -> Question { Question { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, @@ -40,6 +31,7 @@ impl DataManager { // ... context: serde_json::from_str(&get!(x->10(String))).unwrap(), ip: get!(x->11(String)), + drawings: serde_json::from_str(&get!(x->12(String))).unwrap(), } } @@ -340,13 +332,20 @@ impl DataManager { Ok(res.unwrap()) } + const MAXIMUM_DRAWING_SIZE: usize = 32768; // 32 KiB + /// Create a new question in the database. /// /// # Arguments /// * `data` - a mock [`Question`] object to insert - pub async fn create_question(&self, mut data: Question) -> Result { + pub async fn create_question( + &self, + mut data: Question, + drawings: Vec>, + ) -> Result { // check if we can post this if data.is_global { + // global if data.community > 0 { // posting to community data.receiver = 0; @@ -377,6 +376,7 @@ impl DataManager { } } } else { + // single let receiver = self.get_user_by_id(data.receiver).await?; if !receiver.settings.enable_questions { @@ -387,6 +387,10 @@ impl DataManager { return Err(Error::NotAllowed); } + if !receiver.settings.enable_drawings && drawings.len() > 0 { + return Err(Error::DrawingsDisabled); + } + // check for ip block if self .get_ipblock_by_initiator_receiver(receiver.id, &data.ip) @@ -397,6 +401,31 @@ impl DataManager { } } + // create uploads + if drawings.len() > 2 { + return Err(Error::MiscError( + "Too many uploads. Please use a maximum of 2".to_string(), + )); + } + + for drawing in &drawings { + // this is the initial iter to check sizes, we'll do uploads after + if drawing.len() > Self::MAXIMUM_DRAWING_SIZE { + return Err(Error::FileTooLarge); + } else if drawing.len() < 25 { + // if we have less than 25 bytes in a drawing, the drawing is likely blank + return Err(Error::FileTooSmall); + } + } + + for _ in 0..drawings.len() { + data.drawings.push( + self.create_upload(MediaUpload::new(MediaType::Carpgraph, data.owner)) + .await? + .id, + ); + } + // ... let conn = match self.0.connect().await { Ok(c) => c, @@ -405,7 +434,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO questions VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", + "INSERT INTO questions VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)", params![ &(data.id as i64), &(data.created as i64), @@ -418,7 +447,8 @@ impl DataManager { &0_i32, &0_i32, &serde_json::to_string(&data.context).unwrap(), - &data.ip + &data.ip, + &serde_json::to_string(&data.drawings).unwrap(), ] ); @@ -437,6 +467,23 @@ impl DataManager { .await?; } + // write to uploads + for (i, drawing_id) in data.drawings.iter().enumerate() { + let drawing = match drawings.get(i) { + Some(d) => d, + None => { + self.delete_upload(*drawing_id).await?; + continue; + } + }; + + let upload = self.get_upload_by_id(*drawing_id).await?; + + if let Err(e) = std::fs::write(&upload.path(&self.0.0).to_string(), drawing.to_vec()) { + return Err(Error::MiscError(e.to_string())); + } + } + // return Ok(data.id) } @@ -502,6 +549,11 @@ impl DataManager { return Err(Error::DatabaseError(e.to_string())); } + // delete uploads + for upload in y.drawings { + self.delete_upload(upload).await?; + } + // return Ok(()) } diff --git a/crates/core/src/database/reactions.rs b/crates/core/src/database/reactions.rs index 438f282..347548f 100644 --- a/crates/core/src/database/reactions.rs +++ b/crates/core/src/database/reactions.rs @@ -7,20 +7,11 @@ use crate::model::{ }; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_row, query_rows, params}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { /// Get a [`Reaction`] from an SQL row. - pub(crate) fn get_reaction_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> Reaction { + pub(crate) fn get_reaction_from_row(x: &PostgresRow) -> Reaction { Reaction { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, @@ -145,10 +136,14 @@ impl DataManager { if data.asset_type == AssetType::Post { let post = self.get_post_by_id(data.asset).await?; - if self + if (self .get_userblock_by_initiator_receiver(post.owner, user.id) .await .is_ok() + | self + .get_user_stack_blocked_users(post.owner) + .await + .contains(&user.id)) && !user.permissions.check(FinePermission::MANAGE_POSTS) { return Err(Error::NotAllowed); @@ -156,10 +151,14 @@ impl DataManager { } else if data.asset_type == AssetType::Question { let question = self.get_question_by_id(data.asset).await?; - if self + if (self .get_userblock_by_initiator_receiver(question.owner, user.id) .await .is_ok() + | self + .get_user_stack_blocked_users(question.owner) + .await + .contains(&user.id)) && !user.permissions.check(FinePermission::MANAGE_POSTS) { return Err(Error::NotAllowed); @@ -176,7 +175,7 @@ impl DataManager { &(data.owner as i64), &(data.asset as i64), &serde_json::to_string(&data.asset_type).unwrap().as_str(), - &{ if data.is_like { 1 } else { 0 } } + &if data.is_like { 1 } else { 0 } ] ); diff --git a/crates/core/src/database/reports.rs b/crates/core/src/database/reports.rs index d2cd474..f29fdc8 100644 --- a/crates/core/src/database/reports.rs +++ b/crates/core/src/database/reports.rs @@ -3,20 +3,13 @@ use crate::model::moderation::AuditLogEntry; use crate::model::{Error, Result, auth::User, moderation::Report, permissions::FinePermission}; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] use oiseau::PostgresRow; use oiseau::{execute, get, query_rows, params}; impl DataManager { /// Get a [`Report`] from an SQL row. - pub(crate) fn get_report_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> Report { + pub(crate) fn get_report_from_row(x: &PostgresRow) -> Report { Report { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/requests.rs b/crates/core/src/database/requests.rs index c8d2383..5a82062 100644 --- a/crates/core/src/database/requests.rs +++ b/crates/core/src/database/requests.rs @@ -3,20 +3,11 @@ use crate::model::requests::ActionType; use crate::model::{Error, Result, requests::ActionRequest, auth::User, permissions::FinePermission}; use crate::DataManager; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_row, query_rows, params}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { /// Get an [`ActionRequest`] from an SQL row. - pub(crate) fn get_request_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> ActionRequest { + pub(crate) fn get_request_from_row(x: &PostgresRow) -> ActionRequest { ActionRequest { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/stackblocks.rs b/crates/core/src/database/stackblocks.rs new file mode 100644 index 0000000..4e3c779 --- /dev/null +++ b/crates/core/src/database/stackblocks.rs @@ -0,0 +1,239 @@ +use oiseau::cache::Cache; +use crate::model::stacks::StackPrivacy; +use crate::model::{Error, Result, auth::User, stacks::StackBlock, permissions::FinePermission}; +use crate::{auto_method, DataManager}; + +use oiseau::PostgresRow; + +use oiseau::{execute, get, params, query_row, query_rows}; + +impl DataManager { + /// Get a [`StackBlock`] from an SQL row. + pub(crate) fn get_stackblock_from_row(x: &PostgresRow) -> StackBlock { + StackBlock { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + initiator: get!(x->2(i64)) as usize, + stack: get!(x->3(i64)) as usize, + } + } + + auto_method!(get_stackblock_by_id()@get_stackblock_from_row -> "SELECT * FROM stackblocks WHERE id = $1" --name="stack block" --returns=StackBlock --cache-key-tmpl="atto.stackblock:{}"); + + pub async fn get_user_stack_blocked_users(&self, user_id: usize) -> Vec { + let mut stack_block_users = Vec::new(); + + for block in self.get_stackblocks_by_initiator(user_id).await { + for user in match self.fill_stackblocks_receivers(block.stack).await { + Ok(ul) => ul, + Err(_) => continue, + } { + stack_block_users.push(user); + } + } + + stack_block_users + } + + /// Fill a vector of stack blocks with their receivers (by pulling the stack). + pub async fn fill_stackblocks_receivers(&self, stack: usize) -> Result> { + let stack = self.get_stack_by_id(stack).await?; + let mut out = Vec::new(); + + for block in stack.users { + out.push(block); + } + + Ok(out) + } + + /// Get all stack blocks created by the given `initiator`. + pub async fn get_stackblocks_by_initiator(&self, initiator: usize) -> Vec { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(_) => return Vec::new(), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM stackblocks WHERE initiator = $1", + &[&(initiator as i64)], + |x| { Self::get_stackblock_from_row(x) } + ); + + if res.is_err() { + return Vec::new(); + } + + // make sure all stacks still exist + let list = res.unwrap(); + + for block in &list { + if self.get_stack_by_id(block.stack).await.is_err() { + if self.delete_stackblock_sudo(block.id).await.is_err() { + continue; + } + } + } + + // return + list + } + + /// Get a stack block by `initiator` and `stack` (in that order). + pub async fn get_stackblock_by_initiator_stack( + &self, + initiator: usize, + stack: usize, + ) -> 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 stackblocks WHERE initiator = $1 AND stack = $2", + &[&(initiator as i64), &(stack as i64)], + |x| { Ok(Self::get_stackblock_from_row(x)) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("stack block".to_string())); + } + + Ok(res.unwrap()) + } + + const MAXIMUM_FREE_STACKBLOCKS: usize = 5; + const MAXIMUM_SUPPORTER_STACKBLOCKS: usize = 10; + + /// Create a new stack block in the database. + /// + /// # Arguments + /// * `data` - a mock [`StackBlock`] object to insert + pub async fn create_stackblock(&self, data: StackBlock) -> Result<()> { + let initiator = self.get_user_by_id(data.initiator).await?; + + // check number of stackblocks + let stackblocks = self.get_stackblocks_by_initiator(data.initiator).await; + + if !initiator.permissions.check(FinePermission::SUPPORTER) { + if stackblocks.len() >= Self::MAXIMUM_FREE_STACKBLOCKS { + return Err(Error::MiscError( + "You already have the maximum number of stack blocks you can have".to_string(), + )); + } + } else { + if stackblocks.len() >= Self::MAXIMUM_SUPPORTER_STACKBLOCKS { + return Err(Error::MiscError( + "You already have the maximum number of stack blocks you can have".to_string(), + )); + } + } + + // ... + let stack = self.get_stack_by_id(data.stack).await?; + + if initiator.id != stack.owner + && stack.privacy == StackPrivacy::Private + && !initiator.permissions.check(FinePermission::MANAGE_STACKS) + { + return Err(Error::NotAllowed); + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO stackblocks VALUES ($1, $2, $3, $4)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.initiator as i64), + &(data.stack as i64) + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // unfollow/remove follower + for user in stack.users { + if let Ok(f) = self + .get_userfollow_by_initiator_receiver(data.initiator, user) + .await + { + self.delete_userfollow_sudo(f.id, data.initiator).await?; + } + + if let Ok(f) = self + .get_userfollow_by_receiver_initiator(data.initiator, user) + .await + { + self.delete_userfollow_sudo(f.id, data.initiator).await?; + } + } + + // return + Ok(()) + } + + pub async fn delete_stackblock(&self, id: usize, user: User) -> Result<()> { + let block = self.get_stackblock_by_id(id).await?; + + if user.id != block.initiator { + // only the initiator (or moderators) can delete stack blocks! + if !user.permissions.check(FinePermission::MANAGE_FOLLOWS) { + 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 stackblocks WHERE id = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.0.1.remove(format!("atto.stackblock:{}", id)).await; + + // return + Ok(()) + } + + pub async fn delete_stackblock_sudo(&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 stackblocks WHERE id = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.0.1.remove(format!("atto.stackblock:{}", id)).await; + + // return + Ok(()) + } +} diff --git a/crates/core/src/database/stacks.rs b/crates/core/src/database/stacks.rs index 2407807..46a3e30 100644 --- a/crates/core/src/database/stacks.rs +++ b/crates/core/src/database/stacks.rs @@ -1,27 +1,19 @@ use oiseau::cache::Cache; -use crate::model::{ - Error, Result, - auth::User, - permissions::FinePermission, - stacks::{StackPrivacy, UserStack, StackMode, StackSort}, - communities::{Community, Poll, Post, Question}, +use crate::{ + database::posts::FullPost, + model::{ + auth::User, + permissions::FinePermission, + stacks::{StackMode, StackPrivacy, StackSort, UserStack}, + Error, Result, + }, }; use crate::{auto_method, DataManager}; - -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_rows, params}; +use oiseau::{PostgresRow, execute, get, query_rows, params}; impl DataManager { /// Get a [`UserStack`] from an SQL row. - pub(crate) fn get_stack_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> UserStack { + pub(crate) fn get_stack_from_row(x: &PostgresRow) -> UserStack { UserStack { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, @@ -44,16 +36,7 @@ impl DataManager { page: usize, ignore_users: &Vec, user: &Option, - ) -> Result< - Vec<( - Post, - User, - Community, - Option<(User, Post)>, - Option<(Question, User)>, - Option<(Poll, bool, bool)>, - )>, - > { + ) -> Result> { let stack = self.get_stack_by_id(id).await?; Ok(match stack.mode { @@ -73,7 +56,7 @@ impl DataManager { match stack.sort { StackSort::Created => { self.fill_posts_with_community( - self.get_latest_posts(batch, page).await?, + self.get_latest_posts(batch, page, &user).await?, as_user_id, &ignore_users, user, @@ -91,14 +74,59 @@ impl DataManager { } } } + StackMode::BlockList => { + return Err(Error::MiscError( + "You should use `get_stack_users` for this type".to_string(), + )); + } + StackMode::Circle => { + if !stack.users.contains(&as_user_id) && as_user_id != stack.owner { + return Err(Error::NotAllowed); + } + + self.fill_posts_with_community( + self.get_posts_by_stack(stack.id, batch, page).await?, + as_user_id, + &ignore_users, + user, + ) + .await? + } }) } + pub async fn get_stack_users(&self, id: usize, batch: usize, page: usize) -> Result> { + let stack = self.get_stack_by_id(id).await?; + + if stack.mode != StackMode::BlockList { + return Err(Error::MiscError( + "You should use `get_stack_posts` for this type".to_string(), + )); + } + + // build list + let mut out = Vec::new(); + let mut i = 0; + + for user in stack.users.iter().skip(batch * page) { + if i == batch { + break; + } + + out.push(self.get_user_by_id(user.to_owned()).await?); + i += 1; + } + + Ok(out) + } + /// Get all stacks by user. /// + /// Also pulls stacks that are of "Circle" type AND the user is added to the `users` list. + /// /// # Arguments /// * `id` - the ID of the user to fetch stacks for - pub async fn get_stacks_by_owner(&self, id: usize) -> Result> { + pub async fn get_stacks_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())), @@ -106,8 +134,8 @@ impl DataManager { let res = query_rows!( &conn, - "SELECT * FROM stacks WHERE owner = $1 ORDER BY name ASC", - &[&(id as i64)], + "SELECT * FROM stacks WHERE owner = $1 OR (mode = '\"Circle\"' AND users LIKE $2) ORDER BY name ASC", + &[&(id as i64), &format!("%{id}%")], |x| { Self::get_stack_from_row(x) } ); @@ -119,6 +147,7 @@ impl DataManager { } const MAXIMUM_FREE_STACKS: usize = 5; + pub const MAXIMUM_FREE_STACK_USERS: usize = 50; /// Create a new stack in the database. /// @@ -136,7 +165,7 @@ impl DataManager { let owner = self.get_user_by_id(data.owner).await?; if !owner.permissions.check(FinePermission::SUPPORTER) { - let stacks = self.get_stacks_by_owner(data.owner).await?; + let stacks = self.get_stacks_by_user(data.owner).await?; if stacks.len() >= Self::MAXIMUM_FREE_STACKS { return Err(Error::MiscError( @@ -193,6 +222,25 @@ impl DataManager { return Err(Error::DatabaseError(e.to_string())); } + // delete stackblocks + let res = execute!( + &conn, + "DELETE FROM stackblocks WHERE stack = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // delete posts + let res = execute!(&conn, "DELETE FROM posts WHERE stack = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... self.0.1.remove(format!("atto.stack:{}", id)).await; Ok(()) } diff --git a/crates/core/src/database/uploads.rs b/crates/core/src/database/uploads.rs index 49778de..e3b2cb5 100644 --- a/crates/core/src/database/uploads.rs +++ b/crates/core/src/database/uploads.rs @@ -4,20 +4,13 @@ use crate::model::permissions::FinePermission; use crate::model::{Error, Result, uploads::MediaUpload}; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] use oiseau::PostgresRow; use oiseau::{execute, get, query_rows, params}; impl DataManager { /// Get a [`MediaUpload`] from an SQL row. - pub(crate) fn get_upload_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> MediaUpload { + pub(crate) fn get_upload_from_row(x: &PostgresRow) -> MediaUpload { MediaUpload { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/user_warnings.rs b/crates/core/src/database/user_warnings.rs index 4aebfca..79af849 100644 --- a/crates/core/src/database/user_warnings.rs +++ b/crates/core/src/database/user_warnings.rs @@ -4,20 +4,11 @@ use crate::model::moderation::AuditLogEntry; use crate::model::{Error, Result, auth::User, permissions::FinePermission}; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_row, query_rows, params}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { /// Get a [`UserWarning`] from an SQL row. - pub(crate) fn get_user_warning_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> UserWarning { + pub(crate) fn get_user_warning_from_row(x: &PostgresRow) -> UserWarning { UserWarning { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/userblocks.rs b/crates/core/src/database/userblocks.rs index f54b97e..ac95cab 100644 --- a/crates/core/src/database/userblocks.rs +++ b/crates/core/src/database/userblocks.rs @@ -2,20 +2,11 @@ use oiseau::cache::Cache; use crate::model::{Error, Result, auth::User, auth::UserBlock, permissions::FinePermission}; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_row, query_rows, params}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { /// Get a [`UserBlock`] from an SQL row. - pub(crate) fn get_userblock_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> UserBlock { + pub(crate) fn get_userblock_from_row(x: &PostgresRow) -> UserBlock { UserBlock { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, diff --git a/crates/core/src/database/userfollows.rs b/crates/core/src/database/userfollows.rs index 12046c5..09504c0 100644 --- a/crates/core/src/database/userfollows.rs +++ b/crates/core/src/database/userfollows.rs @@ -4,20 +4,11 @@ use crate::model::requests::{ActionRequest, ActionType}; use crate::model::{Error, Result, auth::User, auth::UserFollow, permissions::FinePermission}; use crate::{auto_method, DataManager}; -#[cfg(feature = "sqlite")] -use oiseau::SqliteRow; - -#[cfg(feature = "postgres")] -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_row, query_rows, params}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { /// Get a [`UserFollow`] from an SQL row. - pub(crate) fn get_userfollow_from_row( - #[cfg(feature = "sqlite")] x: &SqliteRow<'_>, - #[cfg(feature = "postgres")] x: &PostgresRow, - ) -> UserFollow { + pub(crate) fn get_userfollow_from_row(x: &PostgresRow) -> UserFollow { UserFollow { id: get!(x->0(i64)) as usize, created: get!(x->1(i64)) as usize, @@ -346,4 +337,41 @@ impl DataManager { // return Ok(()) } + + pub async fn delete_userfollow_sudo(&self, id: usize, user_id: usize) -> Result<()> { + let follow = self.get_userfollow_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, + "DELETE FROM userfollows WHERE id = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.0.1.remove(format!("atto.userfollow:{}", id)).await; + + // decr counts + if follow.initiator != user_id { + self.decr_user_following_count(follow.initiator) + .await + .unwrap(); + } + + if follow.receiver != user_id { + self.decr_user_follower_count(follow.receiver) + .await + .unwrap(); + } + + // return + Ok(()) + } } diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index 8e1e9b7..aa61770 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -4,10 +4,3 @@ pub mod model; pub use database::DataManager; pub use oiseau::cache; - -/// Tells us if pubsub capabilities are provided (via Redis). -/// -/// If we have access to pubsub, community channels/messages will be available. -/// -/// This is mostly used a flag for the UI. -pub const PUBSUB_ENABLED: bool = cfg!(feature = "redis"); diff --git a/crates/core/src/model/addr.rs b/crates/core/src/model/addr.rs index 42e6721..ffca6aa 100644 --- a/crates/core/src/model/addr.rs +++ b/crates/core/src/model/addr.rs @@ -1,7 +1,7 @@ use std::net::SocketAddr; /// How many bytes should be taken as the prefix (from the begining of the address). -pub(crate) const IPV6_PREFIX_BYTES: usize = 8; +pub(crate) const IPV6_PREFIX_BYTES: usize = 11; /// The protocol of a [`RemoteAddr`]. #[derive(Clone, Debug, PartialEq, Eq)] diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 8c12f76..4617770 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -1,6 +1,9 @@ use std::collections::HashMap; -use super::{oauth::AuthGrant, permissions::FinePermission}; +use super::{ + oauth::AuthGrant, + permissions::{FinePermission, SecondaryPermission}, +}; use serde::{Deserialize, Serialize}; use totp_rs::TOTP; use tetratto_shared::{ @@ -49,6 +52,15 @@ pub struct User { /// A list of the IDs of all accounts the user has signed into through the UI. #[serde(default)] pub associated: Vec, + /// The ID of the [`InviteCode`] this user provided during registration. + #[serde(default)] + pub invite_code: usize, + /// Secondary permissions because the regular permissions struct ran out of possible bits. + #[serde(default)] + pub secondary_permissions: SecondaryPermission, + /// Users collect achievements through little actions across the site. + #[serde(default)] + pub achievements: Vec, } pub type UserConnections = @@ -231,6 +243,21 @@ pub struct UserSettings { /// A list of strings the user has muted. #[serde(default)] pub muted: Vec, + /// If timelines are paged instead of infinitely scrolled. + #[serde(default)] + pub paged_timelines: bool, + /// If drawings are enabled for questions sent to the user. + #[serde(default)] + pub enable_drawings: bool, + /// Automatically unlist posts from timelines. + #[serde(default)] + pub auto_unlist: bool, + /// Hide posts that are answering a question on the "All" timeline. + #[serde(default)] + pub all_timeline_hide_answers: bool, + /// Automatically clear all notifications when notifications are viewed. + #[serde(default)] + pub auto_clear_notifs: bool, } fn mime_avif() -> String { @@ -271,6 +298,9 @@ impl User { stripe_id: String::new(), grants: Vec::new(), associated: Vec::new(), + invite_code: 0, + secondary_permissions: SecondaryPermission::DEFAULT, + achievements: Vec::new(), } } @@ -332,7 +362,7 @@ impl User { // parse for char in input.chars() { - if (char == '\\') && !escape { + if ((char == '\\') | (char == '/')) && !escape { escape = true; continue; } @@ -444,6 +474,92 @@ pub struct ExternalConnectionData { pub data: HashMap, } +/// The total number of achievements needed to 100% Tetratto! +pub const ACHIEVEMENTS: usize = 8; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum AchievementName { + /// Create your first post. + CreatePost, + /// Follow somebody. + FollowUser, + /// Create your 50th post. + Create50Posts, + /// Create your 100th post. + Create100Posts, + /// Create your 1000th post. + Create1000Posts, + /// Ask your first question. + CreateQuestion, + /// Edit your settings. + EditSettings, + /// Create your first journal. + CreateJournal, +} + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum AchievementRarity { + Common, + Uncommon, + Rare, +} + +impl AchievementName { + pub fn title(&self) -> &str { + match self { + Self::CreatePost => "Dear friends,", + Self::FollowUser => "Virtual connections...", + Self::Create50Posts => "Hello, world!", + Self::Create100Posts => "It's my world", + Self::Create1000Posts => "Timeline domination", + Self::CreateQuestion => "Big questions...", + Self::EditSettings => "Just how I like it!", + Self::CreateJournal => "Dear diary...", + } + } + + pub fn description(&self) -> &str { + match self { + Self::CreatePost => "Create your first post!", + Self::FollowUser => "Follow somebody!", + Self::Create50Posts => "Create your 50th post.", + Self::Create100Posts => "Create your 100th post.", + Self::Create1000Posts => "Create your 1000th post.", + Self::CreateQuestion => "Ask your first question!", + Self::EditSettings => "Edit your settings.", + Self::CreateJournal => "Create your first journal.", + } + } + + pub fn rarity(&self) -> AchievementRarity { + match self { + Self::CreatePost => AchievementRarity::Common, + Self::FollowUser => AchievementRarity::Common, + Self::Create50Posts => AchievementRarity::Uncommon, + Self::Create100Posts => AchievementRarity::Uncommon, + Self::Create1000Posts => AchievementRarity::Rare, + Self::CreateQuestion => AchievementRarity::Common, + Self::EditSettings => AchievementRarity::Common, + Self::CreateJournal => AchievementRarity::Uncommon, + } + } +} + +impl Into for AchievementName { + fn into(self) -> Achievement { + Achievement { + name: self, + unlocked: unix_epoch_timestamp(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Achievement { + pub name: AchievementName, + pub unlocked: usize, +} + #[derive(Debug, Serialize, Deserialize)] pub struct Notification { pub id: usize, @@ -579,3 +695,25 @@ impl UserWarning { } } } + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct InviteCode { + pub id: usize, + pub created: usize, + pub owner: usize, + pub code: String, + pub is_used: bool, +} + +impl InviteCode { + /// Create a new [`InviteCode`]. + pub fn new(owner: usize) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + owner, + code: salt(), + is_used: false, + } + } +} diff --git a/crates/core/src/model/carp.rs b/crates/core/src/model/carp.rs new file mode 100644 index 0000000..40876fa --- /dev/null +++ b/crates/core/src/model/carp.rs @@ -0,0 +1,285 @@ +use serde::{Serialize, Deserialize}; + +/// Starting at the beginning of the file, the header details specific information +/// about the file. +/// +/// 1. `CG` tag (2 bytes) +/// 2. version number (2 bytes) +/// 3. width of graph (4 bytes) +/// 4. height of graph (4 bytes) +/// 5. `END_OF_HEADER` +/// +/// The header has a total of 13 bytes. (12 of info, 1 of `END_OF_HEADER) +/// +/// Everything after `END_OF_HEADER` should be another command and its parameters. +pub const END_OF_HEADER: u8 = 0x1a; +/// The color command marks the beginning of a hex-encoded color **string**. +/// +/// The hastag character should **not** be included. +pub const COLOR: u8 = 0x1b; +/// The size command marks the beginning of a integer brush size. +pub const SIZE: u8 = 0x2b; +/// Marks the beginning of a new line. +pub const LINE: u8 = 0x3b; +/// A point marks the coordinates (relative to the previous `DELTA_ORIGIN`, or `(0, 0)`) +/// in which a point should be drawn. +/// +/// The size and color are that of the previous `COLOR` and `SIZE` commands. +/// +/// Points are two `u32`s (or 8 bytes in length). +pub const POINT: u8 = 0x4b; +/// An end-of-file marker. +pub const EOF: u8 = 0x1f; + +/// A type of [`Command`]. +#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] +#[repr(u8)] +pub enum CommandType { + /// [`END_OF_HEADER`] + EndOfHeader = END_OF_HEADER, + /// [`COLOR`] + Color = COLOR, + /// [`SIZE`] + Size = SIZE, + /// [`LINE`] + Line = LINE, + /// [`POINT`] + Point = POINT, + /// [`EOF`] + Eof = EOF, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct Command { + /// The type of the command. + pub r#type: CommandType, + /// Raw data as bytes. + pub data: Vec, +} + +impl From for Vec { + fn from(val: Command) -> Self { + let mut d = val.data; + d.insert(0, val.r#type as u8); + d + } +} + +/// A graph is CarpGraph's representation of an image. It's essentially just a +/// reproducable series of commands which a renderer can traverse to reconstruct +/// an image. +#[derive(Serialize, Deserialize, Debug)] +pub struct CarpGraph { + pub header: Vec, + pub dimensions: (u32, u32), + pub commands: Vec, +} + +macro_rules! select_bytes { + ($count:literal, $from:ident) => {{ + let mut data: Vec = Vec::new(); + let mut seen_bytes = 0; + + while let Some((_, byte)) = $from.next() { + seen_bytes += 1; + data.push(byte.to_owned()); + + if seen_bytes == $count { + // we only need bytes, stop just before we eat the next byte + break; + } + } + + data + }}; +} + +macro_rules! spread { + ($into:ident, $from:expr) => { + for byte in &$from { + $into.push(byte.to_owned()) + } + }; +} + +impl CarpGraph { + pub fn to_bytes(&self) -> Vec { + let mut out: Vec = Vec::new(); + + // reconstruct header + spread!(out, self.header); + spread!(out, self.dimensions.0.to_be_bytes()); // width + spread!(out, self.dimensions.1.to_be_bytes()); // height + out.push(END_OF_HEADER); + + // reconstruct commands + for command in &self.commands { + out.push(command.r#type as u8); + spread!(out, command.data); + } + + // ... + out.push(EOF); + out + } + + pub fn from_bytes(bytes: Vec) -> Self { + let mut header: Vec = Vec::new(); + let mut dimensions: (u32, u32) = (0, 0); + let mut commands: Vec = Vec::new(); + + let mut in_header: bool = true; + let mut byte_buffer: Vec = Vec::new(); // storage for bytes which need to construct a bigger type (like `u32`) + + let mut bytes_iter = bytes.iter().enumerate(); + while let Some((i, byte)) = bytes_iter.next() { + let byte = byte.to_owned(); + match byte { + END_OF_HEADER => in_header = false, + COLOR => { + let data = select_bytes!(6, bytes_iter); + commands.push(Command { + r#type: CommandType::Color, + data, + }); + } + SIZE => { + let data = select_bytes!(2, bytes_iter); + commands.push(Command { + r#type: CommandType::Size, + data, + }); + } + POINT => { + let data = select_bytes!(8, bytes_iter); + commands.push(Command { + r#type: CommandType::Point, + data, + }); + } + LINE => commands.push(Command { + r#type: CommandType::Line, + data: Vec::new(), + }), + EOF => break, + _ => { + if in_header { + if (0..2).contains(&i) { + // tag + header.push(byte); + } else if (2..4).contains(&i) { + // version + header.push(byte); + } else if (4..8).contains(&i) { + // width + byte_buffer.push(byte); + + if i == 7 { + // end, construct from byte buffer + let (bytes, _) = byte_buffer.split_at(size_of::()); + dimensions.0 = u32::from_be_bytes(bytes.try_into().unwrap()); + byte_buffer = Vec::new(); + } + } else if (8..12).contains(&i) { + // height + byte_buffer.push(byte); + + if i == 11 { + // end, construct from byte buffer + let (bytes, _) = byte_buffer.split_at(size_of::()); + dimensions.1 = u32::from_be_bytes(bytes.try_into().unwrap()); + byte_buffer = Vec::new(); + } + } + } else { + // misc byte + println!("extraneous byte at {i}"); + } + } + } + } + + Self { + header, + dimensions, + commands, + } + } + + pub fn to_svg(&self) -> String { + let mut out: String = String::new(); + out.push_str(&format!( + "", + self.dimensions.0, self.dimensions.1, self.dimensions.0, self.dimensions.1, self.dimensions.0, self.dimensions.1 + )); + + // add lines + let mut stroke_size: u16 = 2; + let mut stroke_color: String = "000000".to_string(); + + let mut previous_x_y: Option<(u32, u32)> = None; + let mut line_path = String::new(); + + for command in &self.commands { + match command.r#type { + CommandType::Size => { + let (bytes, _) = command.data.split_at(size_of::()); + stroke_size = u16::from_be_bytes(bytes.try_into().unwrap_or([0, 0])); + } + CommandType::Color => { + stroke_color = + String::from_utf8(command.data.to_owned()).unwrap_or("#000000".to_string()) + } + CommandType::Line => { + if !line_path.is_empty() { + out.push_str(&format!( + "" + )); + } + + previous_x_y = None; + line_path = String::new(); + } + CommandType::Point => { + let (x, y) = command.data.split_at(size_of::()); + let point = ({ u32::from_be_bytes(x.try_into().unwrap()) }, { + u32::from_be_bytes(y.try_into().unwrap()) + }); + + // add to path string + line_path.push_str(&format!( + " M{} {}{}", + point.0, + point.1, + if let Some(pxy) = previous_x_y { + // line to there + format!(" L{} {}", pxy.0, pxy.1) + } else { + String::new() + } + )); + + previous_x_y = Some((point.0, point.1)); + + // add circular point + out.push_str(&format!( + "", + point.0, + point.1, + stroke_size / 2 // the size is technically the diameter of the circle + )); + } + _ => unreachable!("never pushed to commands"), + } + } + + if !line_path.is_empty() { + out.push_str(&format!( + "" + )); + } + + // return + format!("{out}") + } +} diff --git a/crates/core/src/model/channels.rs b/crates/core/src/model/channels.rs index 5b95d2e..84180c4 100644 --- a/crates/core/src/model/channels.rs +++ b/crates/core/src/model/channels.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use serde::{Serialize, Deserialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; @@ -24,21 +26,26 @@ pub struct Channel { pub members: Vec, /// The title of the channel. pub title: String, + /// The timestamp of the last message in the channel. + pub last_message: usize, } impl Channel { /// Create a new [`Channel`]. pub fn new(community: usize, owner: usize, position: usize, title: String) -> Self { + let created = unix_epoch_timestamp(); + Self { id: Snowflake::new().to_string().parse::().unwrap(), community, owner, - created: unix_epoch_timestamp(), + created, minimum_role_read: (CommunityPermission::DEFAULT | CommunityPermission::MEMBER).bits(), minimum_role_write: (CommunityPermission::DEFAULT | CommunityPermission::MEMBER).bits(), position, members: Vec::new(), title, + last_message: created, } } @@ -74,6 +81,7 @@ pub struct Message { pub edited: usize, pub content: String, pub context: MessageContext, + pub reactions: HashMap, } impl Message { @@ -88,6 +96,7 @@ impl Message { edited: now, content, context: MessageContext, + reactions: HashMap::new(), } } } @@ -100,3 +109,25 @@ impl Default for MessageContext { Self } } + +#[derive(Clone, Serialize, Deserialize)] +pub struct MessageReaction { + pub id: usize, + pub created: usize, + pub owner: usize, + pub message: usize, + pub emoji: String, +} + +impl MessageReaction { + /// Create a new [`MessageReaction`]. + pub fn new(owner: usize, message: usize, emoji: String) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + owner, + message, + emoji, + } + } +} diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index ed0d5b9..7b1957f 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -260,6 +260,10 @@ pub struct Post { pub title: String, /// If the post is "open". Posts can act as tickets in a forge community. pub is_open: bool, + /// The ID of the stack this post belongs to. 0 means no stack is connected. + /// + /// If stack is not 0, community should be 0 (and vice versa). + pub stack: usize, } impl Post { @@ -287,6 +291,7 @@ impl Post { poll_id, title: String::new(), is_open: true, + stack: 0, } } @@ -340,6 +345,9 @@ pub struct Question { /// The IP of the question creator for IP blocking and identifying anonymous users. #[serde(default)] pub ip: String, + /// The IDs of all uploads which hold this question's drawings. + #[serde(default)] + pub drawings: Vec, } impl Question { @@ -364,6 +372,7 @@ impl Question { dislikes: 0, context: QuestionContext::default(), ip, + drawings: Vec::new(), } } } diff --git a/crates/core/src/model/journals.rs b/crates/core/src/model/journals.rs new file mode 100644 index 0000000..8c2491d --- /dev/null +++ b/crates/core/src/model/journals.rs @@ -0,0 +1,84 @@ +use serde::{Serialize, Deserialize}; +use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; + +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum JournalPrivacyPermission { + /// Can be accessed by anyone via link. + Public, + /// Visible only to the journal owner. + Private, +} + +impl Default for JournalPrivacyPermission { + fn default() -> Self { + Self::Private + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Journal { + pub id: usize, + pub created: usize, + pub owner: usize, + pub title: String, + pub privacy: JournalPrivacyPermission, + /// An array of directories notes can be placed in. + /// + /// `Vec<(id, parent id, name)>` + pub dirs: Vec<(usize, usize, String)>, +} + +impl Journal { + /// Create a new [`Journal`]. + pub fn new(owner: usize, title: String) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + owner, + title, + privacy: JournalPrivacyPermission::default(), + dirs: Vec::new(), + } + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Note { + pub id: usize, + pub created: usize, + pub owner: usize, + pub title: String, + /// The ID of the [`Journal`] this note belongs to. + /// + /// The note is subject to the settings set for the journal it's in. + pub journal: usize, + pub content: String, + pub edited: usize, + /// The "id" of the directoryy this note is in. + /// + /// Directories are held in the journal in the `dirs` column. + pub dir: usize, + /// An array of tags associated with the note. + pub tags: Vec, + pub is_global: bool, +} + +impl Note { + /// Create a new [`Note`]. + pub fn new(owner: usize, title: String, journal: usize, content: String) -> Self { + let created = unix_epoch_timestamp(); + + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created, + owner, + title, + journal, + content, + edited: created, + dir: 0, + tags: Vec::new(), + is_global: false, + } + } +} diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index 1693fa3..839310f 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -1,22 +1,20 @@ pub mod addr; pub mod apps; pub mod auth; +pub mod carp; +pub mod channels; pub mod communities; pub mod communities_permissions; +pub mod journals; pub mod moderation; pub mod oauth; pub mod permissions; pub mod reactions; pub mod requests; +pub mod socket; pub mod stacks; pub mod uploads; -#[cfg(feature = "redis")] -pub mod channels; - -#[cfg(feature = "redis")] -pub mod socket; - use std::fmt::Display; use serde::{Deserialize, Serialize}; @@ -44,10 +42,13 @@ pub enum Error { AlreadyAuthenticated, DataTooLong(String), DataTooShort(String), + FileTooLarge, + FileTooSmall, UsernameInUse, TitleInUse, QuestionsDisabled, RequiresSupporter, + DrawingsDisabled, Unknown, } @@ -65,10 +66,13 @@ impl Display for Error { Self::AlreadyAuthenticated => "Already authenticated".to_string(), Self::DataTooLong(name) => format!("Given {name} is too long!"), Self::DataTooShort(name) => format!("Given {name} is too short!"), + Self::FileTooLarge => "Given file is too large".to_string(), + Self::FileTooSmall => "Given file is too small".to_string(), Self::UsernameInUse => "Username in use".to_string(), Self::TitleInUse => "Title in use".to_string(), 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(), _ => format!("An unknown error as occurred: ({:?})", self), }) } diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index 438f5fd..e783a1e 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -62,6 +62,14 @@ pub enum AppScope { UserReadRequests, /// Read questions as the user. UserReadQuestions, + /// Read the user's stacks. + UserReadStacks, + /// Read the user's journals. + UserReadJournals, + /// Read the user's notes. + UserReadNotes, + /// Read the user's links. + UserReadLinks, /// Create posts as the user. UserCreatePosts, /// Create messages as the user. @@ -76,6 +84,12 @@ pub enum AppScope { UserCreateCommunities, /// Create stacks on behalf of the user. UserCreateStacks, + /// Create journals on behalf of the user. + UserCreateJournals, + /// Create notes on behalf of the user. + UserCreateNotes, + /// Create links on behalf of the user. + UserCreateLinks, /// Delete posts owned by the user. UserDeletePosts, /// Delete messages owned by the user. @@ -106,6 +120,12 @@ pub enum AppScope { UserManageRequests, /// Manage the user's uploads. UserManageUploads, + /// Manage the user's journals. + UserManageJournals, + /// Manage the user's notes. + UserManageNotes, + /// Manage the user's links. + UserManageLinks, /// Edit posts created by the user. UserEditPosts, /// Edit drafts created by the user. @@ -142,68 +162,6 @@ pub enum AppScope { CommunityManageChannels, } -impl AppScope { - /// Parse the given input string as a list of scopes. - pub fn parse(input: &str) -> Vec { - let mut out: Vec = Vec::new(); - for scope in input.split(" ") { - out.push(match scope { - "user-read-profiles" => Self::UserReadProfiles, - "user-read-profile" => Self::UserReadProfile, - "user-read-settings" => Self::UserReadSettings, - "user-read-sessions" => Self::UserReadSessions, - "user-read-posts" => Self::UserReadPosts, - "user-read-messages" => Self::UserReadMessages, - "user-read-drafts" => Self::UserReadDrafts, - "user-read-communities" => Self::UserReadCommunities, - "user-read-sockets" => Self::UserReadSockets, - "user-read-notifications" => Self::UserReadNotifications, - "user-read-requests" => Self::UserReadRequests, - "user-read-questions" => Self::UserReadQuestions, - "user-create-posts" => Self::UserCreatePosts, - "user-create-messages" => Self::UserCreateMessages, - "user-create-questions" => Self::UserCreateQuestions, - "user-create-ip-blocks" => Self::UserCreateIpBlock, - "user-create-drafts" => Self::UserCreateDrafts, - "user-create-communities" => Self::UserCreateCommunities, - "user-delete-posts" => Self::UserDeletePosts, - "user-delete-messages" => Self::UserDeleteMessages, - "user-delete-questions" => Self::UserDeleteQuestions, - "user-delete-drafts" => Self::UserDeleteDrafts, - "user-manage-profile" => Self::UserManageProfile, - "user-manage-stacks" => Self::UserManageStacks, - "user-manage-relationships" => Self::UserManageRelationships, - "user-manage-memberships" => Self::UserManageMemberships, - "user-manage-following" => Self::UserManageFollowing, - "user-manage-followers" => Self::UserManageFollowers, - "user-manage-blocks" => Self::UserManageBlocks, - "user-manage-notifications" => Self::UserManageNotifications, - "user-manage-requests" => Self::UserManageRequests, - "user-manage-uploads" => Self::UserManageUploads, - "user-edit-posts" => Self::UserEditPosts, - "user-edit-drafts" => Self::UserEditDrafts, - "user-vote" => Self::UserVote, - "user-react" => Self::UserReact, - "user-join-communities" => Self::UserJoinCommunities, - "mod-purge-posts" => Self::ModPurgePosts, - "mod-delete-posts" => Self::ModDeletePosts, - "mod-manage-warnings" => Self::ModManageWarnings, - "user-read-emojis" => Self::UserReadEmojis, - "community-create-emojis" => Self::CommunityCreateEmojis, - "community-manage-emojis" => Self::CommunityManageEmojis, - "community-delete" => Self::CommunityDelete, - "community-manage" => Self::CommunityManage, - "community-transfer-ownership" => Self::CommunityTransferOwnership, - "community-read-memberships" => Self::CommunityReadMemberships, - "community-create-channels" => Self::CommunityCreateChannels, - "community-manage-channels" => Self::CommunityManageChannels, - _ => continue, - }) - } - out - } -} - impl AuthGrant { /// Check a verifier against the stored challenge (using the given [`PkceChallengeMethod`]). pub fn check_verifier(&self, verifier: &str) -> Result<()> { diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs index c0c3542..1584083 100644 --- a/crates/core/src/model/permissions.rs +++ b/crates/core/src/model/permissions.rs @@ -37,91 +37,117 @@ bitflags! { const MANAGE_STACKS = 1 << 26; const STAFF_BADGE = 1 << 27; const MANAGE_APPS = 1 << 28; + const MANAGE_JOURNALS = 1 << 29; + const MANAGE_NOTES = 1 << 30; const _ = !0; } } -impl Serialize for FinePermission { - fn serialize(&self, serializer: S) -> Result - where - S: serde::Serializer, - { - serializer.serialize_u32(self.bits()) - } +macro_rules! user_permission { + ($struct:ident, $visitor:ident, $banned_check:ident) => { + impl Serialize for $struct { + fn serialize(&self, serializer: S) -> Result + where + S: serde::Serializer, + { + serializer.serialize_u32(self.bits()) + } + } + + struct $visitor; + impl Visitor<'_> for $visitor { + type Value = $struct; + + fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { + formatter.write_str("u32") + } + + fn visit_u32(self, value: u32) -> Result + where + E: DeError, + { + if let Some(permission) = $struct::from_bits(value) { + Ok(permission) + } else { + Ok($struct::from_bits_retain(value)) + } + } + + fn visit_i32(self, value: i32) -> Result + where + E: DeError, + { + if let Some(permission) = $struct::from_bits(value as u32) { + Ok(permission) + } else { + Ok($struct::from_bits_retain(value as u32)) + } + } + + fn visit_u64(self, value: u64) -> Result + where + E: DeError, + { + if let Some(permission) = $struct::from_bits(value as u32) { + Ok(permission) + } else { + Ok($struct::from_bits_retain(value as u32)) + } + } + } + + impl<'de> Deserialize<'de> for $struct { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + deserializer.deserialize_any($visitor) + } + } + + impl $struct { + /// Join two permissions into a single `u32`. + pub fn join(lhs: $struct, rhs: $struct) -> Self { + lhs | rhs + } + + /// Check if the given `input` contains the given permission. + pub fn check(self, permission: $struct) -> bool { + if (self & $struct::ADMINISTRATOR) == $struct::ADMINISTRATOR { + // has administrator permission, meaning everything else is automatically true + return true; + } else if self.$banned_check() { + // has banned permission, meaning everything else is automatically false + return false; + } + + (self & permission) == permission + } + + /// Sink for checking if the permission is banned. + pub fn sink(&self) -> bool { + false + } + } + + impl Default for $struct { + fn default() -> Self { + Self::DEFAULT + } + } + }; } -struct FinePermissionVisitor; -impl Visitor<'_> for FinePermissionVisitor { - type Value = FinePermission; - - fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result { - formatter.write_str("u32") - } - - fn visit_u32(self, value: u32) -> Result - where - E: DeError, - { - if let Some(permission) = FinePermission::from_bits(value) { - Ok(permission) - } else { - Ok(FinePermission::from_bits_retain(value)) - } - } - - fn visit_i32(self, value: i32) -> Result - where - E: DeError, - { - if let Some(permission) = FinePermission::from_bits(value as u32) { - Ok(permission) - } else { - Ok(FinePermission::from_bits_retain(value as u32)) - } - } - - fn visit_u64(self, value: u64) -> Result - where - E: DeError, - { - if let Some(permission) = FinePermission::from_bits(value as u32) { - Ok(permission) - } else { - Ok(FinePermission::from_bits_retain(value as u32)) - } - } -} - -impl<'de> Deserialize<'de> for FinePermission { - fn deserialize(deserializer: D) -> Result - where - D: Deserializer<'de>, - { - deserializer.deserialize_any(FinePermissionVisitor) - } -} +user_permission!(FinePermission, FinePermissionVisitor, check_banned); impl FinePermission { - /// Join two [`FinePermission`]s into a single `u32`. - pub fn join(lhs: FinePermission, rhs: FinePermission) -> FinePermission { - lhs | rhs + /// Check if the given permission qualifies as "Banned" status. + pub fn check_banned(self) -> bool { + (self & FinePermission::BANNED) == FinePermission::BANNED } - /// Check if the given `input` contains the given [`FinePermission`]. - pub fn check(self, permission: FinePermission) -> bool { - if (self & FinePermission::ADMINISTRATOR) == FinePermission::ADMINISTRATOR { - // has administrator permission, meaning everything else is automatically true - return true; - } else if self.check_banned() { - // has banned permission, meaning everything else is automatically false - return false; - } - - (self & permission) == permission - } - - /// Check if the given [`FinePermission`] qualifies as "Helper" status. + /// Check if the given permission qualifies as "Helper" status. pub fn check_helper(self) -> bool { self.check(FinePermission::MANAGE_COMMUNITIES) && self.check(FinePermission::MANAGE_POSTS) @@ -131,24 +157,26 @@ impl FinePermission { && self.check(FinePermission::VIEW_AUDIT_LOG) } - /// Check if the given [`FinePermission`] qualifies as "Manager" status. + /// Check if the given permission qualifies as "Manager" status. pub fn check_manager(self) -> bool { self.check_helper() && self.check(FinePermission::MANAGE_USERS) } - /// Check if the given [`FinePermission`] qualifies as "Administrator" status. + /// Check if the given permission qualifies as "Administrator" status. pub fn check_admin(self) -> bool { self.check_manager() && self.check(FinePermission::ADMINISTRATOR) } +} - /// Check if the given [`FinePermission`] qualifies as "Banned" status. - pub fn check_banned(self) -> bool { - (self & FinePermission::BANNED) == FinePermission::BANNED +bitflags! { + /// Fine-grained permissions built using bitwise operations. Second permission value. + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] + pub struct SecondaryPermission: u32 { + const DEFAULT = 1 << 0; + const ADMINISTRATOR = 1 << 1; + + const _ = !0; } } -impl Default for FinePermission { - fn default() -> Self { - Self::DEFAULT - } -} +user_permission!(SecondaryPermission, SecondaryPermissionVisitor, sink); diff --git a/crates/core/src/model/stacks.rs b/crates/core/src/model/stacks.rs index 809a0e0..437f2cc 100644 --- a/crates/core/src/model/stacks.rs +++ b/crates/core/src/model/stacks.rs @@ -1,7 +1,7 @@ use serde::{Serialize, Deserialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum StackPrivacy { /// Can be viewed by anyone. Public, @@ -15,7 +15,7 @@ impl Default for StackPrivacy { } } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum StackMode { /// `users` vec contains ID of users to INCLUDE into the timeline; /// every other user is excluded @@ -23,6 +23,13 @@ pub enum StackMode { /// `users` vec contains ID of users to EXCLUDE from the timeline; /// every other user is included Exclude, + /// `users` vec contains ID of users to show in a user listing on the stack's + /// page (instead of a timeline). + /// + /// Other users can block the entire list (creating a `StackBlock`, not a `UserBlock`). + BlockList, + /// `users` vec contains ID of users who are allowed to view posts posted to the stack. + Circle, } impl Default for StackMode { @@ -31,7 +38,7 @@ impl Default for StackMode { } } -#[derive(Debug, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum StackSort { Created, Likes, @@ -43,7 +50,7 @@ impl Default for StackSort { } } -#[derive(Serialize, Deserialize)] +#[derive(Clone, Debug, Serialize, Deserialize)] pub struct UserStack { pub id: usize, pub created: usize, @@ -70,3 +77,23 @@ impl UserStack { } } } + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct StackBlock { + pub id: usize, + pub created: usize, + pub initiator: usize, + pub stack: usize, +} + +impl StackBlock { + /// Create a new [`StackBlock`]. + pub fn new(initiator: usize, stack: usize) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + initiator, + stack, + } + } +} diff --git a/crates/core/src/model/uploads.rs b/crates/core/src/model/uploads.rs index d502697..35165c6 100644 --- a/crates/core/src/model/uploads.rs +++ b/crates/core/src/model/uploads.rs @@ -5,7 +5,7 @@ use crate::config::Config; use std::fs::{write, exists, remove_file}; use super::{Error, Result}; -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, PartialEq, Eq)] pub enum MediaType { #[serde(alias = "image/webp")] Webp, @@ -17,6 +17,8 @@ pub enum MediaType { Jpg, #[serde(alias = "image/gif")] Gif, + #[serde(alias = "image/carpgraph")] + Carpgraph, } impl MediaType { @@ -27,6 +29,7 @@ impl MediaType { Self::Png => "png", Self::Jpg => "jpg", Self::Gif => "gif", + Self::Carpgraph => "carpgraph", } } diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index f3f3e62..c50b714 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-l10n" -version = "7.0.0" +version = "9.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 43caae5..8d48901 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-shared" -version = "7.0.0" +version = "9.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/shared/src/markdown.rs b/crates/shared/src/markdown.rs index c4b51da..1ae665b 100644 --- a/crates/shared/src/markdown.rs +++ b/crates/shared/src/markdown.rs @@ -14,8 +14,9 @@ pub fn render_markdown(input: &str) -> String { }, parse: ParseOptions { constructs: Constructs { - gfm_autolink_literal: true, - ..Default::default() + math_flow: true, + math_text: true, + ..Constructs::gfm() }, gfm_strikethrough_single_tilde: false, math_text_single_dollar: false, diff --git a/example/nginx/sites-enabled/tetratto.conf b/example/nginx/sites-enabled/tetratto.conf new file mode 100644 index 0000000..52aa6a4 --- /dev/null +++ b/example/nginx/sites-enabled/tetratto.conf @@ -0,0 +1,28 @@ +# servers can be uncommented to add load balancing +upstream tetratto { + least_conn; + server localhost:4118; + # server localhost:5118; +} + +server { + listen 80 default_server; + listen [::]:80 default_server; + + server_name tetratto; + + # main service stuff + location / { + proxy_pass http://tetratto; + proxy_pass_header CF-Connecting-IP; + proxy_pass_request_headers on; + } + + # websocket forwarding stuff + location ~ /_connect/ { + proxy_pass http://tetratto; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection "upgrade"; + } +} diff --git a/example/tetratto.toml b/example/tetratto.toml index 37119a4..488bc89 100644 --- a/example/tetratto.toml +++ b/example/tetratto.toml @@ -23,6 +23,7 @@ html_footer_path = "public/footer.html" [security] registration_enabled = true real_ip_header = "CF-Connecting-IP" +enable_invite_codes = false [dirs] templates = "html" diff --git a/sql_changes/channels_last_message.sql b/sql_changes/channels_last_message.sql new file mode 100644 index 0000000..9709200 --- /dev/null +++ b/sql_changes/channels_last_message.sql @@ -0,0 +1,2 @@ +ALTER TABLE channels +ADD COLUMN last_message BIGINT NOT NULL DEFAULT '0'; diff --git a/sql_changes/journals_dirs.sql b/sql_changes/journals_dirs.sql new file mode 100644 index 0000000..72d1aaf --- /dev/null +++ b/sql_changes/journals_dirs.sql @@ -0,0 +1,2 @@ +ALTER TABLE journals +ADD COLUMN dirs TEXT NOT NULL DEFAULT '[]'; diff --git a/sql_changes/messages_reactions.sql b/sql_changes/messages_reactions.sql new file mode 100644 index 0000000..684e890 --- /dev/null +++ b/sql_changes/messages_reactions.sql @@ -0,0 +1,2 @@ +ALTER TABLE messages +ADD COLUMN reactions TEXT NOT NULL DEFAULT '{}'; diff --git a/sql_changes/notes_dir_tags.sql b/sql_changes/notes_dir_tags.sql new file mode 100644 index 0000000..0bf24d1 --- /dev/null +++ b/sql_changes/notes_dir_tags.sql @@ -0,0 +1,5 @@ +ALTER TABLE notes +ADD COLUMN dir BIGINT NOT NULL DEFAULT 0; + +ALTER TABLE notes +ADD COLUMN tags TEXT NOT NULL DEFAULT '[]'; diff --git a/sql_changes/notes_is_global.sql b/sql_changes/notes_is_global.sql new file mode 100644 index 0000000..eeea878 --- /dev/null +++ b/sql_changes/notes_is_global.sql @@ -0,0 +1,2 @@ +ALTER TABLE notes +ADD COLUMN is_global INT NOT NULL DEFAULT 0; diff --git a/sql_changes/posts_stack.sql b/sql_changes/posts_stack.sql new file mode 100644 index 0000000..9cd3474 --- /dev/null +++ b/sql_changes/posts_stack.sql @@ -0,0 +1,5 @@ +ALTER TABLE posts +DROP COLUMN circle; + +ALTER TABLE posts +ADD COLUMN stack BIGINT NOT NULL DEFAULT 0; diff --git a/sql_changes/questions_drawings.sql b/sql_changes/questions_drawings.sql new file mode 100644 index 0000000..f45e50b --- /dev/null +++ b/sql_changes/questions_drawings.sql @@ -0,0 +1,2 @@ +ALTER TABLE questions +ADD COLUMN drawings TEXT NOT NULL DEFAULT '[]'; diff --git a/sql_changes/users_achievements.sql b/sql_changes/users_achievements.sql new file mode 100644 index 0000000..2eadcf4 --- /dev/null +++ b/sql_changes/users_achievements.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN achievements TEXT NOT NULL DEFAULT '[]'; diff --git a/sql_changes/users_invite_code.sql b/sql_changes/users_invite_code.sql new file mode 100644 index 0000000..2e97a8d --- /dev/null +++ b/sql_changes/users_invite_code.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN invite_code BIGINT NOT NULL DEFAULT 0; diff --git a/sql_changes/users_secondary_permissions.sql b/sql_changes/users_secondary_permissions.sql new file mode 100644 index 0000000..7172889 --- /dev/null +++ b/sql_changes/users_secondary_permissions.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN secondary_permissions INT NOT NULL DEFAULT 1;