From 4843688fcfafa45fe7c5fe56f9451852c01472cd Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 23 Jun 2025 13:48:16 -0400 Subject: [PATCH] add: ability to generate invite codes in bulk add: better mark as nsfw ui --- crates/app/src/langs/en-US.toml | 2 +- crates/app/src/public/css/root.css | 2 +- .../public/html/communities/create_post.lisp | 2 +- crates/app/src/public/html/components.lisp | 27 +++++++++---- crates/app/src/public/html/misc/requests.lisp | 29 +++++++------- .../app/src/public/html/profile/settings.lisp | 25 +++++++++--- crates/app/src/routes/api/v1/auth/profile.rs | 33 ++++++++++------ .../src/routes/api/v1/communities/posts.rs | 5 +-- crates/app/src/routes/api/v1/mod.rs | 5 ++- crates/app/src/routes/pages/misc.rs | 6 +-- crates/app/src/routes/pages/profile.rs | 6 ++- crates/core/src/database/invite_codes.rs | 38 ++++++++++++++++--- crates/core/src/database/posts.rs | 36 +----------------- 13 files changed, 126 insertions(+), 90 deletions(-) diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 2ad3e9d..4dacfb5 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -168,7 +168,7 @@ version = "1.0.0" "settings:label.export" = "Export" "settings:label.manage_blocks" = "Manage blocks" "settings:label.users" = "Users" -"settings:label.generate_invite" = "Generate invite" +"settings:label.generate_invites" = "Generate invites" "settings:label.add_to_stack" = "Add to stack" "settings:tab.security" = "Security" "settings:tab.blocks" = "Blocks" diff --git a/crates/app/src/public/css/root.css b/crates/app/src/public/css/root.css index 3d7dd62..3de8708 100644 --- a/crates/app/src/public/css/root.css +++ b/crates/app/src/public/css/root.css @@ -218,7 +218,7 @@ pre { } code { - padding: var(--pad-1); + padding: 0; } pre, diff --git a/crates/app/src/public/html/communities/create_post.lisp b/crates/app/src/public/html/communities/create_post.lisp index 754e915..0b7cf19 100644 --- a/crates/app/src/public/html/communities/create_post.lisp +++ b/crates/app/src/public/html/communities/create_post.lisp @@ -139,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") diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 99bfc87..2d27d6d 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -1399,7 +1399,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 @@ -1414,7 +1414,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") @@ -1474,11 +1487,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, 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/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index b0db773..85777e5 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -77,7 +77,7 @@ (text "{% if config.security.enable_invite_codes -%}") (a ("data-tab-button" "account/invites") - ("href" "#/account/invites") + ("href" "?page=0#/account/invites") (text "{{ icon \"ticket\" }}") (span (text "{{ text \"settings:tab.invites\" }}"))) @@ -538,10 +538,12 @@ (text "{{ text \"settings:tab.invites\" }}"))) (div ("class" "card flex flex-col gap-2 secondary") + (pre ("id" "invite_codes_output") ("class" "hidden") (code)) + (button - ("onclick" "generate_invite_code()") + ("onclick" "generate_invite_codes()") (icon (text "plus")) - (str (text "settings:label.generate_invite"))) + (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 @@ -555,8 +557,10 @@ (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_code = async () => { + (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.\", @@ -565,7 +569,16 @@ return; } - fetch(`/api/v1/invite`, { + 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_output\").children[0].innerText = \"Working...\"; + + fetch(`/api/v1/invites/${count}`, { method: \"POST\", }) .then((res) => res.json()) @@ -576,7 +589,7 @@ ]); if (res.ok) { - alert(res.payload); + document.getElementById(\"invite_codes_output\").children[0].innerText = res.payload; } }); };")))))) diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index 419e864..f3b0396 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -832,8 +832,9 @@ pub async fn refresh_grant_request( /// Generate an invite code. /// /// Does not support third-party grants. -pub async fn generate_invite_code_request( +pub async fn generate_invite_codes_request( jar: CookieJar, + Path(count): Path, Extension(data): Extension, ) -> impl IntoResponse { let data = &(data.read().await).0; @@ -846,15 +847,25 @@ pub async fn generate_invite_code_request( return Json(Error::NotAllowed.into()); } - match data - .create_invite_code(InviteCode::new(user.id), &user) - .await - { - Ok(x) => Json(ApiReturn { - ok: true, - message: "Code generated".to_string(), - payload: Some(x.code), - }), - Err(e) => Json(e.into()), + if count > 48 { + return Json(Error::DataTooLong("count".to_string()).into()); } + + let mut out_string = String::new(); + + for _ in 0..count { + match data + .create_invite_code(InviteCode::new(user.id), &user) + .await + { + Ok(x) => out_string += &(x.code + "\n"), + Err(_) => break, + } + } + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(out_string), + }) } diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index 5737fc5..70529ed 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -441,10 +441,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 { diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 74571af..ea45ffd 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -37,7 +37,10 @@ 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("/invite", post(auth::profile::generate_invite_code_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)) diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs index 8d0d8be..d83a695 100644 --- a/crates/app/src/routes/pages/misc.rs +++ b/crates/app/src/routes/pages/misc.rs @@ -625,12 +625,10 @@ pub async fn swiss_army_timeline_request( check_user_blocked_or_private!(user, other_user, data, jar); if req.tag.is_empty() { - data.0 - .get_posts_by_user(req.user_id, 12, req.page, &user) - .await + 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, &user) + .get_posts_by_user_tag(req.user_id, &req.tag, 12, req.page) .await } } else { diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index 78b2d22..17d0d1f 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -101,7 +101,11 @@ pub async fn settings_request( } }; - let invites = match data.0.get_invite_codes_by_owner(profile.id).await { + 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) => { diff --git a/crates/core/src/database/invite_codes.rs b/crates/core/src/database/invite_codes.rs index 760b469..2c6d950 100644 --- a/crates/core/src/database/invite_codes.rs +++ b/crates/core/src/database/invite_codes.rs @@ -1,4 +1,4 @@ -use oiseau::{cache::Cache, query_rows}; +use oiseau::{cache::Cache, query_row, query_rows}; use tetratto_shared::unix_epoch_timestamp; use crate::model::{ Error, Result, @@ -24,7 +24,12 @@ impl DataManager { 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) -> Result> { + 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())), @@ -32,8 +37,8 @@ impl DataManager { let res = query_rows!( &conn, - "SELECT * FROM invite_codes WHERE owner = $1", - &[&(owner as i64)], + "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) } ); @@ -44,6 +49,27 @@ impl DataManager { 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, @@ -89,7 +115,7 @@ impl DataManager { // 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(user.id).await?.len() + if (self.get_invite_codes_by_owner_count(user.id).await? as usize) >= Self::MAXIMUM_FREE_INVITE_CODES { return Err(Error::MiscError( @@ -99,7 +125,7 @@ impl DataManager { } } 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(user.id).await?.len() + if (self.get_invite_codes_by_owner_count(user.id).await? as usize) >= Self::MAXIMUM_SUPPORTER_INVITE_CODES { return Err(Error::MiscError( diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index ba4bc5e..c1f1dca 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -689,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) } ); @@ -1008,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}\"%"),