From 8d70f65863982d872c64966d59fcceeb90b755f1 Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 27 Jun 2025 13:36:10 -0400 Subject: [PATCH 01/93] add: achievements progress bar --- crates/app/src/public/html/components.lisp | 5 +++-- crates/app/src/public/html/misc/achievements.lisp | 7 +++++-- crates/app/src/public/js/atto.js | 4 ++++ crates/app/src/routes/pages/misc.rs | 7 ++++++- 4 files changed, 18 insertions(+), 5 deletions(-) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 75f9620..9b5f567 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -114,9 +114,10 @@ (div ("style" "display: contents") (text "{{ self::post(post=post, owner=owner, secondary=secondary, community=community, show_community=show_community, can_manage_post=can_manage_post, repost=repost, expect_repost=true) }}")) -(text "{%- endmacro %} {% macro post(post, owner, question=false, secondary=false, community=false, show_community=true, can_manage_post=false, repost=false, expect_repost=false, poll=false, dont_show_title=false) -%} {% if community and show_community and community.id != config.town_square or question %}") +(text "{%- endmacro %} {% macro post(post, owner, question=false, secondary=false, community=false, show_community=true, can_manage_post=false, repost=false, expect_repost=false, poll=false, dont_show_title=false, is_repost=false) -%} {% if community and show_community and community.id != config.town_square or question %}") (div ("class" "card-nest post_outer:{{ post.id }} post_outer") + ("is_repost" "{{ is_repost }}") (text "{% if question -%} {{ self::question(question=question[0], owner=question[1], profile=owner) }} {% else %}") (div ("class" "card small") @@ -235,7 +236,7 @@ ; content (span ("id" "post_content:{{ post.id }}") (text "{{ post.content|markdown|safe }}")) - (text "{% if expect_repost -%} {% if repost -%} {{ self::post(post=repost[1], owner=repost[0], secondary=not secondary, community=false, show_community=false, can_manage_post=false) }} {% else %}") + (text "{% if expect_repost -%} {% if repost -%} {{ self::post(post=repost[1], owner=repost[0], secondary=not secondary, community=false, show_community=false, can_manage_post=false, is_repost=true) }} {% else %}") (div ("class" "card lowered red flex items-center gap-2") (text "{{ icon \"frown\" }}") diff --git a/crates/app/src/public/html/misc/achievements.lisp b/crates/app/src/public/html/misc/achievements.lisp index 4b21b5d..429c924 100644 --- a/crates/app/src/public/html/misc/achievements.lisp +++ b/crates/app/src/public/html/misc/achievements.lisp @@ -12,9 +12,12 @@ (icon (text "coffee")) (span (text "Welcome to {{ config.name }}!"))) (div - ("class" "card no_p_margin") + ("class" "card no_p_margin flex flex-col gap-2") (p (text "To help you move in, you'll be rewarded with small achievements for completing simple tasks on {{ config.name }}!")) - (p (text "You'll find out what each achievement is when you get it, so look around!")))) + (p (text "You'll find out what each achievement is when you get it, so look around!")) + (hr) + (span (b (text "Your progress: ")) (text "{{ percentage|round(method=\"floor\", precision=2) }}%")) + (div ("class" "poll_bar") ("style" "width: {{ percentage }}%")))) (div ("class" "card-nest") diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 350b7a3..32c5b5f 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -1295,6 +1295,10 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} `.post${outer ? "_outer" : ""}\\:${id}`, ), )) { + if (element.getAttribute("is_repost") === true) { + continue; + } + if (idx === 0) { idx += 1; continue; diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs index 3b99c5d..1abc14b 100644 --- a/crates/app/src/routes/pages/misc.rs +++ b/crates/app/src/routes/pages/misc.rs @@ -10,7 +10,7 @@ use axum::{ use axum_extra::extract::CookieJar; use serde::Deserialize; use tetratto_core::model::{ - auth::{AchievementName, DefaultTimelineChoice}, + auth::{AchievementName, DefaultTimelineChoice, ACHIEVEMENTS}, permissions::FinePermission, requests::ActionType, Error, @@ -473,6 +473,11 @@ pub async fn achievements_request( // ... let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + + context.insert( + "percentage", + &((achievements.len() as f32 / ACHIEVEMENTS as f32) * 100.0), + ); context.insert("achievements", &achievements); // return From a799c777ea940d5c23891cdfe98639c63a51a2cc Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 27 Jun 2025 14:21:42 -0400 Subject: [PATCH 02/93] add: 8 more achievements --- crates/app/src/public/html/components.lisp | 2 + .../app/src/public/html/profile/settings.lisp | 5 +++ crates/app/src/public/js/me.js | 10 ++++- .../routes/api/v1/communities/communities.rs | 9 ++--- .../src/routes/api/v1/communities/drafts.rs | 13 ++++++- .../src/routes/api/v1/communities/posts.rs | 11 +++++- crates/core/src/database/auth.rs | 4 ++ crates/core/src/database/communities.rs | 9 ++--- crates/core/src/database/memberships.rs | 12 +++++- crates/core/src/database/userfollows.rs | 27 ++++++++++++++ crates/core/src/model/auth.rs | 37 ++++++++++++++++++- 11 files changed, 121 insertions(+), 18 deletions(-) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 9b5f567..2f4f004 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -1084,10 +1084,12 @@ ("href" "/journals/0/0") (icon (text "notebook")) (str (text "general:link.journals"))) + (text "{% if not user.settings.disable_achievements -%}") (a ("href" "/achievements") (icon (text "award")) (str (text "general:link.achievements"))) + (text "{%- endif %}") (a ("href" "/settings") (text "{{ icon \"settings\" }}") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index f2280e6..4ac8b4c 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -1553,6 +1553,11 @@ \"{{ profile.settings.disable_gpa_fun }}\", \"checkbox\", ], + [ + [\"disable_achievements\", \"Disable achievements\"], + \"{{ profile.settings.disable_achievements }}\", + \"checkbox\", + ], ], settings, ); diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index 31290c9..15bf7a2 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -979,7 +979,13 @@ self.define( "timestamp", - ({ $ }, updated_, progress_ms_, duration_ms_, display = "full") => { + async ( + { $ }, + updated_, + progress_ms_, + duration_ms_, + display = "full", + ) => { if (duration_ms_ === "0") { return; } @@ -1003,7 +1009,7 @@ } if (display === "full") { - return `${$.ms_time_text(progress_ms)}/${$.ms_time_text(duration_ms)} (${Math.floor((progress_ms / duration_ms) * 100)}%)`; + return `${await $.ms_time_text(progress_ms)}/${await $.ms_time_text(duration_ms)} (${Math.floor((progress_ms / duration_ms) * 100)}%)`; } if (display === "left") { diff --git a/crates/app/src/routes/api/v1/communities/communities.rs b/crates/app/src/routes/api/v1/communities/communities.rs index e2c72f1..539cc08 100644 --- a/crates/app/src/routes/api/v1/communities/communities.rs +++ b/crates/app/src/routes/api/v1/communities/communities.rs @@ -292,11 +292,10 @@ pub async fn create_membership( }; match data - .create_membership(CommunityMembership::new( - user.id, - id, - CommunityPermission::default(), - )) + .create_membership( + CommunityMembership::new(user.id, id, CommunityPermission::default()), + &user, + ) .await { Ok(m) => Json(ApiReturn { diff --git a/crates/app/src/routes/api/v1/communities/drafts.rs b/crates/app/src/routes/api/v1/communities/drafts.rs index a6de4c9..346a253 100644 --- a/crates/app/src/routes/api/v1/communities/drafts.rs +++ b/crates/app/src/routes/api/v1/communities/drafts.rs @@ -4,7 +4,7 @@ use axum::{ Extension, Json, }; use axum_extra::extract::CookieJar; -use tetratto_core::model::{communities::PostDraft, oauth, ApiReturn, Error}; +use tetratto_core::model::{auth::AchievementName, communities::PostDraft, oauth, ApiReturn, Error}; use crate::{ get_user_from_token, routes::{ @@ -20,11 +20,20 @@ pub async fn create_request( Json(req): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateDrafts) { + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateDrafts) { Some(ua) => ua, None => return Json(Error::NotAllowed.into()), }; + // award achievement + if let Err(e) = data + .add_achievement(&mut user, AchievementName::CreateDraft.into()) + .await + { + return Json(e.into()); + } + + // ... match data .create_draft(PostDraft::new(req.content, user.id)) .await diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index fee1ba8..1982d6e 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -341,11 +341,20 @@ pub async fn update_content_request( Json(req): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserEditPosts) { + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserEditPosts) { Some(ua) => ua, None => return Json(Error::NotAllowed.into()), }; + // award achievement + if let Err(e) = data + .add_achievement(&mut user, AchievementName::EditPost.into()) + .await + { + return Json(e.into()); + } + + // ... match data.update_post_content(id, user, req.content).await { Ok(_) => Json(ApiReturn { ok: true, diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index a34b634..f6fb848 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -713,6 +713,10 @@ impl DataManager { /// /// Still returns `Ok` if the user already has the achievement. pub async fn add_achievement(&self, user: &mut User, achievement: Achievement) -> Result<()> { + if user.settings.disable_achievements { + return Ok(()); + } + if user .achievements .iter() diff --git a/crates/core/src/database/communities.rs b/crates/core/src/database/communities.rs index 2642f37..8237b7e 100644 --- a/crates/core/src/database/communities.rs +++ b/crates/core/src/database/communities.rs @@ -299,11 +299,10 @@ impl DataManager { } // add community owner as admin - self.create_membership(CommunityMembership::new( - data.owner, - data.id, - CommunityPermission::ADMINISTRATOR, - )) + self.create_membership( + CommunityMembership::new(data.owner, data.id, CommunityPermission::ADMINISTRATOR), + &owner, + ) .await .unwrap(); diff --git a/crates/core/src/database/memberships.rs b/crates/core/src/database/memberships.rs index 4ae7094..01f286b 100644 --- a/crates/core/src/database/memberships.rs +++ b/crates/core/src/database/memberships.rs @@ -1,4 +1,5 @@ use oiseau::cache::Cache; +use crate::model::auth::AchievementName; use crate::model::communities::Community; use crate::model::requests::{ActionRequest, ActionType}; use crate::model::{ @@ -169,7 +170,11 @@ impl DataManager { /// # Arguments /// * `data` - a mock [`CommunityMembership`] object to insert #[async_recursion::async_recursion] - pub async fn create_membership(&self, data: CommunityMembership) -> Result { + pub async fn create_membership( + &self, + data: CommunityMembership, + user: &User, + ) -> Result { // make sure membership doesn't already exist if self .get_membership_by_owner_community_no_void(data.owner, data.community) @@ -199,7 +204,7 @@ impl DataManager { .await?; // ... - return self.create_membership(data).await; + return self.create_membership(data, user).await; } } _ => (), @@ -237,6 +242,9 @@ impl DataManager { Ok(if data.role.check(CommunityPermission::REQUESTED) { "Join request sent".to_string() } else { + self.add_achievement(&mut user.clone(), AchievementName::JoinCommunity.into()) + .await?; + "Community joined".to_string() }) } diff --git a/crates/core/src/database/userfollows.rs b/crates/core/src/database/userfollows.rs index 3409443..ffcd891 100644 --- a/crates/core/src/database/userfollows.rs +++ b/crates/core/src/database/userfollows.rs @@ -265,6 +265,33 @@ impl DataManager { self.add_achievement(&mut other_user, AchievementName::FollowedByStaff.into()) .await?; } + + // other achivements + self.add_achievement(&mut other_user, AchievementName::Get1Follower.into()) + .await?; + + if other_user.follower_count >= 9 { + self.add_achievement(&mut other_user, AchievementName::Get10Followers.into()) + .await?; + } + + if other_user.follower_count >= 49 { + self.add_achievement(&mut other_user, AchievementName::Get50Followers.into()) + .await?; + } + + if other_user.follower_count >= 99 { + self.add_achievement(&mut other_user, AchievementName::Get100Followers.into()) + .await?; + } + + if initiator.following_count >= 9 { + self.add_achievement( + &mut initiator.clone(), + AchievementName::Follow10Users.into(), + ) + .await?; + } } // ... diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 11f015f..054d449 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -261,6 +261,9 @@ pub struct UserSettings { /// Increase the text size of buttons and paragraphs. #[serde(default)] pub large_text: bool, + /// Disable achievements. + #[serde(default)] + pub disable_achievements: bool, } fn mime_avif() -> String { @@ -478,7 +481,7 @@ pub struct ExternalConnectionData { } /// The total number of achievements needed to 100% Tetratto! -pub const ACHIEVEMENTS: usize = 16; +pub const ACHIEVEMENTS: usize = 24; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum AchievementName { @@ -498,6 +501,14 @@ pub enum AchievementName { Get50Likes, Get100Likes, Get25Dislikes, + Get1Follower, + Get10Followers, + Get50Followers, + Get100Followers, + Follow10Users, + JoinCommunity, + CreateDraft, + EditPost, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -526,6 +537,14 @@ impl AchievementName { Self::Get50Likes => "banger post follow for more", Self::Get100Likes => "everyone liked that", Self::Get25Dislikes => "Sorry...", + Self::Get1Follower => "Friends?", + Self::Get10Followers => "Friends!", + Self::Get50Followers => "50 WHOLE FOLLOWERS??", + Self::Get100Followers => "Everyone is my friend!", + Self::Follow10Users => "Big fan", + Self::JoinCommunity => "A sense of community...", + Self::CreateDraft => "Maybe later!", + Self::EditPost => "Grammar police?", } } @@ -547,6 +566,14 @@ impl AchievementName { Self::Get50Likes => "Get 50 likes on one post.", Self::Get100Likes => "Get 100 likes on one post.", Self::Get25Dislikes => "Get 25 dislikes on one post... :(", + Self::Get1Follower => "Get 1 follow. Cool!", + Self::Get10Followers => "Get 10 followers. You're getting popular!", + Self::Get50Followers => "Get 50 followers. Okay, you're fairly popular!", + Self::Get100Followers => "Get 100 followers. You might be famous..?", + Self::Follow10Users => "Follow 10 other users. I'm sure people appreciate it!", + Self::JoinCommunity => "Join a community. Welcome!", + Self::CreateDraft => "Save a post as a draft.", + Self::EditPost => "Edit a post.", } } @@ -570,6 +597,14 @@ impl AchievementName { Self::Get50Likes => Uncommon, Self::Get100Likes => Rare, Self::Get25Dislikes => Uncommon, + Self::Get1Follower => Common, + Self::Get10Followers => Common, + Self::Get50Followers => Uncommon, + Self::Get100Followers => Rare, + Self::Follow10Users => Common, + Self::JoinCommunity => Common, + Self::CreateDraft => Common, + Self::EditPost => Common, } } } From 01633913807a3ac7422c884226b2d84d69d80cc9 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 28 Jun 2025 13:15:37 -0400 Subject: [PATCH 03/93] add: ability to ip block users from their profile --- crates/app/src/langs/en-US.toml | 1 + crates/app/src/public/css/root.css | 4 ++ crates/app/src/public/html/components.lisp | 9 +-- crates/app/src/public/html/profile/base.lisp | 48 +++++++++++++-- .../app/src/public/html/profile/settings.lisp | 53 ++++++++++++++++ crates/app/src/public/js/atto.js | 6 +- crates/app/src/public/js/me.js | 21 +++++++ crates/app/src/routes/api/v1/auth/social.rs | 61 +++++++++++++++++++ crates/app/src/routes/api/v1/mod.rs | 8 +++ crates/app/src/routes/pages/profile.rs | 6 ++ crates/core/src/database/ipblocks.rs | 35 ++++++++--- crates/core/src/model/auth.rs | 9 +++ 12 files changed, 241 insertions(+), 20 deletions(-) diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 13b6b64..fe07c8b 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -169,6 +169,7 @@ version = "1.0.0" "settings:label.export" = "Export" "settings:label.manage_blocks" = "Manage blocks" "settings:label.users" = "Users" +"settings:label.ips" = "IPs" "settings:label.generate_invites" = "Generate invites" "settings:label.add_to_stack" = "Add to stack" "settings:tab.security" = "Security" diff --git a/crates/app/src/public/css/root.css b/crates/app/src/public/css/root.css index 3de8708..34281e6 100644 --- a/crates/app/src/public/css/root.css +++ b/crates/app/src/public/css/root.css @@ -38,6 +38,10 @@ --pad-2: 0.5rem; --pad-3: 0.75rem; --pad-4: 1rem; + + --online: var(--color-green); + --idle: var(--color-yellow); + --offline: hsl(0, 0%, 50%); } .dark, diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 2f4f004..ba7f4e3 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -528,7 +528,7 @@ ("width" "24") ("height" "24") ("viewBox" "0 0 24 24") - ("style" "fill: var(--color-green)") + ("style" "fill: var(--online)") (circle ("cx" "12") ("cy" "12") @@ -541,7 +541,7 @@ ("width" "24") ("height" "24") ("viewBox" "0 0 24 24") - ("style" "fill: var(--color-yellow)") + ("style" "fill: var(--idle)") (circle ("cx" "12") ("cy" "12") @@ -554,7 +554,7 @@ ("width" "24") ("height" "24") ("viewBox" "0 0 24 24") - ("style" "fill: hsl(0, 0%, 50%)") + ("style" "fill: var(--offline)") (circle ("cx" "12") ("cy" "12") @@ -611,7 +611,8 @@ (text "{%- endif %}") (div ("style" "display: none;") - (text "{{ self::theme_color(color=user.settings.theme_color_surface, css=\"color-surface\") }} {{ self::theme_color(color=user.settings.theme_color_text, css=\"color-text\") }} {{ self::theme_color(color=user.settings.theme_color_text_link, css=\"color-link\") }} {{ self::theme_color(color=user.settings.theme_color_lowered, css=\"color-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_text_lowered, css=\"color-text-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_super_lowered, css=\"color-super-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_raised, css=\"color-raised\") }} {{ self::theme_color(color=user.settings.theme_color_text_raised, css=\"color-text-raised\") }} {{ self::theme_color(color=user.settings.theme_color_super_raised, css=\"color-super-raised\") }} {{ self::theme_color(color=user.settings.theme_color_primary, css=\"color-primary\") }} {{ self::theme_color(color=user.settings.theme_color_text_primary, css=\"color-text-primary\") }} {{ self::theme_color(color=user.settings.theme_color_primary_lowered, css=\"color-primary-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_secondary, css=\"color-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_text_secondary, css=\"color-text-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_secondary_lowered, css=\"color-secondary-lowered\") }} {% if user.permissions|has_supporter -%}") + (text "{{ self::theme_color(color=user.settings.theme_color_surface, css=\"color-surface\") }} {{ self::theme_color(color=user.settings.theme_color_text, css=\"color-text\") }} {{ self::theme_color(color=user.settings.theme_color_text_link, css=\"color-link\") }} {{ self::theme_color(color=user.settings.theme_color_lowered, css=\"color-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_text_lowered, css=\"color-text-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_super_lowered, css=\"color-super-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_raised, css=\"color-raised\") }} {{ self::theme_color(color=user.settings.theme_color_text_raised, css=\"color-text-raised\") }} {{ self::theme_color(color=user.settings.theme_color_super_raised, css=\"color-super-raised\") }} {{ self::theme_color(color=user.settings.theme_color_primary, css=\"color-primary\") }} {{ self::theme_color(color=user.settings.theme_color_text_primary, css=\"color-text-primary\") }} {{ self::theme_color(color=user.settings.theme_color_primary_lowered, css=\"color-primary-lowered\") }} {{ self::theme_color(color=user.settings.theme_color_secondary, css=\"color-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_text_secondary, css=\"color-text-secondary\") }} {{ self::theme_color(color=user.settings.theme_color_secondary_lowered, css=\"color-secondary-lowered\") }} + {{ self::theme_color(color=user.settings.theme_color_online, css=\"online\") }} {{ self::theme_color(color=user.settings.theme_color_idle, css=\"idle\") }} {{ self::theme_color(color=user.settings.theme_color_offline, css=\"offline\") }} {% if user.permissions|has_supporter -%}") (style (text "{{ user.settings.theme_custom_css|remove_script_tags|safe }}")) (text "{%- endif %}")) diff --git a/crates/app/src/public/html/profile/base.lisp b/crates/app/src/public/html/profile/base.lisp index 7718eea..481c007 100644 --- a/crates/app/src/public/html/profile/base.lisp +++ b/crates/app/src/public/html/profile/base.lisp @@ -219,12 +219,24 @@ (text "{{ icon \"user-minus\" }}") (span (text "{{ text \"auth:action.unfollow\" }}"))) - (button - ("onclick" "toggle_block_user()") - ("class" "lowered red") - (text "{{ icon \"shield\" }}") - (span - (text "{{ text \"auth:action.block\" }}"))) + (div + ("class" "dropdown") + (button + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("class" "lowered red") + (icon_class (text "chevron-down") (text "dropdown-arrow")) + (str (text "auth:action.block"))) + (div + ("class" "inner") + (button + ("onclick" "toggle_block_user()") + (icon (text "shield")) + (str (text "auth:action.block"))) + (button + ("onclick" "ip_block_user()") + (icon (text "wifi")) + (str (text "auth:action.ip_block"))))) (text "{% else %}") (button ("onclick" "toggle_block_user()") @@ -342,6 +354,30 @@ res.message, ]); }); + }; + + globalThis.ip_block_user = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch( + \"/api/v1/auth/user/{{ profile.id }}/block_ip\", + { + method: \"POST\", + }, + ) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); };")))) (text "{%- endif %} {% if not profile.settings.private_communities or is_self or is_helper %}") (div diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 4ac8b4c..1d699a9 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -446,6 +446,30 @@ ("class" "button lowered small") (icon (text "external-link")) (span (str (text "requests:action.view_profile")))))) + (text "{% endfor %}"))) + + ; ip blocks + (div + ("class" "card-nest") + (div + ("class" "card flex items-center gap-2 small") + (text "{{ icon \"wifi\" }}") + (span + (text "{{ text \"settings:label.ips\" }}"))) + (div + ("class" "card flex flex-col gap-2") + (text "{% for ip in ipblocks %}") + (div + ("class" "card secondary flex flex-wrap gap-2 items-center justify-between") + (span + (text "Block from: ") (span ("class" "date") (text "{{ ip.created }}"))) + (div + ("class" "flex gap-2") + (button + ("onclick" "trigger('me::remove_ip_block', ['{{ ip.id }}'])") + ("class" "lowered small red") + (icon (text "x")) + (span (str (text "auth:action.unblock")))))) (text "{% endfor %}"))))) (div ("class" "w-full flex flex-col gap-2 hidden") @@ -1734,6 +1758,35 @@ description: \"Hover state for secondary buttons.\", }, ], + // online indicator + [[], \"\", \"divider\"], + [ + [\"theme_color_online\", \"Online indicator (online)\"], + \"{{ profile.settings.theme_color_online }}\", + \"color\", + { + description: + \"The green dot next to the name of online users.\", + }, + ], + [ + [\"theme_color_idle\", \"Online indicator (idle)\"], + \"{{ profile.settings.theme_color_idle }}\", + \"color\", + { + description: + \"The yellow dot next to the name of online users.\", + }, + ], + [ + [\"theme_color_offline\", \"Online indicator (offline)\"], + \"{{ profile.settings.theme_color_offline }}\", + \"color\", + { + description: + \"The grey next to the name of online users.\", + }, + ], ]; if (can_use_custom_css) { diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 32c5b5f..85c9c2c 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -505,7 +505,7 @@ media_theme_pref(); return now - last_seen <= maximum_time_to_be_considered_idle; }); - self.define("hooks::online_indicator", ({ $ }) => { + self.define("hooks::online_indicator", async ({ $ }) => { for (const element of Array.from( document.querySelectorAll("[hook=online_indicator]") || [], )) { @@ -513,8 +513,8 @@ media_theme_pref(); element.getAttribute("hook-arg:last_seen"), ); - const is_online = $.last_seen_just_now(last_seen); - const is_idle = $.last_seen_recently(last_seen); + const is_online = await $.last_seen_just_now(last_seen); + const is_idle = await $.last_seen_recently(last_seen); const offline = element.querySelector("[hook_ui_ident=offline]"); const online = element.querySelector("[hook_ui_ident=online]"); diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index 15bf7a2..36ff6b1 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -402,6 +402,27 @@ }); }); + self.define("remove_ip_block", async (_, id) => { + if ( + !(await trigger("atto::confirm", [ + "Are you sure you want to do this?", + ])) + ) { + return; + } + + fetch(`/api/v1/auth/ip/${id}/unblock_ip`, { + method: "POST", + }) + .then((res) => res.json()) + .then((res) => { + trigger("atto::toast", [ + res.ok ? "success" : "error", + res.message, + ]); + }); + }); + self.define("notifications_stream", ({ _, streams }) => { const element = document.getElementById("notifications_span"); diff --git a/crates/app/src/routes/api/v1/auth/social.rs b/crates/app/src/routes/api/v1/auth/social.rs index 53aff80..badbbc2 100644 --- a/crates/app/src/routes/api/v1/auth/social.rs +++ b/crates/app/src/routes/api/v1/auth/social.rs @@ -314,3 +314,64 @@ pub async fn following_request( Err(e) => Json(e.into()), } } + +pub async fn ip_block_profile_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateIpBlock) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + // get other user + let other_user = match data.get_user_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + for (ip, _, _) in other_user.tokens { + // check for an existing ip block + if data + .get_ipblock_by_initiator_receiver(user.id, &ip) + .await + .is_ok() + { + continue; + } + + // create ip block + if let Err(e) = data.create_ipblock(IpBlock::new(user.id, ip)).await { + return Json(e.into()); + } + } + + Json(ApiReturn { + ok: true, + message: "IP(s) blocked".to_string(), + payload: (), + }) +} + +pub async fn remove_ip_block_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageBlocks) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_ipblock(id, user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "IP unblocked".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 9f850af..b87dbdc 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -288,6 +288,14 @@ pub fn routes() -> Router { post(auth::social::accept_follow_request), ) .route("/auth/user/{id}/block", post(auth::social::block_request)) + .route( + "/auth/user/{id}/block_ip", + post(auth::social::ip_block_profile_request), + ) + .route( + "/auth/ip/{id}/unblock_ip", + post(auth::social::remove_ip_block_request), + ) .route( "/auth/user/{id}/settings", post(auth::profile::update_user_settings_request), diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index 17d0d1f..ed7adcd 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -94,6 +94,11 @@ pub async fn settings_request( out }; + let ipblocks = match data.0.get_ipblocks_by_initiator(profile.id).await { + Ok(l) => l, + Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), + }; + let uploads = match data.0.get_uploads_by_owner(profile.id, 12, req.page).await { Ok(ua) => ua, Err(e) => { @@ -129,6 +134,7 @@ pub async fn settings_request( context.insert("following", &following); context.insert("blocks", &blocks); context.insert("stackblocks", &stackblocks); + context.insert("ipblocks", &ipblocks); context.insert("invites", &invites); context.insert( "user_tokens_serde", diff --git a/crates/core/src/database/ipblocks.rs b/crates/core/src/database/ipblocks.rs index f94ed51..9eaf892 100644 --- a/crates/core/src/database/ipblocks.rs +++ b/crates/core/src/database/ipblocks.rs @@ -2,7 +2,7 @@ use oiseau::cache::Cache; use crate::model::{Error, Result, auth::User, auth::IpBlock, permissions::FinePermission}; use crate::{auto_method, DataManager}; -use oiseau::PostgresRow; +use oiseau::{query_rows, PostgresRow}; use oiseau::{execute, get, query_row, params}; @@ -19,7 +19,7 @@ impl DataManager { auto_method!(get_ipblock_by_id()@get_ipblock_from_row -> "SELECT * FROM ipblocks WHERE id = $1" --name="ip block" --returns=IpBlock --cache-key-tmpl="atto.ipblock:{}"); - /// Get a user block by `initiator` and `receiver` (in that order). + /// Get a ip block by `initiator` and `receiver` (in that order). pub async fn get_ipblock_by_initiator_receiver( &self, initiator: usize, @@ -38,13 +38,13 @@ impl DataManager { ); if res.is_err() { - return Err(Error::GeneralNotFound("user block".to_string())); + return Err(Error::GeneralNotFound("ip block".to_string())); } Ok(res.unwrap()) } - /// Get a user block by `receiver` and `initiator` (in that order). + /// Get a ip block by `receiver` and `initiator` (in that order). pub async fn get_ipblock_by_receiver_initiator( &self, receiver: &str, @@ -63,13 +63,34 @@ impl DataManager { ); if res.is_err() { - return Err(Error::GeneralNotFound("user block".to_string())); + return Err(Error::GeneralNotFound("ip block".to_string())); } Ok(res.unwrap()) } - /// Create a new user block in the database. + /// Get all ip blocks by `initiator`. + pub async fn get_ipblocks_by_initiator(&self, initiator: usize) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM ipblocks WHERE initiator = $1", + params![&(initiator as i64)], + |x| { Self::get_ipblock_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("ip block".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new ip block in the database. /// /// # Arguments /// * `data` - a mock [`IpBlock`] object to insert @@ -102,7 +123,7 @@ impl DataManager { let block = self.get_ipblock_by_id(id).await?; if user.id != block.initiator { - // only the initiator (or moderators) can delete user blocks! + // only the initiator (or moderators) can delete ip blocks! if !user.permissions.check(FinePermission::MANAGE_FOLLOWS) { return Err(Error::NotAllowed); } diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 054d449..c91db34 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -192,6 +192,15 @@ pub struct UserSettings { /// Custom CSS input. #[serde(default)] pub theme_custom_css: String, + /// The color of an online online indicator. + #[serde(default)] + pub theme_color_online: String, + /// The color of an idle online indicator. + #[serde(default)] + pub theme_color_idle: String, + /// The color of an offline online indicator. + #[serde(default)] + pub theme_color_offline: String, #[serde(default)] pub disable_other_themes: bool, #[serde(default)] From 0272985b81a00c210f8773ccc8fc3f4481857ef5 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 28 Jun 2025 13:33:25 -0400 Subject: [PATCH 04/93] add: put ip block button on blocked page as well --- crates/app/src/public/html/profile/base.lisp | 2 +- .../app/src/public/html/profile/blocked.lisp | 48 ++++++++++++++++--- 2 files changed, 43 insertions(+), 7 deletions(-) diff --git a/crates/app/src/public/html/profile/base.lisp b/crates/app/src/public/html/profile/base.lisp index 481c007..7962728 100644 --- a/crates/app/src/public/html/profile/base.lisp +++ b/crates/app/src/public/html/profile/base.lisp @@ -228,7 +228,7 @@ (icon_class (text "chevron-down") (text "dropdown-arrow")) (str (text "auth:action.block"))) (div - ("class" "inner") + ("class" "inner left") (button ("onclick" "toggle_block_user()") (icon (text "shield")) diff --git a/crates/app/src/public/html/profile/blocked.lisp b/crates/app/src/public/html/profile/blocked.lisp index b1c59c1..1a128fa 100644 --- a/crates/app/src/public/html/profile/blocked.lisp +++ b/crates/app/src/public/html/profile/blocked.lisp @@ -24,12 +24,24 @@ (div ("class" "card w-full secondary flex gap-2") (text "{% if user -%} {% if not is_blocking -%}") - (button - ("onclick" "toggle_block_user()") - ("class" "lowered red") - (text "{{ icon \"shield\" }}") - (span - (text "{{ text \"auth:action.block\" }}"))) + (div + ("class" "dropdown") + (button + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("class" "lowered red") + (icon_class (text "chevron-down") (text "dropdown-arrow")) + (str (text "auth:action.block"))) + (div + ("class" "inner left") + (button + ("onclick" "toggle_block_user()") + (icon (text "shield")) + (str (text "auth:action.block"))) + (button + ("onclick" "ip_block_user()") + (icon (text "wifi")) + (str (text "auth:action.ip_block"))))) (text "{% else %}") (button ("onclick" "toggle_block_user()") @@ -58,6 +70,30 @@ res.message, ]); }); + }; + + globalThis.ip_block_user = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch( + \"/api/v1/auth/user/{{ profile.id }}/block_ip\", + { + method: \"POST\", + }, + ) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); };")) (text "{%- endif %}") (a From 50f4592de278589fdd1823de73911d26e245161b Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 29 Jun 2025 12:26:22 -0400 Subject: [PATCH 05/93] fix: user community membership checks for timelines --- .../src/public/html/timelines/swiss_army.lisp | 10 ++-- crates/app/src/public/js/me.js | 15 +++-- crates/core/src/database/posts.rs | 57 +++++++++++++++++-- crates/core/src/model/communities.rs | 2 +- 4 files changed, 67 insertions(+), 17 deletions(-) diff --git a/crates/app/src/public/html/timelines/swiss_army.lisp b/crates/app/src/public/html/timelines/swiss_army.lisp index 23243ce..535dbe9 100644 --- a/crates/app/src/public/html/timelines/swiss_army.lisp +++ b/crates/app/src/public/html/timelines/swiss_army.lisp @@ -1,11 +1,9 @@ (text "{%- import \"components.html\" as components -%} {%- import \"macros.html\" as macros -%}") (text "{% for post in list %} - {% if post[2].read_access == \"Everybody\" -%} - {% if post[0].context.repost and post[0].context.repost.reposting -%} - {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }} - {% else %} - {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} - {%- endif %} + {% if post[0].context.repost and post[0].context.repost.reposting -%} + {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true) }} + {% else %} + {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], poll=post[5]) }} {%- endif %} {% endfor %}") (datalist diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index 36ff6b1..e346a6b 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -425,6 +425,7 @@ self.define("notifications_stream", ({ _, streams }) => { const element = document.getElementById("notifications_span"); + let current = Number.parseInt(element.innerText || "0"); streams.subscribe("notifs"); streams.event("notifs", "message", (data) => { @@ -435,13 +436,12 @@ const inner_data = JSON.parse(data.data); if (data.method.Packet.Crud === "Create") { - const current = Number.parseInt(element.innerText || "0"); - if (current <= 0) { element.classList.remove("hidden"); } - element.innerText = current + 1; + current += 1; + element.innerText = current; // check if we're already connected const connected = @@ -477,16 +477,19 @@ console.info("notification created"); } } else if (data.method.Packet.Crud === "Delete") { - const current = Number.parseInt(element.innerText || "0"); - if (current - 1 <= 0) { element.classList.add("hidden"); } - element.innerText = current - 1; + current -= 1; + element.innerText = current; } else { console.warn("correct packet type but with wrong data"); } + + if (element.innerText !== current) { + element.innerText = current; + } }); }); diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index c1f1dca..011e653 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -1,8 +1,7 @@ use std::collections::HashMap; -use oiseau::cache::Cache; use crate::config::StringBan; use crate::model::auth::Notification; -use crate::model::communities::{Poll, Question}; +use crate::model::communities::{CommunityMembership, CommunityReadAccess, Poll, Question}; use crate::model::communities_permissions::CommunityPermission; use crate::model::moderation::AuditLogEntry; use crate::model::stacks::{StackMode, StackSort, UserStack}; @@ -16,7 +15,7 @@ use tetratto_shared::unix_epoch_timestamp; use crate::{auto_method, DataManager}; use oiseau::{PostgresRow, cache::redis::Commands}; -use oiseau::{execute, get, query_row, query_rows, params}; +use oiseau::{execute, get, query_row, query_rows, params, cache::Cache}; pub type FullPost = ( Post, @@ -479,6 +478,7 @@ impl DataManager { 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(); + let mut memberships: HashMap = HashMap::new(); for post in posts { if post.is_deleted { @@ -493,6 +493,30 @@ impl DataManager { continue; } + // check membership + if community.read_access == CommunityReadAccess::Joined { + if let Some(user) = user { + if let Some(membership) = memberships.get(&community.id) { + if !membership.role.check(CommunityPermission::MEMBER) { + continue; + } + } else { + if let Ok(membership) = self + .get_membership_by_owner_community_no_void(user.id, community.id) + .await + { + if !membership.role.check(CommunityPermission::MEMBER) { + continue; + } + } else { + continue; + } + } + } else { + continue; + } + } + // stack let (can_view, stack) = self .get_post_stack( @@ -537,6 +561,32 @@ impl DataManager { continue; } + // check membership + let community = self.get_community_by_id(community).await?; + if community.read_access == CommunityReadAccess::Joined { + if let Some(user) = user { + if let Some(membership) = memberships.get(&community.id) { + if !membership.role.check(CommunityPermission::MEMBER) { + continue; + } + } else { + if let Ok(membership) = self + .get_membership_by_owner_community_no_void(user.id, community.id) + .await + { + memberships.insert(owner, membership.clone()); + if !membership.role.check(CommunityPermission::MEMBER) { + continue; + } + } else { + continue; + } + } + } else { + continue; + } + } + // check relationship if ua.settings.private_profile && ua.id != user_id { if user_id == 0 { @@ -587,7 +637,6 @@ impl DataManager { } // ... - let community = self.get_community_by_id(community).await?; seen_before.insert((owner, community.id), (ua.clone(), community.clone())); out.push(( post.clone(), diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index 7b1957f..e2c4261 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -2,7 +2,7 @@ use serde::{Deserialize, Serialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; use super::communities_permissions::CommunityPermission; -#[derive(Clone, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct Community { pub id: usize, pub created: usize, From b501a7c5f06ee2954ad11851110082f0e62d24b4 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 29 Jun 2025 18:38:32 -0400 Subject: [PATCH 06/93] add: 4 more achievements --- crates/app/src/public/css/style.css | 3 ++- crates/app/src/routes/api/v1/auth/profile.rs | 11 ++++++++++- crates/app/src/routes/api/v1/notes.rs | 12 +++++++++++- crates/core/src/database/posts.rs | 13 +++++++++++-- crates/core/src/model/auth.rs | 20 ++++++++++++++++++-- 5 files changed, 52 insertions(+), 7 deletions(-) diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 5533a96..24c41bd 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -582,6 +582,7 @@ input[type="checkbox"]:checked { font-size: 12px; border-radius: 6px; height: max-content; + font-weight: 600; } .notification.tr { @@ -670,7 +671,7 @@ nav .button:not(.title):not(.active):hover { margin-bottom: 0; backdrop-filter: none; bottom: 0; - position: absolute; + position: fixed; height: max-content; top: unset; } diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index 0d2bc49..1977d95 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -464,11 +464,20 @@ pub async fn enable_totp_request( Extension(data): Extension, ) -> impl IntoResponse { let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data) { + let mut user = match get_user_from_token!(jar, data) { Some(ua) => ua, None => return Json(Error::NotAllowed.into()), }; + // award achievement + if let Err(e) = data + .add_achievement(&mut user, AchievementName::Enable2fa.into()) + .await + { + return Json(e.into()); + } + + // ... match data.enable_totp(id, user).await { Ok(x) => Json(ApiReturn { ok: true, diff --git a/crates/app/src/routes/api/v1/notes.rs b/crates/app/src/routes/api/v1/notes.rs index b6bc986..ae67c4d 100644 --- a/crates/app/src/routes/api/v1/notes.rs +++ b/crates/app/src/routes/api/v1/notes.rs @@ -16,6 +16,7 @@ use crate::{ use tetratto_core::{ database::NAME_REGEX, model::{ + auth::AchievementName, journals::{JournalPrivacyPermission, Note}, oauth, permissions::FinePermission, @@ -190,11 +191,20 @@ pub async fn update_content_request( Json(props): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageNotes) { Some(ua) => ua, None => return Json(Error::NotAllowed.into()), }; + // award achievement + if let Err(e) = data + .add_achievement(&mut user, AchievementName::EditNote.into()) + .await + { + return Json(e.into()); + } + + // ... match data.update_note_content(id, &user, &props.content).await { Ok(_) => { if let Err(e) = data diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index 011e653..f25e7ec 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; use crate::config::StringBan; -use crate::model::auth::Notification; +use crate::model::auth::{AchievementName, Notification}; use crate::model::communities::{CommunityMembership, CommunityReadAccess, Poll, Question}; use crate::model::communities_permissions::CommunityPermission; use crate::model::moderation::AuditLogEntry; @@ -1624,6 +1624,8 @@ impl DataManager { self.get_community_by_id(data.community).await? }; + let mut owner = self.get_user_by_id(data.owner).await?; + // check values (if this isn't reposting something else) let is_reposting = if let Some(ref repost) = data.context.repost { repost.reposting.is_some() @@ -1651,6 +1653,10 @@ impl DataManager { } else if data.title.len() > 128 { return Err(Error::DataTooLong("title".to_string())); } + + // award achievement + self.add_achievement(&mut owner, AchievementName::CreatePostWithTitle.into()) + .await?; } } @@ -1663,7 +1669,6 @@ impl DataManager { data.context.is_nsfw = community.context.is_nsfw; // remove request if we were answering a question - let owner = self.get_user_by_id(data.owner).await?; if data.context.answering != 0 { let question = self.get_question_by_id(data.context.answering).await?; @@ -1790,6 +1795,10 @@ impl DataManager { ) .await?; } + + // award achievement + self.add_achievement(&mut owner, AchievementName::CreateRepost.into()) + .await?; } // check if the post we're replying to allows commments diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index c91db34..4d332c7 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -490,7 +490,7 @@ pub struct ExternalConnectionData { } /// The total number of achievements needed to 100% Tetratto! -pub const ACHIEVEMENTS: usize = 24; +pub const ACHIEVEMENTS: usize = 28; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum AchievementName { @@ -518,6 +518,10 @@ pub enum AchievementName { JoinCommunity, CreateDraft, EditPost, + Enable2fa, + EditNote, + CreatePostWithTitle, + CreateRepost, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -554,6 +558,10 @@ impl AchievementName { Self::JoinCommunity => "A sense of community...", Self::CreateDraft => "Maybe later!", Self::EditPost => "Grammar police?", + Self::Enable2fa => "Locked in", + Self::EditNote => "I take it back!", + Self::CreatePostWithTitle => "Must declutter", + Self::CreateRepost => "More than a like or a comment...", } } @@ -575,7 +583,7 @@ impl AchievementName { Self::Get50Likes => "Get 50 likes on one post.", Self::Get100Likes => "Get 100 likes on one post.", Self::Get25Dislikes => "Get 25 dislikes on one post... :(", - Self::Get1Follower => "Get 1 follow. Cool!", + Self::Get1Follower => "Get 1 follower. Cool!", Self::Get10Followers => "Get 10 followers. You're getting popular!", Self::Get50Followers => "Get 50 followers. Okay, you're fairly popular!", Self::Get100Followers => "Get 100 followers. You might be famous..?", @@ -583,6 +591,10 @@ impl AchievementName { Self::JoinCommunity => "Join a community. Welcome!", Self::CreateDraft => "Save a post as a draft.", Self::EditPost => "Edit a post.", + Self::Enable2fa => "Enable TOTP 2FA.", + Self::EditNote => "Edit a note.", + Self::CreatePostWithTitle => "Create a post with a title.", + Self::CreateRepost => "Create a repost or quote.", } } @@ -614,6 +626,10 @@ impl AchievementName { Self::JoinCommunity => Common, Self::CreateDraft => Common, Self::EditPost => Common, + Self::Enable2fa => Rare, + Self::EditNote => Uncommon, + Self::CreatePostWithTitle => Common, + Self::CreateRepost => Common, } } } From 14936b8b909094287ec795057b6e34d544ed77a7 Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 30 Jun 2025 12:20:44 -0400 Subject: [PATCH 07/93] fix: notifs stream reconnection --- Cargo.lock | 46 ++++++++++++------ crates/app/Cargo.toml | 2 +- crates/app/src/public/html/body.lisp | 5 ++ .../app/src/public/html/profile/private.lisp | 48 ++++++++++++++++--- crates/core/Cargo.toml | 2 +- 5 files changed, 81 insertions(+), 22 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index ce61c71..f3f387d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -488,7 +488,7 @@ checksum = "93698b29de5e97ad0ae26447b344c482a7284c737d9ddc5f9e52b74a336671bb" dependencies = [ "chrono", "chrono-tz-build", - "phf", + "phf 0.11.3", ] [[package]] @@ -498,7 +498,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c088aee841df9c3041febbb73934cfc39708749bf96dc827e3359cd39ef11b1" dependencies = [ "parse-zoneinfo", - "phf", + "phf 0.11.3", "phf_codegen", ] @@ -648,7 +648,7 @@ dependencies = [ "cssparser-macros", "dtoa-short", "itoa", - "phf", + "phf 0.11.3", "smallvec", ] @@ -728,11 +728,11 @@ checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" [[package]] name = "emojis" -version = "0.6.4" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "99e1f1df1f181f2539bac8bf027d31ca5ffbf9e559e3f2d09413b9107b5c02f4" +checksum = "0a08afd8e599463c275703532e707c767b8c068a826eea9ca8fceaf3435029df" dependencies = [ - "phf", + "phf 0.12.1", ] [[package]] @@ -2164,7 +2164,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" dependencies = [ "phf_macros", - "phf_shared", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared 0.12.1", ] [[package]] @@ -2174,7 +2183,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.11.3", ] [[package]] @@ -2183,7 +2192,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ - "phf_shared", + "phf_shared 0.11.3", "rand 0.8.5", ] @@ -2194,7 +2203,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.11.3", "proc-macro2", "quote", "syn 2.0.101", @@ -2209,6 +2218,15 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -3067,7 +3085,7 @@ checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" dependencies = [ "new_debug_unreachable", "parking_lot", - "phf_shared", + "phf_shared 0.11.3", "precomputed-hash", "serde", ] @@ -3079,7 +3097,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" dependencies = [ "phf_generator", - "phf_shared", + "phf_shared 0.11.3", "proc-macro2", "quote", ] @@ -3476,7 +3494,7 @@ dependencies = [ "log", "parking_lot", "percent-encoding", - "phf", + "phf 0.11.3", "pin-project-lite", "postgres-protocol", "postgres-types", @@ -4066,7 +4084,7 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "954c5a41f2bcb7314344079d0891505458cc2f4b422bdea1d5bfbe6d1a04903b" dependencies = [ - "phf", + "phf 0.11.3", "phf_codegen", "string_cache", "string_cache_codegen", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 3c66674..8a775e8 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -33,6 +33,6 @@ async-stripe = { version = "0.41.0", features = [ "billing", "runtime-tokio-hyper", ] } -emojis = "0.6.4" +emojis = "0.7.0" webp = "0.3.0" bberry = "0.2.0" diff --git a/crates/app/src/public/html/body.lisp b/crates/app/src/public/html/body.lisp index 227a8f1..afc41b4 100644 --- a/crates/app/src/public/html/body.lisp +++ b/crates/app/src/public/html/body.lisp @@ -101,6 +101,11 @@ } setTimeout(() => { + if (globalThis.notifs_stream_init) { + return; + } + + globalThis.notifs_stream_init = true; trigger(\"me::notifications_stream\"); }, 250); }); diff --git a/crates/app/src/public/html/profile/private.lisp b/crates/app/src/public/html/profile/private.lisp index f9963f6..c5acd7d 100644 --- a/crates/app/src/public/html/profile/private.lisp +++ b/crates/app/src/public/html/profile/private.lisp @@ -47,12 +47,24 @@ (span (text "{{ text \"auth:action.unfollow\" }}"))) (text "{%- endif %} {% if not is_blocking -%}") - (button - ("onclick" "toggle_block_user()") - ("class" "lowered red") - (text "{{ icon \"shield\" }}") - (span - (text "{{ text \"auth:action.block\" }}"))) + (div + ("class" "dropdown") + (button + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("class" "lowered red") + (icon_class (text "chevron-down") (text "dropdown-arrow")) + (str (text "auth:action.block"))) + (div + ("class" "inner left") + (button + ("onclick" "toggle_block_user()") + (icon (text "shield")) + (str (text "auth:action.block"))) + (button + ("onclick" "ip_block_user()") + (icon (text "wifi")) + (str (text "auth:action.ip_block"))))) (text "{% else %}") (button ("onclick" "toggle_block_user()") @@ -151,6 +163,30 @@ res.message, ]); }); + }; + + globalThis.ip_block_user = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch( + \"/api/v1/auth/user/{{ profile.id }}/block_ip\", + { + method: \"POST\", + }, + ) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); };")) (text "{%- endif %}") (a diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index afcecb0..e47db7a 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -17,6 +17,6 @@ async-recursion = "1.1.1" md-5 = "0.10.6" base16ct = { version = "0.2.0", features = ["alloc"] } base64 = "0.22.1" -emojis = "0.6.4" +emojis = "0.7.0" regex = "1.11.1" oiseau = { version = "0.1.2", default-features = false, features = ["postgres", "redis"] } From f5faed77624a7228b57ebb4d180956ae64206a4b Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 30 Jun 2025 15:35:18 -0400 Subject: [PATCH 08/93] add: use RemoteAddr for ip blocks as well --- crates/app/src/macros.rs | 14 ++++-- crates/app/src/routes/api/v1/auth/mod.rs | 4 +- crates/app/src/routes/api/v1/auth/social.rs | 8 +++- .../src/routes/api/v1/communities/posts.rs | 2 +- .../routes/api/v1/communities/questions.rs | 4 +- crates/app/src/routes/api/v1/reactions.rs | 25 ++++++++++- crates/app/src/routes/pages/communities.rs | 45 ++++++++++++++++++- crates/core/src/database/ipbans.rs | 2 +- crates/core/src/database/ipblocks.rs | 12 +++-- crates/core/src/database/questions.rs | 8 +++- crates/core/src/database/reactions.rs | 16 +++++-- crates/core/src/database/userblocks.rs | 15 ++++++- 12 files changed, 127 insertions(+), 28 deletions(-) diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 01406bb..056a803 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -352,7 +352,10 @@ macro_rules! ignore_users_gen { ($user:ident, $data:ident) => { if let Some(ref ua) = $user { [ - $data.0.get_userblocks_receivers(ua.id).await, + $data + .0 + .get_userblocks_receivers(ua.id, &ua.associated) + .await, $data.0.get_userblocks_initiator_by_receivers(ua.id).await, $data.0.get_user_stack_blocked_users(ua.id).await, ] @@ -364,7 +367,10 @@ macro_rules! ignore_users_gen { ($user:ident!, $data:ident) => {{ [ - $data.0.get_userblocks_receivers($user.id).await, + $data + .0 + .get_userblocks_receivers($user.id, &$user.associated) + .await, $data .0 .get_userblocks_initiator_by_receivers($user.id) @@ -376,7 +382,9 @@ macro_rules! ignore_users_gen { ($user:ident!, #$data:ident) => { [ - $data.get_userblocks_receivers($user.id).await, + $data + .get_userblocks_receivers($user.id, &$user.associated) + .await, $data.get_userblocks_initiator_by_receivers($user.id).await, ] .concat() diff --git a/crates/app/src/routes/api/v1/auth/mod.rs b/crates/app/src/routes/api/v1/auth/mod.rs index a332dd8..934f5fc 100644 --- a/crates/app/src/routes/api/v1/auth/mod.rs +++ b/crates/app/src/routes/api/v1/auth/mod.rs @@ -54,7 +54,7 @@ pub async fn register_request( // check for ip ban if data - .get_ipban_by_addr(RemoteAddr::from(real_ip.as_str())) + .get_ipban_by_addr(&RemoteAddr::from(real_ip.as_str())) .await .is_ok() { @@ -189,7 +189,7 @@ pub async fn login_request( // check for ip ban if data - .get_ipban_by_addr(RemoteAddr::from(real_ip.as_str())) + .get_ipban_by_addr(&RemoteAddr::from(real_ip.as_str())) .await .is_ok() { diff --git a/crates/app/src/routes/api/v1/auth/social.rs b/crates/app/src/routes/api/v1/auth/social.rs index badbbc2..730746a 100644 --- a/crates/app/src/routes/api/v1/auth/social.rs +++ b/crates/app/src/routes/api/v1/auth/social.rs @@ -11,6 +11,7 @@ use axum::{ }; use axum_extra::extract::CookieJar; use tetratto_core::model::{ + addr::RemoteAddr, auth::{AchievementName, FollowResult, IpBlock, Notification, UserBlock, UserFollow}, oauth, }; @@ -228,7 +229,10 @@ pub async fn ip_block_request( None => return Json(Error::NotAllowed.into()), }; - if let Ok(ipblock) = data.get_ipblock_by_initiator_receiver(user.id, &ip).await { + if let Ok(ipblock) = data + .get_ipblock_by_initiator_receiver(user.id, &RemoteAddr::from(ip.as_str())) + .await + { // delete match data.delete_ipblock(ipblock.id, user).await { Ok(_) => Json(ApiReturn { @@ -335,7 +339,7 @@ pub async fn ip_block_profile_request( for (ip, _, _) in other_user.tokens { // check for an existing ip block if data - .get_ipblock_by_initiator_receiver(user.id, &ip) + .get_ipblock_by_initiator_receiver(user.id, &RemoteAddr::from(ip.as_str())) .await .is_ok() { diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index 1982d6e..e2e608e 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -67,7 +67,7 @@ pub async fn create_request( // check for ip ban if data - .get_ipban_by_addr(RemoteAddr::from(real_ip.as_str())) + .get_ipban_by_addr(&RemoteAddr::from(real_ip.as_str())) .await .is_ok() { diff --git a/crates/app/src/routes/api/v1/communities/questions.rs b/crates/app/src/routes/api/v1/communities/questions.rs index ec24c08..13566e5 100644 --- a/crates/app/src/routes/api/v1/communities/questions.rs +++ b/crates/app/src/routes/api/v1/communities/questions.rs @@ -43,7 +43,7 @@ pub async fn create_request( // check for ip ban if data - .get_ipban_by_addr(RemoteAddr::from(real_ip.as_str())) + .get_ipban_by_addr(&RemoteAddr::from(real_ip.as_str())) .await .is_ok() { @@ -145,7 +145,7 @@ pub async fn ip_block_request( // check for an existing ip block if data - .get_ipblock_by_initiator_receiver(user.id, &question.ip) + .get_ipblock_by_initiator_receiver(user.id, &RemoteAddr::from(question.ip.as_str())) .await .is_ok() { diff --git a/crates/app/src/routes/api/v1/reactions.rs b/crates/app/src/routes/api/v1/reactions.rs index b3efe52..261a48d 100644 --- a/crates/app/src/routes/api/v1/reactions.rs +++ b/crates/app/src/routes/api/v1/reactions.rs @@ -1,7 +1,12 @@ use crate::{State, get_user_from_token, routes::api::v1::CreateReaction}; -use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use axum::{ + extract::Path, + http::{HeaderMap, HeaderValue}, + response::IntoResponse, + Extension, Json, +}; use axum_extra::extract::CookieJar; -use tetratto_core::model::{oauth, ApiReturn, Error, reactions::Reaction}; +use tetratto_core::model::{addr::RemoteAddr, oauth, reactions::Reaction, ApiReturn, Error}; pub async fn get_request( jar: CookieJar, @@ -26,6 +31,7 @@ pub async fn get_request( pub async fn create_request( jar: CookieJar, + headers: HeaderMap, Extension(data): Extension, Json(req): Json, ) -> impl IntoResponse { @@ -40,6 +46,20 @@ pub async fn create_request( Err(e) => return Json(Error::MiscError(e.to_string()).into()), }; + // get real ip + let real_ip = headers + .get(data.0.0.security.real_ip_header.to_owned()) + .unwrap_or(&HeaderValue::from_static("")) + .to_str() + .unwrap_or("") + .to_string(); + + // check for ip ban + let addr = RemoteAddr::from(real_ip.as_str()); + if data.get_ipban_by_addr(&addr).await.is_ok() { + return Json(Error::NotAllowed.into()); + } + // check for existing reaction if let Ok(r) = data.get_reaction_by_owner_asset(user.id, asset_id).await { match data.delete_reaction(r.id, &user).await { @@ -63,6 +83,7 @@ pub async fn create_request( .create_reaction( Reaction::new(user.id, asset_id, req.asset_type, req.is_like), &user, + &addr, ) .await { diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs index e11b685..30d2ce0 100644 --- a/crates/app/src/routes/pages/communities.rs +++ b/crates/app/src/routes/pages/communities.rs @@ -3,14 +3,16 @@ use crate::{ assets::initial_context, check_user_blocked_or_private, get_lang, get_user_from_token, State, }; use axum::{ - Extension, extract::{Path, Query}, + http::{HeaderMap, HeaderValue}, response::{Html, IntoResponse}, + Extension, }; use axum_extra::extract::CookieJar; use serde::Deserialize; use tera::Context; use tetratto_core::model::{ + addr::RemoteAddr, auth::User, communities::Community, communities_permissions::CommunityPermission, @@ -642,6 +644,7 @@ pub async fn settings_request( /// `/post/{id}` pub async fn post_request( jar: CookieJar, + headers: HeaderMap, Path(id): Path, Query(props): Query, Extension(data): Extension, @@ -751,6 +754,46 @@ pub async fn post_request( check_user_blocked_or_private!(user, owner, data, jar); } + // get real ip + let real_ip = headers + .get(data.0.0.0.security.real_ip_header.to_owned()) + .unwrap_or(&HeaderValue::from_static("")) + .to_str() + .unwrap_or("") + .to_string(); + + // check for ip ban + let addr = RemoteAddr::from(real_ip.as_str()); + if data.0.get_ipban_by_addr(&addr).await.is_ok() { + return Err(Html( + render_error( + Error::GeneralNotFound("post".to_string()), + &jar, + &data, + &user, + ) + .await, + )); + } + + // check for ip block + if data + .0 + .get_ipblock_by_initiator_receiver(post.owner, &addr) + .await + .is_ok() + { + return Err(Html( + render_error( + Error::GeneralNotFound("post".to_string()), + &jar, + &data, + &user, + ) + .await, + )); + } + // check repost let (_, reposting) = data.0.get_post_reposting(&post, &ignore_users, &user).await; diff --git a/crates/core/src/database/ipbans.rs b/crates/core/src/database/ipbans.rs index 550570d..4748424 100644 --- a/crates/core/src/database/ipbans.rs +++ b/crates/core/src/database/ipbans.rs @@ -23,7 +23,7 @@ impl DataManager { /// /// # Arguments /// * `prefix` - pub async fn get_ipban_by_addr(&self, addr: RemoteAddr) -> Result { + pub async fn get_ipban_by_addr(&self, addr: &RemoteAddr) -> Result { let conn = match self.0.connect().await { Ok(c) => c, Err(e) => return Err(Error::DatabaseConnection(e.to_string())), diff --git a/crates/core/src/database/ipblocks.rs b/crates/core/src/database/ipblocks.rs index 9eaf892..c3e92b2 100644 --- a/crates/core/src/database/ipblocks.rs +++ b/crates/core/src/database/ipblocks.rs @@ -1,10 +1,8 @@ use oiseau::cache::Cache; +use crate::model::addr::RemoteAddr; use crate::model::{Error, Result, auth::User, auth::IpBlock, permissions::FinePermission}; use crate::{auto_method, DataManager}; - -use oiseau::{query_rows, PostgresRow}; - -use oiseau::{execute, get, query_row, params}; +use oiseau::{query_rows, PostgresRow, execute, get, query_row, params}; impl DataManager { /// Get an [`IpBlock`] from an SQL row. @@ -23,7 +21,7 @@ impl DataManager { pub async fn get_ipblock_by_initiator_receiver( &self, initiator: usize, - receiver: &str, + receiver: &RemoteAddr, ) -> Result { let conn = match self.0.connect().await { Ok(c) => c, @@ -32,8 +30,8 @@ impl DataManager { let res = query_row!( &conn, - "SELECT * FROM ipblocks WHERE initiator = $1 AND receiver = $2", - params![&(initiator as i64), &receiver], + "SELECT * FROM ipblocks WHERE initiator = $1 AND receiver LIKE $2", + params![&(initiator as i64), &format!("{}%", receiver.prefix(None))], |x| { Ok(Self::get_ipblock_from_row(x)) } ); diff --git a/crates/core/src/database/questions.rs b/crates/core/src/database/questions.rs index a6ca60a..1cee527 100644 --- a/crates/core/src/database/questions.rs +++ b/crates/core/src/database/questions.rs @@ -1,6 +1,7 @@ use std::collections::HashMap; use oiseau::cache::Cache; use tetratto_shared::unix_epoch_timestamp; +use crate::model::addr::RemoteAddr; use crate::model::communities_permissions::CommunityPermission; use crate::model::uploads::{MediaType, MediaUpload}; use crate::model::{ @@ -368,7 +369,10 @@ impl DataManager { // check for ip block if self - .get_ipblock_by_initiator_receiver(receiver.id, &data.ip) + .get_ipblock_by_initiator_receiver( + receiver.id, + &RemoteAddr::from(data.ip.as_str()), + ) .await .is_ok() { @@ -393,7 +397,7 @@ impl DataManager { // check for ip block if self - .get_ipblock_by_initiator_receiver(receiver.id, &data.ip) + .get_ipblock_by_initiator_receiver(receiver.id, &RemoteAddr::from(data.ip.as_str())) .await .is_ok() { diff --git a/crates/core/src/database/reactions.rs b/crates/core/src/database/reactions.rs index 4d15190..0a61261 100644 --- a/crates/core/src/database/reactions.rs +++ b/crates/core/src/database/reactions.rs @@ -1,5 +1,6 @@ use oiseau::cache::Cache; use crate::model::{ + addr::RemoteAddr, auth::{AchievementName, Notification, User}, permissions::FinePermission, reactions::{AssetType, Reaction}, @@ -127,7 +128,12 @@ impl DataManager { /// /// # Arguments /// * `data` - a mock [`Reaction`] object to insert - pub async fn create_reaction(&self, data: Reaction, user: &User) -> Result<()> { + pub async fn create_reaction( + &self, + data: Reaction, + user: &User, + addr: &RemoteAddr, + ) -> Result<()> { let conn = match self.0.connect().await { Ok(c) => c, Err(e) => return Err(Error::DatabaseConnection(e.to_string())), @@ -140,10 +146,14 @@ impl DataManager { .get_userblock_by_initiator_receiver(post.owner, user.id) .await .is_ok() - | self + | (self .get_user_stack_blocked_users(post.owner) .await - .contains(&user.id)) + .contains(&user.id) + | self + .get_ipblock_by_initiator_receiver(post.owner, &addr) + .await + .is_ok())) && !user.permissions.check(FinePermission::MANAGE_POSTS) { return Err(Error::NotAllowed); diff --git a/crates/core/src/database/userblocks.rs b/crates/core/src/database/userblocks.rs index ac95cab..3e9afd5 100644 --- a/crates/core/src/database/userblocks.rs +++ b/crates/core/src/database/userblocks.rs @@ -85,7 +85,18 @@ impl DataManager { } /// Get the receiver of all user blocks for the given `initiator`. - pub async fn get_userblocks_receivers(&self, initiator: usize) -> Vec { + pub async fn get_userblocks_receivers( + &self, + initiator: usize, + associated: &Vec, + ) -> Vec { + let mut associated_str = String::new(); + + for id in associated { + associated_str.push_str(&(" OR initiator = ".to_string() + &id.to_string())); + } + + // ... let conn = match self.0.connect().await { Ok(c) => c, Err(_) => return Vec::new(), @@ -93,7 +104,7 @@ impl DataManager { let res = query_rows!( &conn, - "SELECT * FROM userblocks WHERE initiator = $1", + &format!("SELECT * FROM userblocks WHERE initiator = $1{associated_str}"), &[&(initiator as i64)], |x| { Self::get_userblock_from_row(x) } ); From d6348f7d67fb7c8b2d499dc1ffa69cbb52d9fbc3 Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 30 Jun 2025 16:25:02 -0400 Subject: [PATCH 09/93] fix: force auto_unlist when editing post context --- crates/core/src/database/posts.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index f25e7ec..2c5aed8 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -2272,6 +2272,11 @@ impl DataManager { } } + // auto unlist + if user.settings.auto_unlist { + x.is_nsfw = true; + } + // ... let conn = match self.0.connect().await { Ok(c) => c, From d90b08720a19d8adaf66381f397aa0ab7d39bfec Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 30 Jun 2025 18:10:00 -0400 Subject: [PATCH 10/93] add: move new block feature to a setting --- crates/app/src/macros.rs | 18 +++++++++++++++--- .../app/src/public/html/profile/settings.lisp | 8 ++++++++ crates/core/src/database/userblocks.rs | 7 +++++-- crates/core/src/model/auth.rs | 5 ++++- 4 files changed, 32 insertions(+), 6 deletions(-) diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 056a803..2787330 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -354,7 +354,11 @@ macro_rules! ignore_users_gen { [ $data .0 - .get_userblocks_receivers(ua.id, &ua.associated) + .get_userblocks_receivers( + ua.id, + &ua.associated, + ua.settings.hide_associated_blocked_users, + ) .await, $data.0.get_userblocks_initiator_by_receivers(ua.id).await, $data.0.get_user_stack_blocked_users(ua.id).await, @@ -369,7 +373,11 @@ macro_rules! ignore_users_gen { [ $data .0 - .get_userblocks_receivers($user.id, &$user.associated) + .get_userblocks_receivers( + $user.id, + &$user.associated, + $user.settings.hide_associated_blocked_users, + ) .await, $data .0 @@ -383,7 +391,11 @@ macro_rules! ignore_users_gen { ($user:ident!, #$data:ident) => { [ $data - .get_userblocks_receivers($user.id, &$user.associated) + .get_userblocks_receivers( + $user.id, + &$user.associated, + $user.settings.hide_associated_blocked_users, + ) .await, $data.get_userblocks_initiator_by_receivers($user.id).await, ] diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 1d699a9..b191b8a 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -1519,6 +1519,14 @@ \"{{ profile.settings.all_timeline_hide_answers }}\", \"checkbox\", ], + [ + [ + \"hide_associated_blocked_users\", + \"Hide users that you've blocked on your other accounts from timelines.\", + ], + \"{{ profile.settings.hide_associated_blocked_users }}\", + \"checkbox\", + ], [[], \"Questions\", \"title\"], [ [ diff --git a/crates/core/src/database/userblocks.rs b/crates/core/src/database/userblocks.rs index 3e9afd5..3cdaaf9 100644 --- a/crates/core/src/database/userblocks.rs +++ b/crates/core/src/database/userblocks.rs @@ -89,11 +89,14 @@ impl DataManager { &self, initiator: usize, associated: &Vec, + do_associated: bool, ) -> Vec { let mut associated_str = String::new(); - for id in associated { - associated_str.push_str(&(" OR initiator = ".to_string() + &id.to_string())); + if do_associated { + for id in associated { + associated_str.push_str(&(" OR initiator = ".to_string() + &id.to_string())); + } } // ... diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 4d332c7..c2c68c1 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -273,6 +273,9 @@ pub struct UserSettings { /// Disable achievements. #[serde(default)] pub disable_achievements: bool, + /// Automatically hide users that you've blocked on your other accounts from your timelines. + #[serde(default)] + pub hide_associated_blocked_users: bool, } fn mime_avif() -> String { @@ -561,7 +564,7 @@ impl AchievementName { Self::Enable2fa => "Locked in", Self::EditNote => "I take it back!", Self::CreatePostWithTitle => "Must declutter", - Self::CreateRepost => "More than a like or a comment...", + Self::CreateRepost => "More than a like or comment...", } } From 973373426aed534afc79e8d089e20df2ad6d3133 Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 30 Jun 2025 18:49:41 -0400 Subject: [PATCH 11/93] add: policy achievements --- crates/app/src/public/html/components.lisp | 10 +++--- .../app/src/public/html/profile/settings.lisp | 2 +- crates/app/src/public/js/me.js | 21 +++++++++++ crates/app/src/routes/api/v1/auth/profile.rs | 35 +++++++++++++++++-- crates/app/src/routes/api/v1/mod.rs | 11 ++++++ crates/core/src/model/auth.rs | 13 ++++++- example/tetratto.toml | 4 +-- 7 files changed, 83 insertions(+), 13 deletions(-) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index ba7f4e3..e863b65 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -1135,15 +1135,13 @@ (icon (text "rabbit")) (str (text "general:link.reference"))) - (a - ("href" "{{ config.policies.terms_of_service }}") - ("class" "button") + (button + ("onclick" "trigger('me::achievement', ['OpenTos']); Turbo.visit('{{ config.policies.terms_of_service }}')") (icon (text "heart-handshake")) (text "Terms of service")) - (a - ("href" "{{ config.policies.privacy }}") - ("class" "button") + (button + ("onclick" "trigger('me::achievement', ['OpenPrivacyPolicy']); Turbo.visit('{{ config.policies.privacy }}')") (icon (text "cookie")) (text "Privacy policy")) (b ("class" "title") (str (text "general:label.account"))) diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index b191b8a..dce30d2 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -1522,7 +1522,7 @@ [ [ \"hide_associated_blocked_users\", - \"Hide users that you've blocked on your other accounts from timelines.\", + \"Hide users that you've blocked on your other accounts from timelines\", ], \"{{ profile.settings.hide_associated_blocked_users }}\", \"checkbox\", diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index e346a6b..0c7ac10 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -342,6 +342,27 @@ }, ); + self.define("achievement", async (_, name) => { + fetch("/api/v1/auth/user/me/achievement", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name, + }), + }) + .then((res) => res.json()) + .then((res) => { + if (!res.ok) { + trigger("atto::toast", [ + res.ok ? "success" : "error", + res.message, + ]); + } + }); + }); + self.define("report", (_, asset, asset_type) => { window.open( `/mod_panel/file_report?asset=${asset}&asset_type=${asset_type}`, diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index 1977d95..f66e9bf 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -3,8 +3,9 @@ use crate::{ get_user_from_token, model::{ApiReturn, Error}, routes::api::v1::{ - AppendAssociations, DeleteUser, DisableTotp, RefreshGrantToken, UpdateSecondaryUserRole, - UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, UpdateUserUsername, + AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken, + UpdateSecondaryUserRole, UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, + UpdateUserUsername, }, State, }; @@ -21,7 +22,7 @@ use futures_util::{sink::SinkExt, stream::StreamExt}; use tetratto_core::{ cache::Cache, model::{ - auth::{AchievementName, InviteCode, Token, UserSettings}, + auth::{AchievementName, InviteCode, Token, UserSettings, SELF_SERVE_ACHIEVEMENTS}, moderation::AuditLogEntry, oauth, permissions::FinePermission, @@ -920,3 +921,31 @@ pub async fn generate_invite_codes_request( payload: Some((out_string, errors_string)), }) } + +/// Award an achievement to the current user. +/// Only works with specific "self-serve" achievements. +pub async fn self_serve_achievement_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if !SELF_SERVE_ACHIEVEMENTS.contains(&req.name) { + return Json(Error::MiscError("Cannot grant this achievement manually".to_string()).into()); + } + + // award achievement + match data.add_achievement(&mut user, req.name.into()).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Achievement granted".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 b87dbdc..3cd4423 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -19,6 +19,7 @@ use axum::{ use serde::Deserialize; use tetratto_core::model::{ apps::AppQuota, + auth::AchievementName, communities::{ CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess, PollOption, PostContext, @@ -387,6 +388,10 @@ pub fn routes() -> Router { "/auth/user/{id}/grants/{app}/refresh", post(auth::profile::refresh_grant_request), ) + .route( + "/auth/user/me/achievement", + post(auth::profile::self_serve_achievement_request), + ) // apps .route("/apps", post(apps::create_request)) .route("/apps/{id}/title", post(apps::update_title_request)) @@ -976,7 +981,13 @@ pub struct AddJournalDir { pub struct RemoveJournalDir { pub dir: String, } + #[derive(Deserialize)] pub struct UpdateNoteTags { pub tags: Vec, } + +#[derive(Deserialize)] +pub struct AwardAchievement { + pub name: AchievementName, +} diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index c2c68c1..bac4ae6 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -493,7 +493,10 @@ pub struct ExternalConnectionData { } /// The total number of achievements needed to 100% Tetratto! -pub const ACHIEVEMENTS: usize = 28; +pub const ACHIEVEMENTS: usize = 30; +/// "self-serve" achievements can be granted by the user through the API. +pub const SELF_SERVE_ACHIEVEMENTS: &[AchievementName] = + &[AchievementName::OpenTos, AchievementName::OpenPrivacyPolicy]; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum AchievementName { @@ -525,6 +528,8 @@ pub enum AchievementName { EditNote, CreatePostWithTitle, CreateRepost, + OpenTos, + OpenPrivacyPolicy, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -565,6 +570,8 @@ impl AchievementName { Self::EditNote => "I take it back!", Self::CreatePostWithTitle => "Must declutter", Self::CreateRepost => "More than a like or comment...", + Self::OpenTos => "Well informed!", + Self::OpenPrivacyPolicy => "Privacy conscious", } } @@ -598,6 +605,8 @@ impl AchievementName { Self::EditNote => "Edit a note.", Self::CreatePostWithTitle => "Create a post with a title.", Self::CreateRepost => "Create a repost or quote.", + Self::OpenTos => "Open the terms of service.", + Self::OpenPrivacyPolicy => "Open the privacy policy.", } } @@ -633,6 +642,8 @@ impl AchievementName { Self::EditNote => Uncommon, Self::CreatePostWithTitle => Common, Self::CreateRepost => Common, + Self::OpenTos => Uncommon, + Self::OpenPrivacyPolicy => Uncommon, } } } diff --git a/example/tetratto.toml b/example/tetratto.toml index 488bc89..0f36100 100644 --- a/example/tetratto.toml +++ b/example/tetratto.toml @@ -38,8 +38,8 @@ user = "user" password = "postgres" [policies] -terms_of_service = "/public/tos.html" -privacy = "/public/privacy.html" +terms_of_service = "/doc/tos.md" +privacy = "/doc/privacy.md" [turnstile] site_key = "1x00000000000000000000AA" From 063481927882de7bcfa3e3c433e6badf1b29fc9d Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 1 Jul 2025 14:50:19 -0400 Subject: [PATCH 12/93] add: ability to mask your account when creating a question --- crates/app/src/langs/en-US.toml | 1 + crates/app/src/public/html/components.lisp | 117 +++++++++++------- crates/app/src/public/html/profile/media.lisp | 2 +- .../app/src/public/html/profile/outbox.lisp | 2 +- crates/app/src/public/html/profile/posts.lisp | 2 +- .../app/src/public/html/profile/replies.lisp | 2 +- crates/app/src/public/js/atto.js | 22 ++-- crates/app/src/public/js/me.js | 47 ++++--- .../routes/api/v1/communities/questions.rs | 4 + crates/app/src/routes/api/v1/mod.rs | 2 + crates/core/src/model/communities.rs | 3 + 11 files changed, 125 insertions(+), 79 deletions(-) diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index fe07c8b..f854057 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -43,6 +43,7 @@ version = "1.0.0" "general:label.could_not_find_post" = "Could not find original post..." "general:label.timeline_end" = "That's a wrap!" "general:label.loading" = "Working on it!" +"general:label.send_anonymously" = "Send anonymously" "general:label.supporter_motivation" = "Become a supporter!" "general:action.become_supporter" = "Become supporter" diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index e863b65..d9001c9 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -627,7 +627,7 @@ (text "{%- endif %} {%- endmacro %} {% macro question(question, owner, show_community=true, secondary=false, profile=false) -%}") (div ("class" "card question {% if secondary -%}secondary{%- endif %} flex gap-2") - (text "{% if owner.id == 0 -%}") + (text "{% if owner.id == 0 or question.context.mask_owner -%}") (span (text "{% if profile and profile.settings.anonymous_avatar_url -%}") (img @@ -636,7 +636,7 @@ ("class" "avatar shadow") ("loading" "lazy") ("style" "--size: 52px")) - (text "{% else %} {{ self::avatar(username=owner.username, selector_type=\"username\", size=\"52px\") }} {%- endif %}")) + (text "{% else %} {{ self::avatar(username=\"anonymous\", selector_type=\"username\", size=\"52px\") }} {%- endif %}")) (text "{% else %}") (a ("href" "/@{{ owner.username }}") @@ -648,7 +648,7 @@ ("class" "flex items-center gap-2 flex-wrap") (span ("class" "name") - (text "{% if owner.id == 0 -%} {% if profile and profile.settings.anonymous_username -%}") + (text "{% if owner.id == 0 or question.context.mask_owner -%} {% if profile and profile.settings.anonymous_username -%}") (span ("class" "flex items-center gap-2") (b @@ -696,7 +696,7 @@ (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 %}") + (text "{% if is_helper and (owner.id == 0 or question.context.mask_owner) -%}") (details ("class" "card tiny lowered w-full") (summary @@ -705,12 +705,22 @@ (span (text "View IP"))) (pre (code (text "{{ question.ip }}")))) - (text "{% endif %}") + + (text "{% if question.context.mask_owner -%}") + (details + ("class" "card tiny lowered w-full") + (summary + ("class" "w-full flex gap-2 flex-wrap items-center") + (icon (text "venetian-mask")) + (span (text "Unmask"))) + + (text "{{ self::full_username(user=owner) }}")) + (text "{%- endif %} {%- endif %}") ; ... (div ("class" "flex gap-2 items-center justify-between")))) -(text "{%- endmacro %} {% macro create_question_form(receiver=\"0\", community=\"\", header=\"\", is_global=false, drawing_enabled=false) -%}") +(text "{%- endmacro %} {% macro create_question_form(receiver=\"0\", community=\"\", header=\"\", is_global=false, drawing_enabled=false, allow_anonymous=false) -%}") (div ("class" "card-nest") (div @@ -742,50 +752,66 @@ ("minlength" "2") ("maxlength" "4096"))) (div - ("class" "flex gap-2") - (button - ("class" "primary") - (text "{{ text \"communities:action.create\" }}")) + ("class" "flex w-full justify-between gap-2 flex-collapse") + (div + ("class" "flex gap-2") + (button + ("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\" }}")) + (text "{% if drawing_enabled -%}") + (button + ("class" "lowered") + ("ui_ident" "add_drawing") + ("onclick" "attach_drawing()") + ("type" "button") + (text "{{ text \"communities:action.draw\" }}")) - (button - ("class" "lowered red hidden") - ("ui_ident" "remove_drawing") - ("onclick" "remove_drawing()") - ("type" "button") - (text "{{ text \"communities:action.remove_drawing\" }}")) + (button + ("class" "lowered red hidden") + ("ui_ident" "remove_drawing") + ("onclick" "remove_drawing()") + ("type" "button") + (text "{{ text \"communities:action.remove_drawing\" }}")) - (script - (text "globalThis.attach_drawing = async () => { - globalThis.gerald = await trigger(\"carp::new\", [document.querySelector(\"[ui_ident=carp_canvas_field]\")]); - globalThis.gerald.create_canvas(); + (script + (text "globalThis.attach_drawing = async () => { + globalThis.gerald = await trigger(\"carp::new\", [document.querySelector(\"[ui_ident=carp_canvas_field]\")]); + globalThis.gerald.create_canvas(); - document.querySelector(\"[ui_ident=add_drawing]\").classList.add(\"hidden\"); - document.querySelector(\"[ui_ident=remove_drawing]\").classList.remove(\"hidden\"); - } - - globalThis.remove_drawing = async () => { - if ( - !(await trigger(\"atto::confirm\", [ - \"Are you sure you would like to do this?\", - ])) - ) { - return; + document.querySelector(\"[ui_ident=add_drawing]\").classList.add(\"hidden\"); + document.querySelector(\"[ui_ident=remove_drawing]\").classList.remove(\"hidden\"); } - document.querySelector(\"[ui_ident=carp_canvas_field]\").innerHTML = \"\"; - globalThis.gerald = null; + globalThis.remove_drawing = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } - document.querySelector(\"[ui_ident=add_drawing]\").classList.remove(\"hidden\"); - document.querySelector(\"[ui_ident=remove_drawing]\").classList.add(\"hidden\"); - }")) + document.querySelector(\"[ui_ident=carp_canvas_field]\").innerHTML = \"\"; + globalThis.gerald = null; + + document.querySelector(\"[ui_ident=add_drawing]\").classList.remove(\"hidden\"); + document.querySelector(\"[ui_ident=remove_drawing]\").classList.add(\"hidden\"); + }")) + (text "{%- endif %}")) + + (text "{% if not is_global and allow_anonymous -%}") + (div + ("class" "flex gap-2 items-center") + (input + ("type" "checkbox") + ("name" "mask_owner") + ("id" "mask_owner") + ("class" "w-content")) + + (label + ("for" "mask_owner") + (b (str (text "general:label.send_anonymously"))))) (text "{%- endif %}")))) (script @@ -811,6 +837,7 @@ receiver: \"{{ receiver }}\", community: \"{{ community }}\", is_global: \"{{ is_global }}\" == \"true\", + mask_owner: (e.target.mask_owner || { checked:false }).checked }), ); @@ -1136,12 +1163,12 @@ (str (text "general:link.reference"))) (button - ("onclick" "trigger('me::achievement', ['OpenTos']); Turbo.visit('{{ config.policies.terms_of_service }}')") + ("onclick" "trigger('me::achievement_link', ['OpenTos', '{{ config.policies.terms_of_service }}'])") (icon (text "heart-handshake")) (text "Terms of service")) (button - ("onclick" "trigger('me::achievement', ['OpenPrivacyPolicy']); Turbo.visit('{{ config.policies.privacy }}')") + ("onclick" "trigger('me::achievement_link', ['OpenPrivacyPolicy', '{{ config.policies.privacy }}'])") (icon (text "cookie")) (text "Privacy policy")) (b ("class" "title") (str (text "general:label.account"))) diff --git a/crates/app/src/public/html/profile/media.lisp b/crates/app/src/public/html/profile/media.lisp index a3888dc..b4bf80c 100644 --- a/crates/app/src/public/html/profile/media.lisp +++ b/crates/app/src/public/html/profile/media.lisp @@ -1,7 +1,7 @@ (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}") (div ("style" "display: contents") - (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}")) + (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}")) (text "{%- endif %} {{ macros::profile_nav(selected=\"media\") }}") (div diff --git a/crates/app/src/public/html/profile/outbox.lisp b/crates/app/src/public/html/profile/outbox.lisp index d77d314..7dbcabb 100644 --- a/crates/app/src/public/html/profile/outbox.lisp +++ b/crates/app/src/public/html/profile/outbox.lisp @@ -1,7 +1,7 @@ (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}") (div ("style" "display: contents") - (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}")) + (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}")) (text "{%- endif %} {{ macros::profile_nav(selected=\"outbox\") }}") (div diff --git a/crates/app/src/public/html/profile/posts.lisp b/crates/app/src/public/html/profile/posts.lisp index 6120fea..183aeca 100644 --- a/crates/app/src/public/html/profile/posts.lisp +++ b/crates/app/src/public/html/profile/posts.lisp @@ -1,7 +1,7 @@ (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}") (div ("style" "display: contents") - (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}")) + (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}")) (text "{%- endif %} {% if not tag and pinned|length != 0 -%}") (div diff --git a/crates/app/src/public/html/profile/replies.lisp b/crates/app/src/public/html/profile/replies.lisp index 4afe348..5f70d68 100644 --- a/crates/app/src/public/html/profile/replies.lisp +++ b/crates/app/src/public/html/profile/replies.lisp @@ -1,7 +1,7 @@ (text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}") (div ("style" "display: contents") - (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings) }}")) + (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}")) (text "{%- endif %} {{ macros::profile_nav(selected=\"replies\") }}") (div diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 85c9c2c..30d29bd 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -917,18 +917,18 @@ media_theme_pref(); if (option.input_element_type === "checkbox") { into_element.innerHTML += `
- + - -
`; + + `; return; } diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index 0c7ac10..4fd2150 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -342,25 +342,34 @@ }, ); - self.define("achievement", async (_, name) => { - fetch("/api/v1/auth/user/me/achievement", { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify({ - name, - }), - }) - .then((res) => res.json()) - .then((res) => { - if (!res.ok) { - trigger("atto::toast", [ - res.ok ? "success" : "error", - res.message, - ]); - } - }); + self.define("achievement", (_, name) => { + return new Promise((resolve) => { + fetch("/api/v1/auth/user/me/achievement", { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + name, + }), + }) + .then((res) => res.json()) + .then((res) => { + if (!res.ok) { + trigger("atto::toast", [ + res.ok ? "success" : "error", + res.message, + ]); + } + + resolve(); + }); + }); + }); + + self.define("achievement_link", async (_, name, href) => { + await self.achievement(name); + Turbo.visit(href); }); self.define("report", (_, asset, asset_type) => { diff --git a/crates/app/src/routes/api/v1/communities/questions.rs b/crates/app/src/routes/api/v1/communities/questions.rs index 13566e5..631e5c0 100644 --- a/crates/app/src/routes/api/v1/communities/questions.rs +++ b/crates/app/src/routes/api/v1/communities/questions.rs @@ -92,6 +92,10 @@ pub async fn create_request( } } + if req.mask_owner && !req.is_global { + props.context.mask_owner = true; + } + match data .create_question(props, drawings.iter().map(|x| x.to_vec()).collect()) .await diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 3cd4423..c88b003 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -790,6 +790,8 @@ pub struct CreateQuestion { pub receiver: String, #[serde(default)] pub community: String, + #[serde(default)] + pub mask_owner: bool, } #[derive(Deserialize)] diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index e2c4261..4df9795 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -381,6 +381,9 @@ impl Question { pub struct QuestionContext { #[serde(default)] pub is_nsfw: bool, + /// If the owner is shown as anonymous in the UI. + #[serde(default)] + pub mask_owner: bool, } #[derive(Clone, Debug, Serialize, Deserialize)] From c83d0a9fc009e888b7243692e77b756a2a92fbe8 Mon Sep 17 00:00:00 2001 From: trisua Date: Wed, 2 Jul 2025 17:08:40 -0400 Subject: [PATCH 13/93] add: layouts types --- crates/core/src/database/auth.rs | 2 + crates/core/src/model/auth.rs | 8 + crates/core/src/model/layouts.rs | 386 +++++++++++++++++++++++++++++++ crates/core/src/model/mod.rs | 1 + sql_changes/users_layouts.sql | 2 + 5 files changed, 399 insertions(+) create mode 100644 crates/core/src/model/layouts.rs create mode 100644 sql_changes/users_layouts.sql diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index f6fb848..4038fb9 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -112,6 +112,7 @@ impl DataManager { invite_code: get!(x->21(i64)) as usize, secondary_permissions: SecondaryPermission::from_bits(get!(x->22(i32)) as u32).unwrap(), achievements: serde_json::from_str(&get!(x->23(String)).to_string()).unwrap(), + layouts: serde_json::from_str(&get!(x->24(String)).to_string()).unwrap(), } } @@ -293,6 +294,7 @@ impl DataManager { &(data.invite_code as i64), &(SecondaryPermission::DEFAULT.bits() as i32), &serde_json::to_string(&data.achievements).unwrap(), + &serde_json::to_string(&data.layouts).unwrap(), ] ); diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index bac4ae6..8c0e761 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -1,5 +1,7 @@ use std::collections::HashMap; +use crate::model::layouts::CustomizablePage; + use super::{ oauth::AuthGrant, permissions::{FinePermission, SecondaryPermission}, @@ -61,6 +63,11 @@ pub struct User { /// Users collect achievements through little actions across the site. #[serde(default)] pub achievements: Vec, + /// The ID of each layout the user is using. + /// + /// Only applies if the user is a supporter. + #[serde(default)] + pub layouts: HashMap, } pub type UserConnections = @@ -319,6 +326,7 @@ impl User { invite_code: 0, secondary_permissions: SecondaryPermission::DEFAULT, achievements: Vec::new(), + layouts: HashMap::new(), } } diff --git a/crates/core/src/model/layouts.rs b/crates/core/src/model/layouts.rs new file mode 100644 index 0000000..7254d0a --- /dev/null +++ b/crates/core/src/model/layouts.rs @@ -0,0 +1,386 @@ +use std::{collections::HashMap, fmt::Display}; +use serde::{Deserialize, Serialize}; +use crate::model::auth::DefaultTimelineChoice; + +/// Each different page which can be customized. +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] +pub enum CustomizablePage { + Home, + All, + Popular, +} + +/// Layouts allow you to customize almost every page in the Tetratto UI through +/// simple blocks. +#[derive(Serialize, Deserialize)] +pub struct Layout { + pub id: usize, + pub created: usize, + pub owner: usize, + pub title: String, + pub privacy: LayoutPrivacy, + pub pages: Vec, +} + +/// The privacy of the layout, which controls who has the ability to view it. +#[derive(Serialize, Deserialize)] +pub enum LayoutPrivacy { + Public, + Private, +} + +impl Display for Layout { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut out = String::new(); + + for (i, page) in self.pages.iter().enumerate() { + let mut x = page.to_string(); + + if i == 0 { + x = x.replace("%?%", ""); + } else { + x = x.replace("%?%", "hidden"); + } + + out.push_str(&x); + } + + f.write_str(&out) + } +} + +/// Layouts are able to contain subpages within them. +/// +/// Each layout is only allowed 2 subpages pages, meaning one main page and one extra. +#[derive(Serialize, Deserialize)] +pub struct LayoutPage { + pub name: String, + pub blocks: Vec, + pub css: String, + pub js: String, +} + +impl Display for LayoutPage { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&format!( + "
{}
", + { + let mut out = String::new(); + + for block in &self.blocks { + out.push_str(&block.to_string()); + } + + out + }, + self.css, + self.js + )) + } +} + +/// Blocks are the basis of each layout page. They are simple and composable. +#[derive(Serialize, Deserialize)] +pub struct LayoutBlock { + pub r#type: BlockType, + pub children: Vec, +} + +impl LayoutBlock { + pub fn render_children(&self) -> String { + let mut out = String::new(); + + for child in &self.children { + out.push_str(&child.to_string()); + } + + out + } +} + +impl Display for LayoutBlock { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let mut out = String::new(); + + // head + out.push_str(&match self.r#type { + BlockType::Block(ref x) => format!("<{} {}>", x.element, x), + BlockType::Flexible(ref x) => format!("<{} {}>", x.element, x), + BlockType::Markdown(ref x) => format!("<{} {}>", x.element, x), + BlockType::Timeline(ref x) => format!("<{} {}>", x.element, x), + }); + + // body + out.push_str(&match self.r#type { + BlockType::Block(_) => self.render_children(), + BlockType::Flexible(_) => self.render_children(), + BlockType::Markdown(ref x) => x.sub_options.content.to_string(), + BlockType::Timeline(ref x) => { + format!( + "
", + x.sub_options.timeline + ) + } + }); + + // tail + out.push_str(&self.r#type.unwrap_cloned().element.tail()); + + // ... + f.write_str(&out) + } +} + +/// Each different type of block has different attributes associated with it. +#[derive(Serialize, Deserialize)] +pub enum BlockType { + Block(GeneralBlockOptions), + Flexible(GeneralBlockOptions), + Markdown(GeneralBlockOptions), + Timeline(GeneralBlockOptions), +} + +impl BlockType { + pub fn unwrap(self) -> GeneralBlockOptions> { + match self { + Self::Block(x) => x.boxed(), + Self::Flexible(x) => x.boxed(), + Self::Markdown(x) => x.boxed(), + Self::Timeline(x) => x.boxed(), + } + } + + pub fn unwrap_cloned(&self) -> GeneralBlockOptions> { + match self { + Self::Block(x) => x.boxed_cloned::(), + Self::Flexible(x) => x.boxed_cloned::(), + Self::Markdown(x) => x.boxed_cloned::(), + Self::Timeline(x) => x.boxed_cloned::(), + } + } +} + +#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum HtmlElement { + Div, + Span, + Italics, + Bold, + Heading1, + Heading2, + Heading3, + Heading4, + Heading5, + Heading6, + Image, +} + +impl HtmlElement { + pub fn tail(&self) -> String { + match self { + Self::Image => String::new(), + _ => format!(""), + } + } +} + +impl Display for HtmlElement { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Div => "div", + Self::Span => "span", + Self::Italics => "i", + Self::Bold => "b", + Self::Heading1 => "h1", + Self::Heading2 => "h2", + Self::Heading3 => "h3", + Self::Heading4 => "h4", + Self::Heading5 => "h5", + Self::Heading6 => "h6", + Self::Image => "img", + }) + } +} + +/// This trait is used to provide cloning capabilities to structs which DO implement +/// clone, but we aren't allowed to tell the compiler that they implement clone +/// (through a trait bound), as Clone is not dyn compatible. +/// +/// Implementations for this trait should really just take reference to another +/// value (T), then just run `.to_owned()` on it. This means T and F (Self) MUST +/// be the same type. +pub trait RefFrom { + fn ref_from(value: &T) -> Self; +} + +#[derive(Serialize, Deserialize)] +pub struct GeneralBlockOptions +where + T: Display, +{ + pub element: HtmlElement, + pub class_list: String, + pub id: String, + pub attributes: HashMap, + pub sub_options: T, +} + +impl GeneralBlockOptions { + pub fn boxed(self) -> GeneralBlockOptions> { + GeneralBlockOptions { + element: self.element, + class_list: self.class_list, + id: self.id, + attributes: self.attributes, + sub_options: Box::new(self.sub_options), + } + } + + pub fn boxed_cloned + 'static>( + &self, + ) -> GeneralBlockOptions> { + let x: F = F::ref_from(&self.sub_options); + GeneralBlockOptions { + element: self.element.clone(), + class_list: self.class_list.clone(), + id: self.id.clone(), + attributes: self.attributes.clone(), + sub_options: Box::new(x), + } + } +} + +impl Display for GeneralBlockOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&format!( + "class=\"{} {}\" {} id={} {}", + self.class_list, + self.sub_options.to_string(), + { + let mut attrs = String::new(); + + for (k, v) in &self.attributes { + attrs.push_str(&format!("{k}=\"{v}\"")); + } + + attrs + }, + self.id, + if self.element == HtmlElement::Image { + "/" + } else { + "" + } + )) + } +} +#[derive(Clone, Serialize, Deserialize)] +pub struct EmptyBlockOptions; + +impl Display for EmptyBlockOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("") + } +} + +impl RefFrom for EmptyBlockOptions { + fn ref_from(value: &EmptyBlockOptions) -> Self { + value.to_owned() + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct FlexibleBlockOptions { + pub gap: FlexibleBlockGap, + pub direction: FlexibleBlockDirection, + pub wrap: bool, + pub collapse: bool, +} + +impl Display for FlexibleBlockOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&format!( + "flex {} {} {} {}", + self.gap, + self.direction, + if self.wrap { "flex-wrap" } else { "" }, + if self.collapse { "flex-collapse" } else { "" } + )) + } +} + +impl RefFrom for FlexibleBlockOptions { + fn ref_from(value: &FlexibleBlockOptions) -> Self { + value.to_owned() + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub enum FlexibleBlockGap { + Tight, + Comfortable, + Spacious, + Large, +} + +impl Display for FlexibleBlockGap { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Tight => "gap-1", + Self::Comfortable => "gap-2", + Self::Spacious => "gap-3", + Self::Large => "gap-4", + }) + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub enum FlexibleBlockDirection { + Row, + Column, +} + +impl Display for FlexibleBlockDirection { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Row => "flex-row", + Self::Column => "flex-col", + }) + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct MarkdownBlockOptions { + pub content: String, +} + +impl Display for MarkdownBlockOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("") + } +} + +impl RefFrom for MarkdownBlockOptions { + fn ref_from(value: &MarkdownBlockOptions) -> Self { + value.to_owned() + } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct TimelineBlockOptions { + pub timeline: DefaultTimelineChoice, +} + +impl Display for TimelineBlockOptions { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str("w-full flex flex-col gap-2\" ui_ident=\"io_data_load") + } +} + +impl RefFrom for TimelineBlockOptions { + fn ref_from(value: &TimelineBlockOptions) -> Self { + value.to_owned() + } +} diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index 839310f..3ff8379 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -6,6 +6,7 @@ pub mod channels; pub mod communities; pub mod communities_permissions; pub mod journals; +pub mod layouts; pub mod moderation; pub mod oauth; pub mod permissions; diff --git a/sql_changes/users_layouts.sql b/sql_changes/users_layouts.sql new file mode 100644 index 0000000..0d8e489 --- /dev/null +++ b/sql_changes/users_layouts.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN layouts TEXT NOT NULL DEFAULT '{}'; From b493b2ade8e4c957ab6835b7435470fef9694dd9 Mon Sep 17 00:00:00 2001 From: trisua Date: Wed, 2 Jul 2025 20:14:04 -0400 Subject: [PATCH 14/93] add: layouts api --- crates/app/src/routes/api/v1/layouts.rs | 175 ++++++++++++++++++++++++ crates/app/src/routes/api/v1/mod.rs | 34 +++++ crates/core/src/database/auth.rs | 2 - crates/core/src/database/common.rs | 5 +- crates/core/src/database/layouts.rs | 117 ++++++++++++++++ crates/core/src/database/mod.rs | 1 + crates/core/src/model/auth.rs | 8 -- crates/core/src/model/layouts.rs | 19 ++- crates/core/src/model/oauth.rs | 12 +- sql_changes/users_layouts.sql | 2 +- 10 files changed, 353 insertions(+), 22 deletions(-) create mode 100644 crates/app/src/routes/api/v1/layouts.rs create mode 100644 crates/core/src/database/layouts.rs diff --git a/crates/app/src/routes/api/v1/layouts.rs b/crates/app/src/routes/api/v1/layouts.rs new file mode 100644 index 0000000..b86bfd2 --- /dev/null +++ b/crates/app/src/routes/api/v1/layouts.rs @@ -0,0 +1,175 @@ +use crate::{ + get_user_from_token, + routes::{ + api::v1::{CreateLayout, UpdateLayoutName, UpdateLayoutPages, UpdateLayoutPrivacy}, + }, + State, +}; +use axum::{extract::Path, response::IntoResponse, Extension, Json}; +use axum_extra::extract::CookieJar; +use tetratto_core::{ + model::{ + layouts::{Layout, LayoutPrivacy}, + oauth, + permissions::FinePermission, + ApiReturn, Error, + }, +}; + +pub async fn get_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadStacks) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let layout = match data.get_layout_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if layout.privacy == LayoutPrivacy::Public + && user.id != layout.owner + && !user.permissions.check(FinePermission::MANAGE_USERS) + { + return Json(Error::NotAllowed.into()); + } + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(layout), + }) +} + +pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadLayouts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.get_layouts_by_user(user.id).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateLayouts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_layout(Layout::new(req.name, user.id, req.replaces)) + .await + { + Ok(s) => Json(ApiReturn { + ok: true, + message: "Layout created".to_string(), + payload: s.id.to_string(), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_name_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_layout_title(id, &user, &req.name).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Layout updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_privacy_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_layout_privacy(id, &user, req.privacy).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Layout updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_pages_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_layout_pages(id, &user, req.pages).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Layout updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_layout(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Layout deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index c88b003..f207f1c 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -3,6 +3,7 @@ pub mod auth; pub mod channels; pub mod communities; pub mod journals; +pub mod layouts; pub mod notes; pub mod notifications; pub mod reactions; @@ -26,6 +27,7 @@ use tetratto_core::model::{ }, communities_permissions::CommunityPermission, journals::JournalPrivacyPermission, + layouts::{CustomizablePage, LayoutPage, LayoutPrivacy}, oauth::AppScope, permissions::{FinePermission, SecondaryPermission}, reactions::AssetType, @@ -612,6 +614,17 @@ pub fn routes() -> Router { // uploads .route("/uploads/{id}", get(uploads::get_request)) .route("/uploads/{id}", delete(uploads::delete_request)) + // layouts + .route("/layouts", get(layouts::list_request)) + .route("/layouts", post(layouts::create_request)) + .route("/layouts/{id}", get(layouts::get_request)) + .route("/layouts/{id}", delete(layouts::delete_request)) + .route("/layouts/{id}/title", post(layouts::update_name_request)) + .route( + "/layouts/{id}/privacy", + post(layouts::update_privacy_request), + ) + .route("/layouts/{id}/pages", post(layouts::update_pages_request)) } #[derive(Deserialize)] @@ -993,3 +1006,24 @@ pub struct UpdateNoteTags { pub struct AwardAchievement { pub name: AchievementName, } + +#[derive(Deserialize)] +pub struct CreateLayout { + pub name: String, + pub replaces: CustomizablePage, +} + +#[derive(Deserialize)] +pub struct UpdateLayoutName { + pub name: String, +} + +#[derive(Deserialize)] +pub struct UpdateLayoutPrivacy { + pub privacy: LayoutPrivacy, +} + +#[derive(Deserialize)] +pub struct UpdateLayoutPages { + pub pages: Vec, +} diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 4038fb9..f6fb848 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -112,7 +112,6 @@ impl DataManager { invite_code: get!(x->21(i64)) as usize, secondary_permissions: SecondaryPermission::from_bits(get!(x->22(i32)) as u32).unwrap(), achievements: serde_json::from_str(&get!(x->23(String)).to_string()).unwrap(), - layouts: serde_json::from_str(&get!(x->24(String)).to_string()).unwrap(), } } @@ -294,7 +293,6 @@ impl DataManager { &(data.invite_code as i64), &(SecondaryPermission::DEFAULT.bits() as i32), &serde_json::to_string(&data.achievements).unwrap(), - &serde_json::to_string(&data.layouts).unwrap(), ] ); diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 45111db..6a22ba9 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -397,10 +397,7 @@ macro_rules! auto_method { } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( user.id, - format!( - "invoked `{}` with x value `{id}` and y value `{x:?}`", - stringify!($name) - ), + format!("invoked `{}` with x value `{id}`", stringify!($name)), )) .await? } diff --git a/crates/core/src/database/layouts.rs b/crates/core/src/database/layouts.rs new file mode 100644 index 0000000..6ab1f48 --- /dev/null +++ b/crates/core/src/database/layouts.rs @@ -0,0 +1,117 @@ +use crate::model::{ + auth::User, + layouts::{Layout, LayoutPage, LayoutPrivacy}, + permissions::FinePermission, + Error, Result, +}; +use crate::{auto_method, DataManager}; +use oiseau::{PostgresRow, execute, get, query_rows, params, cache::Cache}; + +impl DataManager { + /// Get a [`Layout`] from an SQL row. + pub(crate) fn get_layout_from_row(x: &PostgresRow) -> Layout { + Layout { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + title: get!(x->3(String)), + privacy: serde_json::from_str(&get!(x->4(String))).unwrap(), + pages: serde_json::from_str(&get!(x->5(String))).unwrap(), + replaces: serde_json::from_str(&get!(x->6(String))).unwrap(), + } + } + + auto_method!(get_layout_by_id(usize as i64)@get_layout_from_row -> "SELECT * FROM layouts WHERE id = $1" --name="layout" --returns=Layout --cache-key-tmpl="atto.layout:{}"); + + /// Get all layouts by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch layouts for + pub async fn get_layouts_by_user(&self, id: usize) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM layouts WHERE owner = $1 ORDER BY created DESC", + &[&(id as i64)], + |x| { Self::get_layout_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("layout".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new layout in the database. + /// + /// # Arguments + /// * `data` - a mock [`Layout`] object to insert + pub async fn create_layout(&self, data: Layout) -> Result { + // check values + if data.title.len() < 2 { + return Err(Error::DataTooShort("title".to_string())); + } else if data.title.len() > 32 { + return Err(Error::DataTooLong("title".to_string())); + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO layouts VALUES ($1, $2, $3, $4, $5, $6, $7)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &data.title, + &serde_json::to_string(&data.privacy).unwrap(), + &serde_json::to_string(&data.pages).unwrap(), + &serde_json::to_string(&data.replaces).unwrap(), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_layout(&self, id: usize, user: &User) -> Result<()> { + let layout = self.get_layout_by_id(id).await?; + + // check user permission + if user.id != layout.owner && !user.permissions.check(FinePermission::MANAGE_STACKS) { + return Err(Error::NotAllowed); + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!(&conn, "DELETE FROM layouts WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... + self.0.1.remove(format!("atto.layout:{}", id)).await; + Ok(()) + } + + auto_method!(update_layout_title(&str)@get_layout_by_id:MANAGE_USERS -> "UPDATE layouts SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.layout:{}"); + auto_method!(update_layout_privacy(LayoutPrivacy)@get_layout_by_id:MANAGE_USERS -> "UPDATE layouts SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.layout:{}"); + auto_method!(update_layout_pages(Vec)@get_layout_by_id:MANAGE_USERS -> "UPDATE layouts SET pages = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.layout:{}"); +} diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 5f81259..6877100 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -12,6 +12,7 @@ mod invite_codes; mod ipbans; mod ipblocks; mod journals; +mod layouts; mod memberships; mod message_reactions; mod messages; diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 8c0e761..bac4ae6 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -1,7 +1,5 @@ use std::collections::HashMap; -use crate::model::layouts::CustomizablePage; - use super::{ oauth::AuthGrant, permissions::{FinePermission, SecondaryPermission}, @@ -63,11 +61,6 @@ pub struct User { /// Users collect achievements through little actions across the site. #[serde(default)] pub achievements: Vec, - /// The ID of each layout the user is using. - /// - /// Only applies if the user is a supporter. - #[serde(default)] - pub layouts: HashMap, } pub type UserConnections = @@ -326,7 +319,6 @@ impl User { invite_code: 0, secondary_permissions: SecondaryPermission::DEFAULT, achievements: Vec::new(), - layouts: HashMap::new(), } } diff --git a/crates/core/src/model/layouts.rs b/crates/core/src/model/layouts.rs index 7254d0a..a9d60a4 100644 --- a/crates/core/src/model/layouts.rs +++ b/crates/core/src/model/layouts.rs @@ -1,5 +1,6 @@ use std::{collections::HashMap, fmt::Display}; use serde::{Deserialize, Serialize}; +use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; use crate::model::auth::DefaultTimelineChoice; /// Each different page which can be customized. @@ -20,10 +21,26 @@ pub struct Layout { pub title: String, pub privacy: LayoutPrivacy, pub pages: Vec, + pub replaces: CustomizablePage, +} + +impl Layout { + /// Create a new [`Layout`]. + pub fn new(title: String, owner: usize, replaces: CustomizablePage) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + owner, + title, + privacy: LayoutPrivacy::Public, + pages: Vec::new(), + replaces, + } + } } /// The privacy of the layout, which controls who has the ability to view it. -#[derive(Serialize, Deserialize)] +#[derive(Serialize, Deserialize, PartialEq, Eq)] pub enum LayoutPrivacy { Public, Private, diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index e783a1e..7d5ebb6 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -68,8 +68,8 @@ pub enum AppScope { UserReadJournals, /// Read the user's notes. UserReadNotes, - /// Read the user's links. - UserReadLinks, + /// Read the user's layouts. + UserReadLayouts, /// Create posts as the user. UserCreatePosts, /// Create messages as the user. @@ -88,8 +88,8 @@ pub enum AppScope { UserCreateJournals, /// Create notes on behalf of the user. UserCreateNotes, - /// Create links on behalf of the user. - UserCreateLinks, + /// Create layouts on behalf of the user. + UserCreateLayouts, /// Delete posts owned by the user. UserDeletePosts, /// Delete messages owned by the user. @@ -124,8 +124,8 @@ pub enum AppScope { UserManageJournals, /// Manage the user's notes. UserManageNotes, - /// Manage the user's links. - UserManageLinks, + /// Manage the user's layouts. + UserManageLayouts, /// Edit posts created by the user. UserEditPosts, /// Edit drafts created by the user. diff --git a/sql_changes/users_layouts.sql b/sql_changes/users_layouts.sql index 0d8e489..d80e60b 100644 --- a/sql_changes/users_layouts.sql +++ b/sql_changes/users_layouts.sql @@ -1,2 +1,2 @@ ALTER TABLE users -ADD COLUMN layouts TEXT NOT NULL DEFAULT '{}'; +DROP COLUMN layouts; From ee2f7c7cbb3ae5b96fb955ca3bccf8d5c1528628 Mon Sep 17 00:00:00 2001 From: trisua Date: Wed, 2 Jul 2025 22:41:10 -0400 Subject: [PATCH 15/93] fix: render dates in quotes with long text --- crates/app/src/public/js/atto.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 30d29bd..6503548 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -163,7 +163,7 @@ media_theme_pref(); } }); - self.define("clean_poll_date_codes", ({ $ }) => { + self.define("clean_poll_date_codes", async ({ $ }) => { for (const element of Array.from( document.querySelectorAll(".poll_date"), )) { @@ -183,7 +183,7 @@ media_theme_pref(); element.setAttribute("title", then.toLocaleString()); const pretty = - $.rel_date(then) + (await $.rel_date(then)) .replaceAll(" minutes ago", "m") .replaceAll(" minute ago", "m") .replaceAll(" hours ago", "h") @@ -409,9 +409,13 @@ media_theme_pref(); } }); - self.define("hooks::long", (_, element, full_text) => { + self.define("hooks::long", ({ $ }, element, full_text) => { element.classList.remove("hook:long.hidden_text"); element.innerHTML = full_text; + + $.clean_date_codes(); + $.clean_poll_date_codes(); + $.link_filter(); }); self.define("hooks::long_text.init", (_) => { From 0aa2ea362fea1b26b3d2e24374103aec0d12edba Mon Sep 17 00:00:00 2001 From: trisua Date: Wed, 2 Jul 2025 23:10:58 -0400 Subject: [PATCH 16/93] chore: refactor auto_method macro for SecondaryPermission --- crates/core/src/database/apps.rs | 8 +++--- crates/core/src/database/channels.rs | 10 +++---- crates/core/src/database/common.rs | 36 ++++++++++++------------- crates/core/src/database/communities.rs | 8 +++--- crates/core/src/database/emojis.rs | 2 +- crates/core/src/database/journals.rs | 6 ++--- crates/core/src/database/layouts.rs | 6 ++--- crates/core/src/database/notes.rs | 8 +++--- crates/core/src/database/stacks.rs | 10 +++---- 9 files changed, 47 insertions(+), 47 deletions(-) diff --git a/crates/core/src/database/apps.rs b/crates/core/src/database/apps.rs index 9faf6d4..f24b427 100644 --- a/crates/core/src/database/apps.rs +++ b/crates/core/src/database/apps.rs @@ -136,11 +136,11 @@ impl DataManager { Ok(()) } - auto_method!(update_app_title(&str)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); - auto_method!(update_app_homepage(&str)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET homepage = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); - auto_method!(update_app_redirect(&str)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET redirect = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); + auto_method!(update_app_title(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); + auto_method!(update_app_homepage(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET homepage = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); + auto_method!(update_app_redirect(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET redirect = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); auto_method!(update_app_quota_status(AppQuota) -> "UPDATE apps SET quota_status = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}"); - auto_method!(update_app_scopes(Vec)@get_app_by_id:MANAGE_APPS -> "UPDATE apps SET scopes = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}"); + auto_method!(update_app_scopes(Vec)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET scopes = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}"); auto_method!(incr_app_grants() -> "UPDATE apps SET grants = grants + 1 WHERE id = $1" --cache-key-tmpl="atto.app:{}" --incr); auto_method!(decr_app_grants()@get_app_by_id -> "UPDATE apps SET grants = grants - 1 WHERE id = $1" --cache-key-tmpl="atto.app:{}" --decr=grants); diff --git a/crates/core/src/database/channels.rs b/crates/core/src/database/channels.rs index b3dc31b..ee42d4b 100644 --- a/crates/core/src/database/channels.rs +++ b/crates/core/src/database/channels.rs @@ -317,10 +317,10 @@ impl DataManager { Ok(()) } - auto_method!(update_channel_title(&str)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); - auto_method!(update_channel_position(i32)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET position = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); - auto_method!(update_channel_minimum_role_read(i32)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET minimum_role_read = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); - auto_method!(update_channel_minimum_role_write(i32)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET minimum_role_write = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); - auto_method!(update_channel_members(Vec)@get_channel_by_id:MANAGE_CHANNELS -> "UPDATE channels SET members = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.channel:{}"); + auto_method!(update_channel_title(&str)@get_channel_by_id:FinePermission::MANAGE_CHANNELS; -> "UPDATE channels SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); + auto_method!(update_channel_position(i32)@get_channel_by_id:FinePermission::MANAGE_CHANNELS; -> "UPDATE channels SET position = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); + auto_method!(update_channel_minimum_role_read(i32)@get_channel_by_id:FinePermission::MANAGE_CHANNELS; -> "UPDATE channels SET minimum_role_read = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); + auto_method!(update_channel_minimum_role_write(i32)@get_channel_by_id:FinePermission::MANAGE_CHANNELS; -> "UPDATE channels SET minimum_role_write = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); + auto_method!(update_channel_members(Vec)@get_channel_by_id:FinePermission::MANAGE_CHANNELS; -> "UPDATE channels SET members = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.channel:{}"); auto_method!(update_channel_last_message(i64) -> "UPDATE channels SET last_message = $1 WHERE id = $2" --cache-key-tmpl="atto.channel:{}"); } diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 6a22ba9..aabb0d3 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -226,12 +226,12 @@ macro_rules! auto_method { } }; - ($name:ident()@$select_fn:ident:$permission:ident -> $query:literal) => { + ($name:ident()@$select_fn:ident:$permission:expr; -> $query:literal) => { pub async fn $name(&self, id: usize, user: &User) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( @@ -256,12 +256,12 @@ macro_rules! auto_method { } }; - ($name:ident()@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal) => { + ($name:ident()@$select_fn:ident:$permission:expr; -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal) => { pub async fn $name(&self, id: usize, user: &User) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( @@ -288,12 +288,12 @@ macro_rules! auto_method { } }; - ($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal) => { + ($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal) => { pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( @@ -319,12 +319,12 @@ macro_rules! auto_method { } }; - ($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal) => { + ($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal --cache-key-tmpl=$cache_key_tmpl:literal) => { pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( @@ -352,12 +352,12 @@ macro_rules! auto_method { } }; - ($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --serde) => { + ($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal --serde) => { pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( @@ -387,12 +387,12 @@ macro_rules! auto_method { } }; - ($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --serde --cache-key-tmpl=$cache_key_tmpl:literal) => { + ($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal --serde --cache-key-tmpl=$cache_key_tmpl:literal) => { pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( @@ -567,12 +567,12 @@ macro_rules! auto_method { } }; - ($name:ident()@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:ident) => { + ($name:ident()@$select_fn:ident:$permission:expr; -> $query:literal --cache-key-tmpl=$cache_key_tmpl:ident) => { pub async fn $name(&self, id: usize, user: &User) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( @@ -600,12 +600,12 @@ macro_rules! auto_method { } }; - ($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --cache-key-tmpl=$cache_key_tmpl:ident) => { + ($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal --cache-key-tmpl=$cache_key_tmpl:ident) => { pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( @@ -679,12 +679,12 @@ macro_rules! auto_method { } }; - ($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --serde --cache-key-tmpl=$cache_key_tmpl:ident) => { + ($name:ident($x:ty)@$select_fn:ident:$permission:expr; -> $query:literal --serde --cache-key-tmpl=$cache_key_tmpl:ident) => { pub async fn $name(&self, id: usize, user: &User, x: $x) -> Result<()> { let y = self.$select_fn(id).await?; if user.id != y.owner { - if !user.permissions.check(FinePermission::$permission) { + if !user.permissions.check($permission) { return Err(Error::NotAllowed); } else { self.create_audit_log_entry($crate::model::moderation::AuditLogEntry::new( diff --git a/crates/core/src/database/communities.rs b/crates/core/src/database/communities.rs index 8237b7e..fa7c234 100644 --- a/crates/core/src/database/communities.rs +++ b/crates/core/src/database/communities.rs @@ -521,10 +521,10 @@ impl DataManager { Ok(()) } - auto_method!(update_community_context(CommunityContext)@get_community_by_id_no_void:MANAGE_COMMUNITIES -> "UPDATE communities SET context = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); - auto_method!(update_community_read_access(CommunityReadAccess)@get_community_by_id_no_void:MANAGE_COMMUNITIES -> "UPDATE communities SET read_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); - auto_method!(update_community_write_access(CommunityWriteAccess)@get_community_by_id_no_void:MANAGE_COMMUNITIES -> "UPDATE communities SET write_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); - auto_method!(update_community_join_access(CommunityJoinAccess)@get_community_by_id_no_void:MANAGE_COMMUNITIES -> "UPDATE communities SET join_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); + auto_method!(update_community_context(CommunityContext)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET context = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); + auto_method!(update_community_read_access(CommunityReadAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET read_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); + auto_method!(update_community_write_access(CommunityWriteAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET write_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); + auto_method!(update_community_join_access(CommunityJoinAccess)@get_community_by_id_no_void:FinePermission::MANAGE_COMMUNITIES; -> "UPDATE communities SET join_access = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_community); auto_method!(incr_community_likes()@get_community_by_id_no_void -> "UPDATE communities SET likes = likes + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr); auto_method!(incr_community_dislikes()@get_community_by_id_no_void -> "UPDATE communities SET dislikes = dislikes + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_community --incr); diff --git a/crates/core/src/database/emojis.rs b/crates/core/src/database/emojis.rs index c61fed6..4f09af7 100644 --- a/crates/core/src/database/emojis.rs +++ b/crates/core/src/database/emojis.rs @@ -201,5 +201,5 @@ impl DataManager { Ok(()) } - auto_method!(update_emoji_name(&str)@get_emoji_by_id:MANAGE_EMOJIS -> "UPDATE emojis SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.emoji:{}"); + auto_method!(update_emoji_name(&str)@get_emoji_by_id:FinePermission::MANAGE_EMOJIS; -> "UPDATE emojis SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.emoji:{}"); } diff --git a/crates/core/src/database/journals.rs b/crates/core/src/database/journals.rs index 2ad4078..4855f9d 100644 --- a/crates/core/src/database/journals.rs +++ b/crates/core/src/database/journals.rs @@ -183,7 +183,7 @@ impl DataManager { Ok(()) } - auto_method!(update_journal_title(&str)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.journal:{}"); - auto_method!(update_journal_privacy(JournalPrivacyPermission)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}"); - auto_method!(update_journal_dirs(Vec<(usize, usize, String)>)@get_journal_by_id:MANAGE_JOURNALS -> "UPDATE journals SET dirs = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}"); + auto_method!(update_journal_title(&str)@get_journal_by_id:FinePermission::MANAGE_JOURNALS; -> "UPDATE journals SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.journal:{}"); + auto_method!(update_journal_privacy(JournalPrivacyPermission)@get_journal_by_id:FinePermission::MANAGE_JOURNALS; -> "UPDATE journals SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}"); + auto_method!(update_journal_dirs(Vec<(usize, usize, String)>)@get_journal_by_id:FinePermission::MANAGE_JOURNALS; -> "UPDATE journals SET dirs = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.journal:{}"); } diff --git a/crates/core/src/database/layouts.rs b/crates/core/src/database/layouts.rs index 6ab1f48..052a733 100644 --- a/crates/core/src/database/layouts.rs +++ b/crates/core/src/database/layouts.rs @@ -111,7 +111,7 @@ impl DataManager { Ok(()) } - auto_method!(update_layout_title(&str)@get_layout_by_id:MANAGE_USERS -> "UPDATE layouts SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.layout:{}"); - auto_method!(update_layout_privacy(LayoutPrivacy)@get_layout_by_id:MANAGE_USERS -> "UPDATE layouts SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.layout:{}"); - auto_method!(update_layout_pages(Vec)@get_layout_by_id:MANAGE_USERS -> "UPDATE layouts SET pages = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.layout:{}"); + auto_method!(update_layout_title(&str)@get_layout_by_id:FinePermission::MANAGE_USERS; -> "UPDATE layouts SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.layout:{}"); + auto_method!(update_layout_privacy(LayoutPrivacy)@get_layout_by_id:FinePermission::MANAGE_USERS; -> "UPDATE layouts SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.layout:{}"); + auto_method!(update_layout_pages(Vec)@get_layout_by_id:FinePermission::MANAGE_USERS; -> "UPDATE layouts SET pages = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.layout:{}"); } diff --git a/crates/core/src/database/notes.rs b/crates/core/src/database/notes.rs index 2754baf..9769159 100644 --- a/crates/core/src/database/notes.rs +++ b/crates/core/src/database/notes.rs @@ -289,10 +289,10 @@ impl DataManager { self.0.1.remove(format!("atto.note:{}", x.title)).await; } - auto_method!(update_note_title(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET title = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); - auto_method!(update_note_content(&str)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET content = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); - auto_method!(update_note_dir(i64)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET dir = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); - auto_method!(update_note_tags(Vec)@get_note_by_id:MANAGE_NOTES -> "UPDATE notes SET tags = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_note); + auto_method!(update_note_title(&str)@get_note_by_id:FinePermission::MANAGE_NOTES; -> "UPDATE notes SET title = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); + auto_method!(update_note_content(&str)@get_note_by_id:FinePermission::MANAGE_NOTES; -> "UPDATE notes SET content = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); + auto_method!(update_note_dir(i64)@get_note_by_id:FinePermission::MANAGE_NOTES; -> "UPDATE notes SET dir = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); + auto_method!(update_note_tags(Vec)@get_note_by_id:FinePermission::MANAGE_NOTES; -> "UPDATE notes SET tags = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_note); auto_method!(update_note_edited(i64)@get_note_by_id -> "UPDATE notes SET edited = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); auto_method!(update_note_is_global(i32)@get_note_by_id -> "UPDATE notes SET is_global = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_note); } diff --git a/crates/core/src/database/stacks.rs b/crates/core/src/database/stacks.rs index 46a3e30..c4fa5df 100644 --- a/crates/core/src/database/stacks.rs +++ b/crates/core/src/database/stacks.rs @@ -245,10 +245,10 @@ impl DataManager { Ok(()) } - auto_method!(update_stack_name(&str)@get_stack_by_id:MANAGE_STACKS -> "UPDATE stacks SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.stack:{}"); - auto_method!(update_stack_users(Vec)@get_stack_by_id:MANAGE_STACKS -> "UPDATE stacks SET users = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); + auto_method!(update_stack_name(&str)@get_stack_by_id:FinePermission::MANAGE_STACKS; -> "UPDATE stacks SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.stack:{}"); + auto_method!(update_stack_users(Vec)@get_stack_by_id:FinePermission::MANAGE_STACKS; -> "UPDATE stacks SET users = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); - auto_method!(update_stack_privacy(StackPrivacy)@get_stack_by_id:MANAGE_STACKS -> "UPDATE stacks SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); - auto_method!(update_stack_mode(StackMode)@get_stack_by_id:MANAGE_STACKS -> "UPDATE stacks SET mode = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); - auto_method!(update_stack_sort(StackSort)@get_stack_by_id:MANAGE_STACKS -> "UPDATE stacks SET sort = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); + auto_method!(update_stack_privacy(StackPrivacy)@get_stack_by_id:FinePermission::MANAGE_STACKS; -> "UPDATE stacks SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); + auto_method!(update_stack_mode(StackMode)@get_stack_by_id:FinePermission::MANAGE_STACKS; -> "UPDATE stacks SET mode = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); + auto_method!(update_stack_sort(StackSort)@get_stack_by_id:FinePermission::MANAGE_STACKS; -> "UPDATE stacks SET sort = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.stack:{}"); } From 2ec8d86edf080e527ceebe88cd7d0aa64015942d Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 3 Jul 2025 21:56:21 -0400 Subject: [PATCH 17/93] add: purchased accounts --- crates/app/src/assets.rs | 1 + crates/app/src/langs/en-US.toml | 4 + crates/app/src/public/html/auth/base.lisp | 2 +- crates/app/src/public/html/auth/login.lisp | 3 +- crates/app/src/public/html/auth/register.lisp | 47 +- crates/app/src/public/html/components.lisp | 77 ++ crates/app/src/public/html/mod/profile.lisp | 10 + .../app/src/public/html/profile/settings.lisp | 120 ++- crates/app/src/public/html/root.lisp | 68 +- crates/app/src/public/js/layout_editor.js | 762 ++++++++++++++++++ .../routes/api/v1/auth/connections/stripe.rs | 21 + crates/app/src/routes/api/v1/auth/mod.rs | 75 +- crates/app/src/routes/api/v1/auth/profile.rs | 84 +- crates/app/src/routes/api/v1/mod.rs | 24 + crates/app/src/routes/assets.rs | 1 + crates/app/src/routes/mod.rs | 4 + crates/core/src/config.rs | 2 + crates/core/src/database/auth.rs | 55 +- .../src/database/drivers/sql/create_users.sql | 4 +- crates/core/src/database/invite_codes.rs | 23 +- crates/core/src/model/auth.rs | 11 + sql_changes/users_awaiting_purchase.sql | 5 + 22 files changed, 1279 insertions(+), 124 deletions(-) create mode 100644 crates/app/src/public/js/layout_editor.js create mode 100644 sql_changes/users_awaiting_purchase.sql diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 3958f09..89af907 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -40,6 +40,7 @@ pub const ATTO_JS: &str = include_str!("./public/js/atto.js"); pub const ME_JS: &str = include_str!("./public/js/me.js"); pub const STREAMS_JS: &str = include_str!("./public/js/streams.js"); pub const CARP_JS: &str = include_str!("./public/js/carp.js"); +pub const LAYOUT_EDITOR_JS: &str = include_str!("./public/js/layout_editor.js"); // html pub const BODY: &str = include_str!("./public/html/body.lisp"); diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index f854057..ea87729 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -44,6 +44,7 @@ version = "1.0.0" "general:label.timeline_end" = "That's a wrap!" "general:label.loading" = "Working on it!" "general:label.send_anonymously" = "Send anonymously" +"general:label.must_activate_account" = "You need to activate your account!" "general:label.supporter_motivation" = "Become a supporter!" "general:action.become_supporter" = "Become supporter" @@ -88,6 +89,9 @@ version = "1.0.0" "auth:action.message" = "Message" "auth:label.banned" = "Banned" "auth:label.banned_message" = "This user has been banned for breaking the site's rules." +"auth:action.create_account" = "Create account" +"auth:action.purchase_account" = "Purchase account" +"auth:action.continue" = "Continue" "communities:action.create" = "Create" "communities:action.select" = "Select" diff --git a/crates/app/src/public/html/auth/base.lisp b/crates/app/src/public/html/auth/base.lisp index c13c336..3ed7b5a 100644 --- a/crates/app/src/public/html/auth/base.lisp +++ b/crates/app/src/public/html/auth/base.lisp @@ -1,7 +1,7 @@ (text "{% extends \"root.html\" %} {% block body %}") (main ("class" "flex flex-col gap-2") - ("style" "max-width: 25rem") + ("style" "max-width: 48ch") (h2 ("class" "w-full text-center") ; block for title diff --git a/crates/app/src/public/html/auth/login.lisp b/crates/app/src/public/html/auth/login.lisp index cb8bfff..e887b2b 100644 --- a/crates/app/src/public/html/auth/login.lisp +++ b/crates/app/src/public/html/auth/login.lisp @@ -48,7 +48,8 @@ ("name" "totp") ("id" "totp")))) (button - (text "Submit"))) + (icon (text "arrow-right")) + (str (text "auth:action.continue")))) (script (text "let flow_page = 1; diff --git a/crates/app/src/public/html/auth/register.lisp b/crates/app/src/public/html/auth/register.lisp index 9e6c22b..aa94c3d 100644 --- a/crates/app/src/public/html/auth/register.lisp +++ b/crates/app/src/public/html/auth/register.lisp @@ -37,16 +37,31 @@ (text "{% if config.security.enable_invite_codes -%}") (div ("class" "flex flex-col gap-1") + ("oninput" "check_should_show_purchase(event)") (label ("for" "invite_code") (b - (text "Invite code"))) + (text "Invite code (optional)"))) (input ("type" "text") ("placeholder" "invite code") - ("required" "") ("name" "invite_code") ("id" "invite_code"))) + + (script + (text "function check_should_show_purchase(e) { + if (e.target.value.length > 0) { + document.querySelector('[ui_ident=purchase_account]').classList.add('hidden'); + document.querySelector('[ui_ident=create_account]').classList.remove('hidden'); + globalThis.DO_PURCHASE = false; + } else { + document.querySelector('[ui_ident=purchase_account]').classList.remove('hidden'); + document.querySelector('[ui_ident=create_account]').classList.add('hidden'); + globalThis.DO_PURCHASE = true; + } + } + + globalThis.DO_PURCHASE = true;")) (text "{%- endif %}") (hr) (div @@ -84,8 +99,33 @@ ("class" "cf-turnstile") ("data-sitekey" "{{ config.turnstile.site_key }}")) (hr) + (text "{% if config.security.enable_invite_codes -%}") + (div + ("class" "w-full flex gap-2 justify-between") + ("ui_ident" "purchase_account") + + (button + (icon (text "credit-card")) + (str (text "auth:action.purchase_account"))) + + (button + ("class" "small square lowered") + ("type" "button") + ("onclick" "document.querySelector('[ui_ident=purchase_help]').classList.toggle('hidden')") + (icon (text "circle-question-mark")))) + + (div + ("class" "hidden lowered card w-full no_p_margin") + ("ui_ident" "purchase_help") + (b (text "What does \"Purchase account\" mean?")) + (p (text "Your account will be created, but you cannot use it until you activate it for {{ config.stripe.supporter_price_text }}.")) + (p (text "Alternatively, you can provide an invite code to create your account for free."))) + (text "{%- endif %}") (button - (text "Submit"))) + ("class" "{% if config.security.enable_invite_codes -%} hidden {%- endif %}") + ("ui_ident" "create_account") + (icon (text "plus")) + (str (text "auth:action.create_account")))) (script (text "async function register(e) { @@ -104,6 +144,7 @@ \"[name=cf-turnstile-response]\", ).value, invite_code: (e.target.invite_code || { value: \"\" }).value, + purchase: globalThis.DO_PURCHASE, }), }) .then((res) => res.json()) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index d9001c9..a15fe19 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -2285,3 +2285,80 @@ (text "{{ self::note_mover_dirs_listing(dir=subdir, dirs=dirs) }}") (text "{%- endif %} {% endfor %}")) (text "{%- endmacro %}") + +(text "{% macro become_supporter_button() -%}") +(p + (text "You're ") + (b + (text "not ")) + (text "currently a supporter! No + pressure, but it helps us do some pretty cool + things! As a supporter, you'll get:")) +(ul + ("style" "margin-bottom: var(--pad-4)") + (li + (text "Vanity badge on profile")) + (li + (text "No more supporter ads (duh)")) + (li + (text "Ability to upload gif avatars/banners")) + (li + (text "Be an admin/owner of up to 10 communities")) + (li + (text "Use custom CSS on your profile")) + (li + (text "Use community emojis outside of + their community")) + (li + (text "Upload and use gif emojis")) + (li + (text "Create infinite stack timelines")) + (li + (text "Upload images to posts")) + (li + (text "Save infinite post drafts")) + (li + (text "Ability to search through all posts")) + (li + (text "Ability to create forges")) + (li + (text "Create more than 1 app")) + (li + (text "Create up to 10 stack blocks")) + (li + (text "Add unlimited users to stacks")) + (li + (text "Increased proxied image size")) + (li + (text "Create infinite journals")) + (li + (text "Create infinite notes in each journal")) + (li + (text "Publish up to 50 notes")) + + (text "{% if config.security.enable_invite_codes -%}") + (li + (text "Create up to 48 invite codes") + (sup (a ("href" "#footnote-1") (text "1")))) + (text "{%- endif %}")) +(a + ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}") + ("class" "button") + ("target" "_blank") + (text "Become a supporter ({{ config.stripe.supporter_price_text }})")) +(span + ("class" "fade") + (text "Please use your") + (b + (text " real email ")) + (text "when + completing payment. It is required to manage + your billing settings.")) + +(text "{% if config.security.enable_invite_codes -%}") +(span + ("class" "fade") + ("id" "footnote-1") + (b (text "1: ")) (text "After your account is at least 1 month old")) +(text "{%- endif %}") +(text "{%- endmacro %}") diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index e64ec63..9fb5ebf 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -168,6 +168,11 @@ \"{{ profile.is_verified }}\", \"checkbox\", ], + [ + [\"awaiting_purchase\", \"Awaiting purchase\"], + \"{{ profile.awaiting_purchase }}\", + \"checkbox\", + ], [ [\"role\", \"Permission level\"], \"{{ profile.permissions }}\", @@ -181,6 +186,11 @@ is_verified: value, }); }, + awaiting_purchase: (value) => { + profile_request(false, \"awaiting_purchase\", { + awaiting_purchase: value, + }); + }, role: (new_role) => { return update_user_role(new_role); }, diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index dce30d2..8acd9c3 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -671,73 +671,31 @@ ("target" "_blank") (text "Manage billing")) (text "{% else %}") - (p - (text "You're ") - (b - (text "not ")) - (text "currently a supporter! No - pressure, but it helps us do some pretty cool - things! As a supporter, you'll get:")) - (ul - ("style" "margin-bottom: var(--pad-4)") - (li - (text "Vanity badge on profile")) - (li - (text "No more supporter ads (duh)")) - (li - (text "Ability to upload gif avatars/banners")) - (li - (text "Be an admin/owner of up to 10 communities")) - (li - (text "Use custom CSS on your profile")) - (li - (text "Use community emojis outside of - their community")) - (li - (text "Upload and use gif emojis")) - (li - (text "Create infinite stack timelines")) - (li - (text "Upload images to posts")) - (li - (text "Save infinite post drafts")) - (li - (text "Ability to search through all posts")) - (li - (text "Ability to create forges")) - (li - (text "Create more than 1 app")) - (li - (text "Create up to 10 stack blocks")) - (li - (text "Add unlimited users to stacks")) - (li - (text "Increased proxied image size")) - (li - (text "Create infinite journals")) - (li - (text "Create infinite notes in each journal")) - (li - (text "Publish up to 50 notes")) - - (text "{% if config.security.enable_invite_codes -%}") - (li - (text "Create up to 48 invite codes")) - (text "{%- endif %}")) - (a - ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}") - ("class" "button") - ("target" "_blank") - (text "Become a supporter")) - (span - ("class" "fade") - (text "Please use your") - (b - (text "real email")) - (text "when - completing payment. It is required to manage - your billing settings.")) + (text "{{ components::become_supporter_button() }}") (text "{%- endif %}"))) + + (text "{% if user.was_purchased and user.invite_code == 0 -%}") + (form + ("class" "card w-full lowered flex flex-col gap-2") + ("onsubmit" "update_invite_code(event)") + (p (text "Your account is currently activated without an invite code. If you stop paying for supporter, your account will be locked again until you renew. You can provide an invite code to avoid this if you're planning on cancelling.")) + + (div + ("class" "flex flex-col gap-1") + (label + ("for" "invite_code") + (b + (text "Invite code"))) + (input + ("type" "text") + ("placeholder" "invite code") + ("name" "invite_code") + ("required" "") + ("id" "invite_code"))) + + (button + (text "Submit"))) + (text "{%- endif %}") (text "{%- endif %}"))))) (div ("class" "w-full hidden flex flex-col gap-2") @@ -1198,6 +1156,11 @@ globalThis.delete_account = async (e) => { e.preventDefault(); + // {% if user.permissions|has_supporter %} + alert(\"Please cancel your membership before deleting your account. You'll have to wait until the next cycle to delete your account after, or you can request support if it is urgent.\"); + return; + // {% endif %} + if ( !(await trigger(\"atto::confirm\", [ \"Are you sure you would like to do this?\", @@ -1381,6 +1344,31 @@ }); }; + globalThis.update_invite_code = async (e) => { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"invite_codes::try\"]); + fetch(\"/api/v1/auth/user/me/invite_code\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + invite_code: e.target.invite_code.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.reload(); + } + }); + } + const account_settings = document.getElementById(\"account_settings\"); const profile_settings = diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp index a4288b3..93312bb 100644 --- a/crates/app/src/public/html/root.lisp +++ b/crates/app/src/public/html/root.lisp @@ -72,7 +72,73 @@ (str (text "general:label.account_banned_body")))))) ; if we aren't banned, just show the page body - (text "{% else %} {% block body %}{% endblock %} {%- endif %}") + (text "{% elif user and user.awaiting_purchase %}") + ; account waiting for payment message + (article + (main + (div + ("class" "card-nest") + (div + ("class" "card small flex items-center gap-2 red") + (icon (text "frown")) + (str (text "general:label.must_activate_account"))) + + (div + ("class" "card no_p_margin flex flex-col gap-2") + (p (text "Since you didn't provide an invite code, you'll need to activate your account to use it.")) + (p (text "Supporter is a recurring membership. If you cancel it, your account will be locked again unless you renew your subscription or provide an invite code.")) + (div + ("class" "card w-full lowered flex flex-col gap-2") + (text "{{ components::become_supporter_button() }}")) + (p (text "Alternatively, you can provide an invite code to activate your account.")) + (form + ("class" "card w-full lowered flex flex-col gap-2") + ("onsubmit" "update_invite_code(event)") + (div + ("class" "flex flex-col gap-1") + (label + ("for" "invite_code") + (b + (text "Invite code"))) + (input + ("type" "text") + ("placeholder" "invite code") + ("name" "invite_code") + ("required" "") + ("id" "invite_code"))) + + (button + (text "Submit"))) + + (script + (text "async function update_invite_code(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"invite_codes::try\"]); + fetch(\"/api/v1/auth/user/me/invite_code\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + invite_code: e.target.invite_code.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.reload(); + } + }); + }")))))) + (text "{% else %}") + ; page body + (text "{% block body %}{% endblock %}") + (text "{%- endif %}") (text "")) (text "{% include \"body.html\" %}"))) diff --git a/crates/app/src/public/js/layout_editor.js b/crates/app/src/public/js/layout_editor.js new file mode 100644 index 0000000..13d3d8b --- /dev/null +++ b/crates/app/src/public/js/layout_editor.js @@ -0,0 +1,762 @@ +/// Copy all the fields from one object to another. +function copy_fields(from, to) { + for (const field of Object.entries(from)) { + to[field[0]] = field[1]; + } + + return to; +} + +/// Simple template components. +const COMPONENT_TEMPLATES = { + EMPTY_COMPONENT: { component: "empty", options: {}, children: [] }, + FLEX_DEFAULT: { + component: "flex", + options: { + direction: "row", + gap: "2", + }, + children: [], + }, + FLEX_SIMPLE_ROW: { + component: "flex", + options: { + direction: "row", + gap: "2", + width: "full", + }, + children: [], + }, + FLEX_SIMPLE_COL: { + component: "flex", + options: { + direction: "col", + gap: "2", + width: "full", + }, + children: [], + }, + FLEX_MOBILE_COL: { + component: "flex", + options: { + collapse: "yes", + gap: "2", + width: "full", + }, + children: [], + }, + MARKDOWN_DEFAULT: { + component: "markdown", + options: { + text: "Hello, world!", + }, + }, + MARKDOWN_CARD: { + component: "markdown", + options: { + class: "card w-full", + text: "Hello, world!", + }, + }, +}; + +/// All available components with their label and JSON representation. +const COMPONENTS = [ + [ + "Markdown block", + COMPONENT_TEMPLATES.MARKDOWN_DEFAULT, + [["Card", COMPONENT_TEMPLATES.MARKDOWN_CARD]], + ], + [ + "Flex container", + COMPONENT_TEMPLATES.FLEX_DEFAULT, + [ + ["Simple rows", COMPONENT_TEMPLATES.FLEX_SIMPLE_ROW], + ["Simple columns", COMPONENT_TEMPLATES.FLEX_SIMPLE_COL], + ["Mobile columns", COMPONENT_TEMPLATES.FLEX_MOBILE_COL], + ], + ], + [ + "Profile tabs", + { + component: "tabs", + }, + ], + [ + "Profile feeds", + { + component: "feed", + }, + ], + [ + "Profile banner", + { + component: "banner", + }, + ], + [ + "Question box", + { + component: "ask", + }, + ], + [ + "Name & avatar", + { + component: "name", + }, + ], + [ + "About section", + { + component: "about", + }, + ], + [ + "Action buttons", + { + component: "actions", + }, + ], + [ + "CSS stylesheet", + { + component: "style", + options: { + data: "", + }, + }, + ], +]; + +// preload icons +trigger("app::icon", ["shapes"]); +trigger("app::icon", ["type"]); +trigger("app::icon", ["plus"]); +trigger("app::icon", ["move-up"]); +trigger("app::icon", ["move-down"]); +trigger("app::icon", ["trash"]); +trigger("app::icon", ["arrow-left"]); +trigger("app::icon", ["x"]); + +/// The location of an element as represented by array indexes. +class ElementPointer { + position = []; + + constructor(element) { + if (element) { + const pos = []; + + let target = element; + while (target.parentElement) { + const parent = target.parentElement; + + // push index + pos.push(Array.from(parent.children).indexOf(target) || 0); + + // update target + if (parent.id === "editor") { + break; + } + + target = parent; + } + + this.position = pos.reverse(); // indexes are added in reverse order because of how we traverse + } else { + this.position = []; + } + } + + get() { + return this.position; + } + + resolve(json, minus = 0) { + let out = json; + + if (this.position.length === 1) { + // this is the first element (this.position === [0]) + return out; + } + + const pos = this.position.slice(1, this.position.length); // the first one refers to the root element + + for (let i = 0; i < minus; i++) { + pos.pop(); + } + + for (const idx of pos) { + const child = ((out || { children: [] }).children || [])[idx]; + + if (!child) { + break; + } + + out = child; + } + + return out; + } +} + +/// The layout editor controller. +class LayoutEditor { + element; + json; + tree = ""; + current = { component: "empty" }; + pointer = new ElementPointer(); + + /// Create a new [`LayoutEditor`]. + constructor(element, json) { + this.element = element; + this.json = json; + + if (this.json.json) { + delete this.json.json; + } + + element.addEventListener("click", (e) => this.click(e, this)); + element.addEventListener("mouseover", (e) => { + e.stopImmediatePropagation(); + const ptr = new ElementPointer(e.target); + + if (document.getElementById("position")) { + document.getElementById( + "position", + ).parentElement.style.display = "flex"; + + document.getElementById("position").innerText = ptr + .get() + .join("."); + } + }); + + this.render(); + } + + /// Render layout. + render() { + fetch("/api/v0/auth/render_layout", { + method: "POST", + body: JSON.stringify({ + layout: this.json, + }), + headers: { + "Content-Type": "application/json", + }, + }) + .then((r) => r.json()) + .then((r) => { + this.element.innerHTML = r.block; + this.tree = r.tree; + + if (this.json.component !== "empty") { + // remove all "empty" components (if the root component isn't an empty) + for (const element of document.querySelectorAll( + '[data-component-name="empty"]', + )) { + element.remove(); + } + } + }); + } + + /// Editor clicked. + click(e, self) { + e.stopImmediatePropagation(); + trigger("app::hooks::dropdown.close"); + + const ptr = new ElementPointer(e.target); + self.current = ptr.resolve(self.json); + self.pointer = ptr; + + if (document.getElementById("current_position")) { + document.getElementById( + "current_position", + ).parentElement.style.display = "flex"; + + document.getElementById("current_position").innerText = ptr + .get() + .join("."); + } + + for (const element of document.querySelectorAll( + ".layout_editor_block.active", + )) { + element.classList.remove("active"); + } + + e.target.classList.add("active"); + self.screen("element"); + } + + /// Open sidebar. + open() { + document.getElementById("editor_sidebar").classList.add("open"); + document.getElementById("editor").style.transform = "scale(0.8)"; + } + + /// Close sidebar. + close() { + document.getElementById("editor_sidebar").style.animation = + "0.2s ease-in-out forwards to_left"; + + setTimeout(() => { + document.getElementById("editor_sidebar").classList.remove("open"); + document.getElementById("editor_sidebar").style.animation = + "0.2s ease-in-out forwards from_right"; + }, 250); + + document.getElementById("editor").style.transform = "scale(1)"; + } + + /// Render editor dialog. + screen(page = "element", data = {}) { + this.current.component = this.current.component.toLowerCase(); + + const sidebar = document.getElementById("editor_sidebar"); + sidebar.innerHTML = ""; + + // render page + if ( + page === "add" || + (page === "element" && this.current.component === "empty") + ) { + // add element + sidebar.appendChild( + (() => { + const heading = document.createElement("h3"); + heading.innerText = data.add_title || "Add component"; + return heading; + })(), + ); + + sidebar.appendChild(document.createElement("hr")); + + const container = document.createElement("div"); + container.className = "flex w-full gap-2 flex-wrap"; + + for (const component of data.components || COMPONENTS) { + container.appendChild( + (() => { + const button = document.createElement("button"); + button.classList.add("secondary"); + + trigger("app::icon", [ + data.icon || "shapes", + "icon", + ]).then((icon) => { + button.prepend(icon); + }); + + button.appendChild( + (() => { + const span = document.createElement("span"); + span.innerText = `${component[0]}${component[2] ? ` (${component[2].length + 1})` : ""}`; + return span; + })(), + ); + + button.addEventListener("click", () => { + if (component[2]) { + // render presets + return this.screen(page, { + back: ["add", {}], + add_title: "Select preset", + components: [ + ["Default", component[1]], + ...component[2], + ], + icon: "type", + }); + } + + // no presets + if ( + page === "element" && + this.current.component === "empty" + ) { + // replace with component + copy_fields(component[1], this.current); + } else { + // add component to children + this.current.children.push( + structuredClone(component[1]), + ); + } + + this.render(); + this.close(); + }); + + return button; + })(), + ); + } + + sidebar.appendChild(container); + } else if (page === "element") { + // edit element + const name = document.createElement("div"); + name.className = "flex flex-col gap-2"; + + name.appendChild( + (() => { + const heading = document.createElement("h3"); + heading.innerText = `Edit ${this.current.component}`; + return heading; + })(), + ); + + name.appendChild( + (() => { + const pos = document.createElement("div"); + pos.className = "notification w-content"; + pos.innerText = this.pointer.get().join("."); + return pos; + })(), + ); + + sidebar.appendChild(name); + sidebar.appendChild(document.createElement("hr")); + + // options + const options = document.createElement("div"); + options.className = "card flex flex-col gap-2 w-full"; + + const add_option = ( + label_text, + name, + valid = [], + input_element = "input", + ) => { + const card = document.createElement("details"); + card.className = "w-full"; + + const summary = document.createElement("summary"); + summary.className = "w-full"; + + const label = document.createElement("label"); + label.setAttribute("for", name); + label.className = "w-full"; + label.innerText = label_text; + label.style.cursor = "pointer"; + + label.addEventListener("click", () => { + // bubble to summary click + summary.click(); + }); + + const input_box = document.createElement("div"); + input_box.style.paddingLeft = "1rem"; + input_box.style.borderLeft = + "solid 2px var(--color-super-lowered)"; + + const input = document.createElement(input_element); + input.id = name; + input.setAttribute("name", name); + input.setAttribute("type", "text"); + + if (input_element === "input") { + input.setAttribute( + "value", + // biome-ignore lint/complexity/useOptionalChain: that's an unsafe change which would possibly break code + (this.current.options || {})[name] || "", + ); + } else { + // biome-ignore lint/complexity/useOptionalChain: that's an unsafe change which would possibly break code + input.innerHTML = (this.current.options || {})[name] || ""; + } + + // biome-ignore lint/complexity/useOptionalChain: that's an unsafe change which would possibly break code + if ((this.current.options || {})[name]) { + // open details if a value is set + card.setAttribute("open", ""); + } + + input.addEventListener("change", (e) => { + if ( + valid.length > 0 && + !valid.includes(e.target.value) && + e.target.value.length > 0 // anything can be set to empty + ) { + alert(`Must be one of: ${JSON.stringify(valid)}`); + return; + } + + if (!this.current.options) { + this.current.options = {}; + } + + this.current.options[name] = + e.target.value === "no" ? "" : e.target.value; + }); + + summary.appendChild(label); + card.appendChild(summary); + input_box.appendChild(input); + card.appendChild(input_box); + options.appendChild(card); + }; + + sidebar.appendChild(options); + + if (this.current.component === "flex") { + add_option("Gap", "gap", ["1", "2", "3", "4"]); + add_option("Direction", "direction", ["row", "col"]); + add_option("Do collapse", "collapse", ["yes", "no"]); + add_option("Width", "width", ["full", "content"]); + add_option("Class name", "class"); + add_option("Unique ID", "id"); + add_option("Style", "style", [], "textarea"); + } else if (this.current.component === "markdown") { + add_option("Content", "text", [], "textarea"); + add_option("Class name", "class"); + } else if (this.current.component === "divider") { + add_option("Class name", "class"); + } else if (this.current.component === "style") { + add_option("Style data", "data", [], "textarea"); + } else { + options.remove(); + } + + // action buttons + const buttons = document.createElement("div"); + buttons.className = "card w-full flex flex-wrap gap-2"; + + if (this.current.component === "flex") { + buttons.appendChild( + (() => { + const button = document.createElement("button"); + + trigger("app::icon", ["plus", "icon"]).then((icon) => { + button.prepend(icon); + }); + + button.appendChild( + (() => { + const span = document.createElement("span"); + span.innerText = "Add child"; + return span; + })(), + ); + + button.addEventListener("click", () => { + this.screen("add"); + }); + + return button; + })(), + ); + } + + buttons.appendChild( + (() => { + const button = document.createElement("button"); + + trigger("app::icon", ["move-up", "icon"]).then((icon) => { + button.prepend(icon); + }); + + button.appendChild( + (() => { + const span = document.createElement("span"); + span.innerText = "Move up"; + return span; + })(), + ); + + button.addEventListener("click", () => { + const idx = this.pointer.get().pop(); + const parent_ref = this.pointer.resolve( + this.json, + ).children; + + if (parent_ref[idx - 1] === undefined) { + alert("No space to move element."); + return; + } + + const clone = JSON.parse(JSON.stringify(this.current)); + const other_clone = JSON.parse( + JSON.stringify(parent_ref[idx - 1]), + ); + + copy_fields(clone, parent_ref[idx - 1]); // move here to here + copy_fields(other_clone, parent_ref[idx]); // move there to here + + this.close(); + this.render(); + }); + + return button; + })(), + ); + + buttons.appendChild( + (() => { + const button = document.createElement("button"); + + trigger("app::icon", ["move-down", "icon"]).then((icon) => { + button.prepend(icon); + }); + + button.appendChild( + (() => { + const span = document.createElement("span"); + span.innerText = "Move down"; + return span; + })(), + ); + + button.addEventListener("click", () => { + const idx = this.pointer.get().pop(); + const parent_ref = this.pointer.resolve( + this.json, + ).children; + + if (parent_ref[idx + 1] === undefined) { + alert("No space to move element."); + return; + } + + const clone = JSON.parse(JSON.stringify(this.current)); + const other_clone = JSON.parse( + JSON.stringify(parent_ref[idx + 1]), + ); + + copy_fields(clone, parent_ref[idx + 1]); // move here to here + copy_fields(other_clone, parent_ref[idx]); // move there to here + + this.close(); + this.render(); + }); + + return button; + })(), + ); + + buttons.appendChild( + (() => { + const button = document.createElement("button"); + button.classList.add("red"); + + trigger("app::icon", ["trash", "icon"]).then((icon) => { + button.prepend(icon); + }); + + button.appendChild( + (() => { + const span = document.createElement("span"); + span.innerText = "Delete"; + return span; + })(), + ); + + button.addEventListener("click", async () => { + if ( + !(await trigger("app::confirm", [ + "Are you sure you would like to do this?", + ])) + ) { + return; + } + + if (this.json === this.current) { + // this is the root element; replace with empty + copy_fields( + COMPONENT_TEMPLATES.EMPTY_COMPONENT, + this.current, + ); + } else { + // get parent + const idx = this.pointer.get().pop(); + const ref = this.pointer.resolve(this.json); + // remove element + ref.children.splice(idx, 1); + } + + this.render(); + this.close(); + }); + + return button; + })(), + ); + + sidebar.appendChild(buttons); + } else if (page === "tree") { + sidebar.innerHTML = this.tree; + } + + sidebar.appendChild(document.createElement("hr")); + + const buttons = document.createElement("div"); + buttons.className = "flex gap-2 flex-wrap"; + + if (data.back) { + buttons.appendChild( + (() => { + const button = document.createElement("button"); + button.className = "secondary"; + + trigger("app::icon", ["arrow-left", "icon"]).then( + (icon) => { + button.prepend(icon); + }, + ); + + button.appendChild( + (() => { + const span = document.createElement("span"); + span.innerText = "Back"; + return span; + })(), + ); + + button.addEventListener("click", () => { + this.screen(...data.back); + }); + + return button; + })(), + ); + } + + buttons.appendChild( + (() => { + const button = document.createElement("button"); + button.className = "red secondary"; + + trigger("app::icon", ["x", "icon"]).then((icon) => { + button.prepend(icon); + }); + + button.appendChild( + (() => { + const span = document.createElement("span"); + span.innerText = "Close"; + return span; + })(), + ); + + button.addEventListener("click", () => { + this.render(); + this.close(); + }); + + return button; + })(), + ); + + sidebar.appendChild(buttons); + + // ... + this.open(); + } +} + +define("ElementPointer", ElementPointer); +define("LayoutEditor", LayoutEditor); diff --git a/crates/app/src/routes/api/v1/auth/connections/stripe.rs b/crates/app/src/routes/api/v1/auth/connections/stripe.rs index 6ef6fcd..b5f746c 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -138,6 +138,15 @@ pub async fn stripe_webhook( return Json(e.into()); } + if data.0.0.security.enable_invite_codes && user.awaiting_purchase { + if let Err(e) = data + .update_user_awaiting_purchased_status(user.id, false, user.clone(), false) + .await + { + return Json(e.into()); + } + } + if let Err(e) = data .create_notification(Notification::new( "Welcome new supporter!".to_string(), @@ -174,6 +183,18 @@ pub async fn stripe_webhook( return Json(e.into()); } + if data.0.0.security.enable_invite_codes && user.was_purchased && user.invite_code == 0 + { + // user doesn't come from an invite code, and is a purchased account + // this means their account must be locked if they stop paying + if let Err(e) = data + .update_user_awaiting_purchased_status(user.id, true, user.clone(), false) + .await + { + return Json(e.into()); + } + } + if let Err(e) = data .create_notification(Notification::new( "Sorry to see you go... :(".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 934f5fc..085844a 100644 --- a/crates/app/src/routes/api/v1/auth/mod.rs +++ b/crates/app/src/routes/api/v1/auth/mod.rs @@ -88,41 +88,46 @@ pub async fn register_request( // check invite code if data.0.0.security.enable_invite_codes { - if props.invite_code.is_empty() { - return ( - None, - Json(Error::MiscError("Missing invite code".to_string()).into()), - ); + if !props.purchase { + if props.invite_code.is_empty() { + return ( + None, + Json(Error::MiscError("Missing invite code".to_string()).into()), + ); + } + + let invite_code = match data.get_invite_code_by_code(&props.invite_code).await { + Ok(c) => c, + Err(e) => return (None, Json(e.into())), + }; + + if invite_code.is_used { + return ( + None, + Json(Error::MiscError("This code has already been used".to_string()).into()), + ); + } + + // let owner = match data.get_user_by_id(invite_code.owner).await { + // Ok(u) => u, + // Err(e) => return (None, Json(e.into())), + // }; + + // if !owner.permissions.check(FinePermission::SUPPORTER) { + // return ( + // None, + // Json( + // Error::MiscError("Invite code owner must be an active supporter".to_string()) + // .into(), + // ), + // ); + // } + + user.invite_code = invite_code.id; + } else { + // this account is being purchased + user.awaiting_purchase = true; } - - let invite_code = match data.get_invite_code_by_code(&props.invite_code).await { - Ok(c) => c, - Err(e) => return (None, Json(e.into())), - }; - - if invite_code.is_used { - return ( - None, - Json(Error::MiscError("This code has already been used".to_string()).into()), - ); - } - - // let owner = match data.get_user_by_id(invite_code.owner).await { - // Ok(u) => u, - // Err(e) => return (None, Json(e.into())), - // }; - - // if !owner.permissions.check(FinePermission::SUPPORTER) { - // return ( - // None, - // Json( - // Error::MiscError("Invite code owner must be an active supporter".to_string()) - // .into(), - // ), - // ); - // } - - user.invite_code = invite_code.id; } // push initial token @@ -133,7 +138,7 @@ pub async fn register_request( match data.create_user(user).await { Ok(_) => { // mark invite as used - if data.0.0.security.enable_invite_codes { + if data.0.0.security.enable_invite_codes && !props.purchase { let invite_code = match data.get_invite_code_by_code(&props.invite_code).await { Ok(c) => c, Err(e) => return (None, Json(e.into())), diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index f66e9bf..75247f1 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -4,8 +4,8 @@ use crate::{ model::{ApiReturn, Error}, routes::api::v1::{ AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken, - UpdateSecondaryUserRole, UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, - UpdateUserUsername, + UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserInviteCode, + UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, UpdateUserUsername, }, State, }; @@ -343,6 +343,34 @@ pub async fn update_user_is_verified_request( } } +/// Update the verification status of the given user. +/// +/// Does not support third-party grants. +pub async fn update_user_awaiting_purchase_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .update_user_awaiting_purchased_status(id, req.awaiting_purchase, user, true) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Awaiting purchase status updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + /// Update the role of the given user. /// /// Does not support third-party grants. @@ -949,3 +977,55 @@ pub async fn self_serve_achievement_request( Err(e) => Json(e.into()), } } + +/// Update the verification status of the given user. +/// +/// Does not support third-party grants. +pub async fn update_user_invite_code_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if req.invite_code.is_empty() { + return Json(Error::MiscError("Missing invite code".to_string()).into()); + } + + let invite_code = match data.get_invite_code_by_code(&req.invite_code).await { + Ok(c) => c, + Err(e) => return Json(e.into()), + }; + + if invite_code.is_used { + return Json(Error::MiscError("This code has already been used".to_string()).into()); + } + + if let Err(e) = data.update_invite_code_is_used(invite_code.id, true).await { + return Json(e.into()); + } + + match data + .update_user_invite_code(user.id, invite_code.id as i64) + .await + { + Ok(_) => { + match data + .update_user_awaiting_purchased_status(user.id, false, user, false) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Invite code updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } + } + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index f207f1c..19a17ca 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -331,6 +331,10 @@ pub fn routes() -> Router { "/auth/user/{id}/verified", post(auth::profile::update_user_is_verified_request), ) + .route( + "/auth/user/{id}/awaiting_purchase", + post(auth::profile::update_user_awaiting_purchase_request), + ) .route( "/auth/user/{id}/totp", post(auth::profile::enable_totp_request), @@ -394,6 +398,10 @@ pub fn routes() -> Router { "/auth/user/me/achievement", post(auth::profile::self_serve_achievement_request), ) + .route( + "/auth/user/me/invite_code", + post(auth::profile::update_user_invite_code_request), + ) // apps .route("/apps", post(apps::create_request)) .route("/apps/{id}/title", post(apps::update_title_request)) @@ -643,6 +651,12 @@ pub struct RegisterProps { pub captcha_response: String, #[serde(default)] pub invite_code: String, + /// If this is true, invite_code should be empty. + /// + /// If invite codes are enabled, but purchase is false, the invite_code MUST + /// be checked and MUST be valid. + #[serde(default)] + pub purchase: bool, } #[derive(Deserialize)] @@ -750,6 +764,11 @@ pub struct UpdateUserIsVerified { pub is_verified: bool, } +#[derive(Deserialize)] +pub struct UpdateUserAwaitingPurchase { + pub awaiting_purchase: bool, +} + #[derive(Deserialize)] pub struct UpdateNotificationRead { pub read: bool, @@ -775,6 +794,11 @@ pub struct UpdateSecondaryUserRole { pub role: SecondaryPermission, } +#[derive(Deserialize)] +pub struct UpdateUserInviteCode { + pub invite_code: String, +} + #[derive(Deserialize)] pub struct DeleteUser { pub password: String, diff --git a/crates/app/src/routes/assets.rs b/crates/app/src/routes/assets.rs index 4a450c5..2e66c19 100644 --- a/crates/app/src/routes/assets.rs +++ b/crates/app/src/routes/assets.rs @@ -19,3 +19,4 @@ serve_asset!(atto_js_request: ATTO_JS("text/javascript")); serve_asset!(me_js_request: ME_JS("text/javascript")); serve_asset!(streams_js_request: STREAMS_JS("text/javascript")); serve_asset!(carp_js_request: CARP_JS("text/javascript")); +serve_asset!(layout_editor_js_request: LAYOUT_EDITOR_JS("text/javascript")); diff --git a/crates/app/src/routes/mod.rs b/crates/app/src/routes/mod.rs index d67dc0c..80f5cbe 100644 --- a/crates/app/src/routes/mod.rs +++ b/crates/app/src/routes/mod.rs @@ -20,6 +20,10 @@ pub fn routes(config: &Config) -> Router { .route("/js/me.js", get(assets::me_js_request)) .route("/js/streams.js", get(assets::streams_js_request)) .route("/js/carp.js", get(assets::carp_js_request)) + .route( + "/js/layout_editor.js", + get(assets::layout_editor_js_request), + ) .nest_service( "/public", get_service(tower_http::services::ServeDir::new(&config.dirs.assets)), diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 7de4cfb..3a3e7d6 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -192,6 +192,8 @@ pub struct StripeConfig { /// /// pub billing_portal_url: String, + /// The text representation of the price of supporter. (like `$4 USD`) + pub supporter_price_text: String, } /// Manuals config (search help, etc) diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index f6fb848..b6a820f 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -112,6 +112,8 @@ impl DataManager { invite_code: get!(x->21(i64)) as usize, secondary_permissions: SecondaryPermission::from_bits(get!(x->22(i32)) as u32).unwrap(), achievements: serde_json::from_str(&get!(x->23(String)).to_string()).unwrap(), + awaiting_purchase: get!(x->24(i32)) as i8 == 1, + was_purchased: get!(x->25(i32)) as i8 == 1, } } @@ -267,7 +269,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24)", + "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26)", params![ &(data.id as i64), &(data.created as i64), @@ -277,7 +279,7 @@ impl DataManager { &serde_json::to_string(&data.settings).unwrap(), &serde_json::to_string(&data.tokens).unwrap(), &(FinePermission::DEFAULT.bits() as i32), - &(if data.is_verified { 1_i32 } else { 0_i32 }), + &if data.is_verified { 1_i32 } else { 0_i32 }, &0_i32, &0_i32, &0_i32, @@ -293,6 +295,8 @@ impl DataManager { &(data.invite_code as i64), &(SecondaryPermission::DEFAULT.bits() as i32), &serde_json::to_string(&data.achievements).unwrap(), + &if data.awaiting_purchase { 1_i32 } else { 0_i32 }, + &if data.was_purchased { 1_i32 } else { 0_i32 }, ] ); @@ -688,6 +692,52 @@ impl DataManager { Ok(()) } + pub async fn update_user_awaiting_purchased_status( + &self, + id: usize, + x: bool, + user: User, + require_permission: bool, + ) -> Result<()> { + if (user.id != id) | require_permission { + if !user.permissions.check(FinePermission::MANAGE_USERS) { + return Err(Error::NotAllowed); + } + } + + let other_user = self.get_user_by_id(id).await?; + + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "UPDATE users SET awaiting_purchase = $1 WHERE id = $2", + params![&{ if x { 1 } else { 0 } }, &(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.cache_clear_user(&other_user).await; + + // create audit log entry + self.create_audit_log_entry(AuditLogEntry::new( + user.id, + format!( + "invoked `update_user_purchased_status` with x value `{}` and y value `{}`", + other_user.id, x + ), + )) + .await?; + + // ... + Ok(()) + } + pub async fn seen_user(&self, user: &User) -> Result<()> { let conn = match self.0.connect().await { Ok(c) => c, @@ -923,6 +973,7 @@ impl DataManager { auto_method!(update_user_connections(UserConnections)@get_user_by_id -> "UPDATE users SET connections = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_associated(Vec)@get_user_by_id -> "UPDATE users SET associated = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_achievements(Vec)@get_user_by_id -> "UPDATE users SET achievements = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); + auto_method!(update_user_invite_code(i64)@get_user_by_id -> "UPDATE users SET invite_code = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); auto_method!(get_user_by_stripe_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE stripe_id = $1" --name="user" --returns=User); auto_method!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql index 9cb0851..3257a2d 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -21,5 +21,7 @@ CREATE TABLE IF NOT EXISTS users ( grants TEXT NOT NULL, associated TEXT NOT NULL, secondary_permissions INT NOT NULL, - achievements TEXT NOT NULL + achievements TEXT NOT NULL, + awaiting_purchase INT NOT NULL, + was_purchased INT NOT NULL ) diff --git a/crates/core/src/database/invite_codes.rs b/crates/core/src/database/invite_codes.rs index 2c6d950..ba31155 100644 --- a/crates/core/src/database/invite_codes.rs +++ b/crates/core/src/database/invite_codes.rs @@ -20,8 +20,8 @@ impl DataManager { } } - auto_method!(get_invite_code_by_id()@get_invite_code_from_row -> "SELECT * FROM invite_codes WHERE id = $1" --name="invite_code" --returns=InviteCode --cache-key-tmpl="atto.invite_code:{}"); - auto_method!(get_invite_code_by_code(&str)@get_invite_code_from_row -> "SELECT * FROM invite_codes WHERE code = $1" --name="invite_code" --returns=InviteCode); + auto_method!(get_invite_code_by_id()@get_invite_code_from_row -> "SELECT * FROM invite_codes WHERE id = $1" --name="invite code" --returns=InviteCode --cache-key-tmpl="atto.invite_code:{}"); + auto_method!(get_invite_code_by_code(&str)@get_invite_code_from_row -> "SELECT * FROM invite_codes WHERE code = $1" --name="invite code" --returns=InviteCode); /// Get invite_codes by `owner`. pub async fn get_invite_codes_by_owner( @@ -96,23 +96,22 @@ impl DataManager { const MAXIMUM_FREE_INVITE_CODES: usize = 4; const MAXIMUM_SUPPORTER_INVITE_CODES: usize = 48; - const MINIMUM_ACCOUNT_AGE_FOR_FREE_INVITE_CODES: usize = 2_629_800_000; // 1mo + const MINIMUM_ACCOUNT_AGE_FOR_INVITE_CODES: usize = 2_629_800_000; // 1mo /// Create a new invite_code in the database. /// /// # Arguments /// * `data` - a mock [`InviteCode`] object to insert pub async fn create_invite_code(&self, data: InviteCode, user: &User) -> Result { - if !user.permissions.check(FinePermission::SUPPORTER) { - // check account creation date - if unix_epoch_timestamp() - user.created - < Self::MINIMUM_ACCOUNT_AGE_FOR_FREE_INVITE_CODES - { - return Err(Error::MiscError( - "Your account is too young to do this".to_string(), - )); - } + // check account creation date + if unix_epoch_timestamp() - user.created < Self::MINIMUM_ACCOUNT_AGE_FOR_INVITE_CODES { + return Err(Error::MiscError( + "Your account is too young to do this".to_string(), + )); + } + // ... + if !user.permissions.check(FinePermission::SUPPORTER) { // our account is old enough, but we need to make sure we don't already have // 2 invite codes if (self.get_invite_codes_by_owner_count(user.id).await? as usize) diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index bac4ae6..91b67d9 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -61,6 +61,15 @@ pub struct User { /// Users collect achievements through little actions across the site. #[serde(default)] pub achievements: Vec, + /// If the account was registered as a "bought" account, the user should not + /// be allowed to actually use the account if they haven't paid for supporter yet. + #[serde(default)] + pub awaiting_purchase: bool, + /// This value cannot be changed after account creation. This value is used to + /// lock the user's account again if the subscription is cancelled and they haven't + /// used an invite code. + #[serde(default)] + pub was_purchased: bool, } pub type UserConnections = @@ -319,6 +328,8 @@ impl User { invite_code: 0, secondary_permissions: SecondaryPermission::DEFAULT, achievements: Vec::new(), + awaiting_purchase: false, + was_purchased: false, } } diff --git a/sql_changes/users_awaiting_purchase.sql b/sql_changes/users_awaiting_purchase.sql new file mode 100644 index 0000000..5d2d565 --- /dev/null +++ b/sql_changes/users_awaiting_purchase.sql @@ -0,0 +1,5 @@ +ALTER TABLE users +ADD COLUMN awaiting_purchase INT NOT NULL DEFAULT 0; + +ALTER TABLE users +ADD COLUMN was_purchased INT NOT NULL DEFAULT 0; From 1dc06112980f2b3010d7d1fc6b0772c30c3b7cf4 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 3 Jul 2025 23:58:42 -0400 Subject: [PATCH 18/93] add: allow published notes to be shown through iframe --- crates/app/src/macros.rs | 8 ++++---- crates/app/src/main.rs | 2 +- crates/app/src/public/css/root.css | 2 +- crates/app/src/routes/pages/journals.rs | 8 +++++++- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 2787330..2c3c03c 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -166,7 +166,7 @@ macro_rules! user_banned { let mut context = initial_context(&$data.0.0.0, lang, &$user).await; context.insert("profile", &$other_user); - return Ok(Html( + return Err(Html( $data.1.render("profile/banned.html", &context).unwrap(), )); }; @@ -233,7 +233,7 @@ macro_rules! check_user_blocked_or_private { .is_ok(), ); - return Ok(Html( + return Err(Html( $data.1.render("profile/blocked.html", &context).unwrap(), )); } @@ -281,7 +281,7 @@ macro_rules! check_user_blocked_or_private { .is_ok(), ); - return Ok(Html( + return Err(Html( $data.1.render("profile/private.html", &context).unwrap(), )); } @@ -293,7 +293,7 @@ macro_rules! check_user_blocked_or_private { context.insert("follow_requested", &false); context.insert("is_following", &false); - return Ok(Html( + return Err(Html( $data.1.render("profile/private.html", &context).unwrap(), )); } diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 52b35be..1236d53 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -130,7 +130,7 @@ async fn main() { ) .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: *"), + HeaderValue::from_static("default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self'; frame-ancestors 'self'"), )) .layer(CatchPanicLayer::new()); diff --git a/crates/app/src/public/css/root.css b/crates/app/src/public/css/root.css index 34281e6..e1c196b 100644 --- a/crates/app/src/public/css/root.css +++ b/crates/app/src/public/css/root.css @@ -267,7 +267,7 @@ span, code { max-width: 100%; overflow-wrap: normal; - text-wrap: pretty; + text-wrap: stable; word-wrap: break-word; } diff --git a/crates/app/src/routes/pages/journals.rs b/crates/app/src/routes/pages/journals.rs index 0c35e04..ff6a738 100644 --- a/crates/app/src/routes/pages/journals.rs +++ b/crates/app/src/routes/pages/journals.rs @@ -362,5 +362,11 @@ pub async fn global_view_request( context.insert("global_mode", &true); // return - Ok(Html(data.1.render("journals/app.html", &context).unwrap())) + Ok(( + [( + "content-security-policy", + "default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self'; frame-ancestors *", + )], + Html(data.1.render("journals/app.html", &context).unwrap()), + )) } From e5b6b5a4d4253e4e6f6693e3fe6d7bd4f64cd3bd Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 4 Jul 2025 17:41:58 -0400 Subject: [PATCH 19/93] fix: duplicated posts in all timeline --- crates/app/src/public/html/timelines/all.lisp | 2 +- .../src/public/html/timelines/swiss_army.lisp | 2 +- crates/app/src/public/js/atto.js | 35 ++++--------------- .../src/routes/api/v1/communities/posts.rs | 2 +- crates/app/src/routes/pages/misc.rs | 6 +++- crates/app/src/routes/pages/mod.rs | 2 ++ crates/core/src/database/auth.rs | 18 +++++----- crates/core/src/database/posts.rs | 8 ++++- crates/core/src/database/stacks.rs | 2 +- 9 files changed, 34 insertions(+), 43 deletions(-) diff --git a/crates/app/src/public/html/timelines/all.lisp b/crates/app/src/public/html/timelines/all.lisp index 7cced78..d739b2e 100644 --- a/crates/app/src/public/html/timelines/all.lisp +++ b/crates/app/src/public/html/timelines/all.lisp @@ -36,7 +36,7 @@ (text "{% set paged = user and user.settings.paged_timelines %}") (script (text "setTimeout(() => { - trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]); + trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?tl=AllPosts&before=$1$&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]); });")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/timelines/swiss_army.lisp b/crates/app/src/public/html/timelines/swiss_army.lisp index 535dbe9..02edda2 100644 --- a/crates/app/src/public/html/timelines/swiss_army.lisp +++ b/crates/app/src/public/html/timelines/swiss_army.lisp @@ -9,7 +9,7 @@ (datalist ("ui_ident" "list_posts_{{ page }}") (text "{% for post in list -%}") - (option ("value" "{{ post[0].id }}")) + (option ("value" "{{ post[0].id }}") ("data-created" "{{ post[0].created }}")) (text "{%- endfor %}")) (text "{% if list|length == 0 -%}") (div diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 6503548..d3b4bbb 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -1212,6 +1212,7 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} self.IO_HAS_LOADED_AT_LEAST_ONCE = false; self.IO_DATA_DISCONNECTED = false; self.IO_DATA_DISABLE_RELOAD = false; + self.IO_DATA_LOAD_BEFORE = 0; if (!paginated_mode) { self.IO_DATA_OBSERVER.observe(self.IO_DATA_MARKER); @@ -1256,7 +1257,9 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} // ... const text = await ( - await fetch(`${self.IO_DATA_TMPL}${self.IO_DATA_PAGE}`) + await fetch( + `${self.IO_DATA_TMPL.replace("&before=$1$", `&before=${self.IO_DATA_LOAD_BEFORE}`)}${self.IO_DATA_PAGE}`, + ) ).text(); self.IO_DATA_WAITING = false; @@ -1291,34 +1294,6 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} self.IO_DATA_ELEMENT.children.length - 1 ].after(self.IO_DATA_MARKER); - // remove posts we've already seen - function remove_elements(id, outer = false) { - let idx = 0; - for (const element of Array.from( - document.querySelectorAll( - `.post${outer ? "_outer" : ""}\\:${id}`, - ), - )) { - if (element.getAttribute("is_repost") === true) { - continue; - } - - 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( @@ -1330,6 +1305,8 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} if (!self.IO_DATA_SEEN_IDS[v]) { self.IO_DATA_SEEN_IDS.push(v); } + + self.IO_DATA_LOAD_BEFORE = opt.getAttribute("data-created"); } }, 150); diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index e2e608e..7ea70e5 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -838,7 +838,7 @@ pub async fn all_request( }; match data - .get_latest_posts(12, props.page, &Some(user.clone())) + .get_latest_posts(12, props.page, &Some(user.clone()), props.before) .await { Ok(posts) => { diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs index 1abc14b..141ec25 100644 --- a/crates/app/src/routes/pages/misc.rs +++ b/crates/app/src/routes/pages/misc.rs @@ -631,6 +631,8 @@ pub struct TimelineQuery { pub tag: String, #[serde(default)] pub paginated: bool, + #[serde(default)] + pub before: usize, } /// `/_swiss_army_timeline` @@ -688,7 +690,9 @@ pub async fn swiss_army_timeline_request( // everything else match req.tl { DefaultTimelineChoice::AllPosts => { - data.0.get_latest_posts(12, req.page, &user).await + data.0 + .get_latest_posts(12, req.page, &user, req.before) + .await } DefaultTimelineChoice::PopularPosts => { data.0.get_popular_posts(12, req.page, 604_800_000).await diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 909fa2d..abc0b32 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -157,6 +157,8 @@ pub async fn render_error( pub struct PaginatedQuery { #[serde(default)] pub page: usize, + #[serde(default)] + pub before: usize, } #[derive(Deserialize)] diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index b6a820f..3cadb2e 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -725,14 +725,16 @@ impl DataManager { self.cache_clear_user(&other_user).await; // create audit log entry - self.create_audit_log_entry(AuditLogEntry::new( - user.id, - format!( - "invoked `update_user_purchased_status` with x value `{}` and y value `{}`", - other_user.id, x - ), - )) - .await?; + if user.id != other_user.id { + self.create_audit_log_entry(AuditLogEntry::new( + user.id, + format!( + "invoked `update_user_purchased_status` with x value `{}` and y value `{}`", + other_user.id, x + ), + )) + .await?; + } // ... Ok(()) diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index 2c5aed8..cc864cd 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -1374,6 +1374,7 @@ impl DataManager { batch: usize, page: usize, as_user: &Option, + before_time: usize, ) -> Result> { let hide_answers: bool = if let Some(user) = as_user { user.settings.all_timeline_hide_answers @@ -1389,7 +1390,12 @@ impl DataManager { let res = query_rows!( &conn, &format!( - "SELECT * FROM posts WHERE replying_to = 0 AND NOT context LIKE '%\"is_nsfw\":true%'{} ORDER BY created DESC LIMIT $1 OFFSET $2", + "SELECT * FROM posts WHERE replying_to = 0{} AND NOT context LIKE '%\"is_nsfw\":true%'{} ORDER BY created DESC LIMIT $1 OFFSET $2", + if before_time > 0 { + format!(" AND created < {before_time}") + } else { + String::new() + }, if hide_answers { " AND context::jsonb->>'answering' = '0'" } else { diff --git a/crates/core/src/database/stacks.rs b/crates/core/src/database/stacks.rs index c4fa5df..6a64b53 100644 --- a/crates/core/src/database/stacks.rs +++ b/crates/core/src/database/stacks.rs @@ -56,7 +56,7 @@ impl DataManager { match stack.sort { StackSort::Created => { self.fill_posts_with_community( - self.get_latest_posts(batch, page, &user).await?, + self.get_latest_posts(batch, page, &user, 0).await?, as_user_id, &ignore_users, user, From 9ba6320d467046a4147208477796cb70db2777a3 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 5 Jul 2025 11:58:51 -0400 Subject: [PATCH 20/93] fix: register page captcha --- crates/app/src/main.rs | 2 +- crates/app/src/routes/pages/journals.rs | 2 +- crates/core/src/database/invite_codes.rs | 12 +++++++----- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 1236d53..75a9c02 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -130,7 +130,7 @@ async fn main() { ) .layer(SetResponseHeaderLayer::if_not_present( HeaderName::from_static("content-security-policy"), - HeaderValue::from_static("default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self'; frame-ancestors 'self'"), + HeaderValue::from_static("default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *.cloudflare.com; frame-ancestors 'self'"), )) .layer(CatchPanicLayer::new()); diff --git a/crates/app/src/routes/pages/journals.rs b/crates/app/src/routes/pages/journals.rs index ff6a738..ca48e78 100644 --- a/crates/app/src/routes/pages/journals.rs +++ b/crates/app/src/routes/pages/journals.rs @@ -365,7 +365,7 @@ pub async fn global_view_request( Ok(( [( "content-security-policy", - "default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self'; frame-ancestors *", + "default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *.cloudflare.com; frame-ancestors *", )], Html(data.1.render("journals/app.html", &context).unwrap()), )) diff --git a/crates/core/src/database/invite_codes.rs b/crates/core/src/database/invite_codes.rs index ba31155..084cfb3 100644 --- a/crates/core/src/database/invite_codes.rs +++ b/crates/core/src/database/invite_codes.rs @@ -103,11 +103,13 @@ impl DataManager { /// # Arguments /// * `data` - a mock [`InviteCode`] object to insert pub async fn create_invite_code(&self, data: InviteCode, user: &User) -> Result { - // check account creation date - if unix_epoch_timestamp() - user.created < Self::MINIMUM_ACCOUNT_AGE_FOR_INVITE_CODES { - return Err(Error::MiscError( - "Your account is too young to do this".to_string(), - )); + // check account creation date (if we aren't a supporter OR this is a purchased account) + if !user.permissions.check(FinePermission::SUPPORTER) | user.was_purchased { + if unix_epoch_timestamp() - user.created < Self::MINIMUM_ACCOUNT_AGE_FOR_INVITE_CODES { + return Err(Error::MiscError( + "Your account is too young to do this".to_string(), + )); + } } // ... From 07a23f505b11d5ed5b310288cd86f40bf5c275b2 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 6 Jul 2025 13:34:20 -0400 Subject: [PATCH 21/93] add: dedicated responses tab for profiles --- crates/app/src/assets.rs | 2 + crates/app/src/langs/en-US.toml | 1 + crates/app/src/public/html/components.lisp | 8 +- crates/app/src/public/html/macros.lisp | 12 ++- .../src/public/html/profile/responses.lisp | 55 +++++++++++++ .../app/src/public/html/profile/settings.lisp | 25 +++++- crates/app/src/public/js/atto.js | 3 +- crates/app/src/public/js/streams.js | 8 +- crates/app/src/routes/api/v1/auth/profile.rs | 6 +- crates/app/src/routes/api/v1/auth/social.rs | 2 +- .../src/routes/api/v1/communities/drafts.rs | 2 +- .../src/routes/api/v1/communities/posts.rs | 10 +-- .../routes/api/v1/communities/questions.rs | 4 +- crates/app/src/routes/api/v1/journals.rs | 2 +- crates/app/src/routes/api/v1/notes.rs | 2 +- crates/app/src/routes/pages/misc.rs | 24 ++++-- crates/app/src/routes/pages/mod.rs | 4 + crates/app/src/routes/pages/profile.rs | 19 ++++- crates/core/src/database/auth.rs | 21 ++++- crates/core/src/database/memberships.rs | 8 +- crates/core/src/database/posts.rs | 80 ++++++++++++++++++- crates/core/src/database/reactions.rs | 10 +-- crates/core/src/database/userfollows.rs | 35 +++++--- crates/core/src/model/auth.rs | 44 +++++++++- 24 files changed, 332 insertions(+), 55 deletions(-) create mode 100644 crates/app/src/public/html/profile/responses.lisp diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 89af907..1bc09ad 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -71,6 +71,7 @@ pub const PROFILE_BANNED: &str = include_str!("./public/html/profile/banned.lisp pub const PROFILE_REPLIES: &str = include_str!("./public/html/profile/replies.lisp"); pub const PROFILE_MEDIA: &str = include_str!("./public/html/profile/media.lisp"); pub const PROFILE_OUTBOX: &str = include_str!("./public/html/profile/outbox.lisp"); +pub const PROFILE_RESPONSES: &str = include_str!("./public/html/profile/responses.lisp"); pub const COMMUNITIES_LIST: &str = include_str!("./public/html/communities/list.lisp"); pub const COMMUNITIES_BASE: &str = include_str!("./public/html/communities/base.lisp"); @@ -370,6 +371,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"profile/replies.html"(crate::assets::PROFILE_REPLIES) --config=config --lisp plugins); write_template!(html_path->"profile/media.html"(crate::assets::PROFILE_MEDIA) --config=config --lisp plugins); write_template!(html_path->"profile/outbox.html"(crate::assets::PROFILE_OUTBOX) --config=config --lisp plugins); + write_template!(html_path->"profile/responses.html"(crate::assets::PROFILE_RESPONSES) --config=config --lisp plugins); write_template!(html_path->"communities/list.html"(crate::assets::COMMUNITIES_LIST) -d "communities" --config=config --lisp plugins); write_template!(html_path->"communities/base.html"(crate::assets::COMMUNITIES_BASE) --config=config --lisp plugins); diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index ea87729..0842e62 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -76,6 +76,7 @@ version = "1.0.0" "auth:label.recent_replies" = "Recent replies" "auth:label.recent_posts_with_media" = "Recent posts (with media)" "auth:label.posts" = "Posts" +"auth:label.responses" = "Answers" "auth:label.replies" = "Replies" "auth:label.media" = "Media" "auth:label.outbox" = "Outbox" diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index a15fe19..626efc0 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -800,7 +800,7 @@ }")) (text "{%- endif %}")) - (text "{% if not is_global and allow_anonymous -%}") + (text "{% if not is_global and allow_anonymous and not user -%}") (div ("class" "flex gap-2 items-center") (input @@ -1155,10 +1155,8 @@ (icon (text "code")) (str (text "general:link.source_code"))) - (a - ("href" "/reference/tetratto/index.html") - ("class" "button") - ("data-turbo" "false") + (button + ("onclick" "trigger('me::achievement_link', ['OpenReference', '/reference/tetratto/index.html'])") (icon (text "rabbit")) (str (text "general:link.reference"))) diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index b2e8863..a554351 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -252,10 +252,17 @@ ("class" "pillmenu") (text "{% if is_self or is_helper or not profile.settings.hide_extra_post_tabs -%}") (a - ("href" "/@{{ profile.username }}") + ("href" "/@{{ profile.username }}?f=true") ("class" "{% if selected == 'posts' -%}active{%- endif %}") (str (text "auth:label.posts"))) + (text "{% if profile.settings.enable_questions -%}") + (a + ("href" "/@{{ profile.username }}?r=true") + ("class" "{% if selected == 'responses' -%}active{%- endif %}") + (str (text "auth:label.responses"))) + (text "{%- endif %}") + (a ("href" "/@{{ profile.username }}/replies") ("class" "{% if selected == 'replies' -%}active{%- endif %}") @@ -311,8 +318,9 @@ (span (text "{{ text \"settings:tab.theme\" }}"))) (a + ("href" "#") ("data-tab-button" "sessions") - ("href" "#/sessions") + ("onclick" "trigger('me::achievement_link', ['OpenSessionSettings', '#/sessions'])") (text "{{ icon \"cookie\" }}") (span (text "{{ text \"settings:tab.sessions\" }}"))) diff --git a/crates/app/src/public/html/profile/responses.lisp b/crates/app/src/public/html/profile/responses.lisp new file mode 100644 index 0000000..868f959 --- /dev/null +++ b/crates/app/src/public/html/profile/responses.lisp @@ -0,0 +1,55 @@ +(text "{% extends \"profile/base.html\" %} {% block content %} {% if profile.settings.enable_questions and (user or profile.settings.allow_anonymous_questions) %}") +(div + ("style" "display: contents") + (text "{{ components::create_question_form(receiver=profile.id, header=profile.settings.motivational_header, drawing_enabled=profile.settings.enable_drawings, allow_anonymous=profile.settings.allow_anonymous_questions) }}")) + +(text "{%- endif %} {% if not tag and pinned|length != 0 -%}") +(div + ("class" "card-nest") + (div + ("class" "card small flex gap-2 items-center") + (text "{{ icon \"pin\" }}") + (span + (text "{{ text \"communities:label.pinned\" }}"))) + (div + ("class" "card flex flex-col gap-4") + (text "{% for post in pinned %} {% if post[2].read_access == \"Everybody\" -%} {% if post[0].context.repost and post[0].context.repost.reposting -%} {{ components::repost(repost=post[3], post=post[0], owner=post[1], secondary=true, community=post[2], show_community=true, can_manage_post=is_self) }} {% else %} {{ components::post(post=post[0], owner=post[1], question=post[4], secondary=true, community=post[2], can_manage_post=is_self, poll=post[5]) }} {%- endif %} {%- endif %} {% endfor %}"))) + +(text "{%- endif %} {{ macros::profile_nav(selected=\"responses\") }}") +(div + ("class" "card-nest") + (div + ("class" "card small flex gap-2 justify-between items-center") + (div + ("class" "flex gap-2 items-center") + (text "{% if not tag -%} {{ icon \"clock\" }}") + (span + (text "{{ text \"auth:label.recent_posts\" }}")) + (text "{% else %} {{ icon \"tag\" }}") + (span + (text "{{ text \"auth:label.recent_with_tag\" }}: ") + (b + (text "{{ tag }}"))) + (text "{%- endif %}")) + (text "{% if user -%}") + (a + ("href" "/search?profile={{ profile.id }}") + ("class" "button lowered small") + (text "{{ icon \"search\" }}") + (span + (text "{{ text \"general:link.search\" }}"))) + (text "{%- endif %}")) + (div + ("class" "card w-full flex flex-col gap-2") + ("ui_ident" "io_data_load") + (div ("ui_ident" "io_data_marker")))) + +(text "{% set paged = user and user.settings.paged_timelines %}") +(script + (text "setTimeout(async () => { + await trigger(\"ui::io_data_load\", [\"/_swiss_army_timeline?user_id={{ profile.id }}&tag={{ tag }}&responses_only=true&page=\", Number.parseInt(\"{{ page }}\") - 1, \"{{ paged }}\" === \"true\"]); + (await ns(\"ui\")).IO_DATA_DISABLE_RELOAD = true; + console.log(\"created profile timeline\"); + }, 1000);")) + +(text "{% endblock %}") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 8acd9c3..b7f0947 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -757,7 +757,29 @@ (text "{{ icon \"check\" }}"))) (span ("class" "fade") - (text "Use an image of 1100x350px for the best results."))))) + (text "Use an image of 1100x350px for the best results.")))) + (div + ("class" "card-nest") + ("ui_ident" "default_profile_page") + (div + ("class" "card small") + (b + (text "Default profile tab"))) + (div + ("class" "card") + (select + ("onchange" "window.SETTING_SET_FUNCTIONS[0]('default_profile_tab', event.target.selectedOptions[0].value)") + (option + ("value" "Posts") + ("selected" "{% if profile.settings.default_profile_tab == 'Posts' -%}true{% else %}false{%- endif %}") + (text "Posts")) + (option + ("value" "Responses") + ("selected" "{% if profile.settings.default_profile_tab == 'Responses' -%}true{% else %}false{%- endif %}") + (text "Responses"))) + (span + ("class" "fade") + (text "This represents the timeline that is shown on your profile by default."))))) (button ("onclick" "save_settings()") ("id" "save_button") @@ -1387,6 +1409,7 @@ \"supporter_ad\", \"change_avatar\", \"change_banner\", + \"default_profile_page\", ]); ui.refresh_container(theme_settings, [ \"supporter_ad\", diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index d3b4bbb..be40e7f 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -1363,7 +1363,8 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} JSON.stringify(accepted_warnings), ); - setTimeout(() => { + setTimeout(async () => { + await trigger("me::achievement", ["AcceptProfileWarning"]); window.history.back(); }, 100); }); diff --git a/crates/app/src/public/js/streams.js b/crates/app/src/public/js/streams.js index 8b9954d..7c5adf7 100644 --- a/crates/app/src/public/js/streams.js +++ b/crates/app/src/public/js/streams.js @@ -43,6 +43,12 @@ }; socket.addEventListener("message", async (event) => { + const sock = await $.sock(stream); + + if (!sock) { + return; + } + if (event.data === "Ping") { return socket.send("Pong"); } @@ -54,7 +60,7 @@ return console.info(`${stream} ${data.data}`); } - return (await $.sock(stream)).events.message(data); + return sock.events.message(data); }); return $.STREAMS[stream]; diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index 75247f1..8104c71 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -154,7 +154,7 @@ pub async fn update_user_settings_request( // award achievement if let Err(e) = data - .add_achievement(&mut user, AchievementName::EditSettings.into()) + .add_achievement(&mut user, AchievementName::EditSettings.into(), true) .await { return Json(e.into()); @@ -500,7 +500,7 @@ pub async fn enable_totp_request( // award achievement if let Err(e) = data - .add_achievement(&mut user, AchievementName::Enable2fa.into()) + .add_achievement(&mut user, AchievementName::Enable2fa.into(), true) .await { return Json(e.into()); @@ -968,7 +968,7 @@ pub async fn self_serve_achievement_request( } // award achievement - match data.add_achievement(&mut user, req.name.into()).await { + match data.add_achievement(&mut user, req.name.into(), true).await { Ok(_) => Json(ApiReturn { ok: true, message: "Achievement granted".to_string(), diff --git a/crates/app/src/routes/api/v1/auth/social.rs b/crates/app/src/routes/api/v1/auth/social.rs index 730746a..b80bd14 100644 --- a/crates/app/src/routes/api/v1/auth/social.rs +++ b/crates/app/src/routes/api/v1/auth/social.rs @@ -62,7 +62,7 @@ pub async fn follow_request( // award achievement if let Err(e) = data - .add_achievement(&mut user, AchievementName::FollowUser.into()) + .add_achievement(&mut user, AchievementName::FollowUser.into(), true) .await { return Json(e.into()); diff --git a/crates/app/src/routes/api/v1/communities/drafts.rs b/crates/app/src/routes/api/v1/communities/drafts.rs index 346a253..75f0948 100644 --- a/crates/app/src/routes/api/v1/communities/drafts.rs +++ b/crates/app/src/routes/api/v1/communities/drafts.rs @@ -27,7 +27,7 @@ pub async fn create_request( // award achievement if let Err(e) = data - .add_achievement(&mut user, AchievementName::CreateDraft.into()) + .add_achievement(&mut user, AchievementName::CreateDraft.into(), true) .await { return Json(e.into()); diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index 7ea70e5..d6554ff 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -181,7 +181,7 @@ pub async fn create_request( // achievements if let Err(e) = data - .add_achievement(&mut user, AchievementName::CreatePost.into()) + .add_achievement(&mut user, AchievementName::CreatePost.into(), true) .await { return Json(e.into()); @@ -189,7 +189,7 @@ pub async fn create_request( if user.post_count >= 49 { if let Err(e) = data - .add_achievement(&mut user, AchievementName::Create50Posts.into()) + .add_achievement(&mut user, AchievementName::Create50Posts.into(), true) .await { return Json(e.into()); @@ -198,7 +198,7 @@ pub async fn create_request( if user.post_count >= 99 { if let Err(e) = data - .add_achievement(&mut user, AchievementName::Create100Posts.into()) + .add_achievement(&mut user, AchievementName::Create100Posts.into(), true) .await { return Json(e.into()); @@ -207,7 +207,7 @@ pub async fn create_request( if user.post_count >= 999 { if let Err(e) = data - .add_achievement(&mut user, AchievementName::Create1000Posts.into()) + .add_achievement(&mut user, AchievementName::Create1000Posts.into(), true) .await { return Json(e.into()); @@ -348,7 +348,7 @@ pub async fn update_content_request( // award achievement if let Err(e) = data - .add_achievement(&mut user, AchievementName::EditPost.into()) + .add_achievement(&mut user, AchievementName::EditPost.into(), true) .await { return Json(e.into()); diff --git a/crates/app/src/routes/api/v1/communities/questions.rs b/crates/app/src/routes/api/v1/communities/questions.rs index 631e5c0..1d1a7ba 100644 --- a/crates/app/src/routes/api/v1/communities/questions.rs +++ b/crates/app/src/routes/api/v1/communities/questions.rs @@ -55,7 +55,7 @@ pub async fn create_request( let mut user = user.clone(); if let Err(e) = data - .add_achievement(&mut user, AchievementName::CreateQuestion.into()) + .add_achievement(&mut user, AchievementName::CreateQuestion.into(), true) .await { return Json(e.into()); @@ -63,7 +63,7 @@ pub async fn create_request( if drawings.len() > 0 { if let Err(e) = data - .add_achievement(&mut user, AchievementName::CreateDrawing.into()) + .add_achievement(&mut user, AchievementName::CreateDrawing.into(), true) .await { return Json(e.into()); diff --git a/crates/app/src/routes/api/v1/journals.rs b/crates/app/src/routes/api/v1/journals.rs index 95ae3da..0b1b394 100644 --- a/crates/app/src/routes/api/v1/journals.rs +++ b/crates/app/src/routes/api/v1/journals.rs @@ -110,7 +110,7 @@ pub async fn create_request( Ok(x) => { // award achievement if let Err(e) = data - .add_achievement(&mut user, AchievementName::CreateJournal.into()) + .add_achievement(&mut user, AchievementName::CreateJournal.into(), true) .await { return Json(e.into()); diff --git a/crates/app/src/routes/api/v1/notes.rs b/crates/app/src/routes/api/v1/notes.rs index ae67c4d..6b274ff 100644 --- a/crates/app/src/routes/api/v1/notes.rs +++ b/crates/app/src/routes/api/v1/notes.rs @@ -198,7 +198,7 @@ pub async fn update_content_request( // award achievement if let Err(e) = data - .add_achievement(&mut user, AchievementName::EditNote.into()) + .add_achievement(&mut user, AchievementName::EditNote.into(), true) .await { return Json(e.into()); diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs index 141ec25..e65f4b5 100644 --- a/crates/app/src/routes/pages/misc.rs +++ b/crates/app/src/routes/pages/misc.rs @@ -464,7 +464,7 @@ pub async fn achievements_request( // award achievement if let Err(e) = data .0 - .add_achievement(&mut user, AchievementName::OpenAchievements.into()) + .add_achievement(&mut user, AchievementName::OpenAchievements.into(), true) .await { return Err(Html(render_error(e, &jar, &data, &None).await)); @@ -633,6 +633,8 @@ pub struct TimelineQuery { pub paginated: bool, #[serde(default)] pub before: usize, + #[serde(default)] + pub responses_only: bool, } /// `/_swiss_army_timeline` @@ -680,11 +682,23 @@ pub async fn swiss_army_timeline_request( check_user_blocked_or_private!(user, other_user, data, jar); if req.tag.is_empty() { - data.0.get_posts_by_user(req.user_id, 12, req.page).await + if req.responses_only { + data.0 + .get_responses_by_user(req.user_id, 12, req.page) + .await + } else { + data.0.get_posts_by_user(req.user_id, 12, req.page).await + } } else { - data.0 - .get_posts_by_user_tag(req.user_id, &req.tag, 12, req.page) - .await + if req.responses_only { + data.0 + .get_responses_by_user_tag(req.user_id, &req.tag, 12, req.page) + .await + } else { + data.0 + .get_posts_by_user_tag(req.user_id, &req.tag, 12, req.page) + .await + } } } else { // everything else diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index abc0b32..6cf5431 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -179,6 +179,10 @@ pub struct ProfileQuery { pub warning: bool, #[serde(default)] pub tag: String, + #[serde(default, alias = "r")] + pub responses_only: bool, + #[serde(default, alias = "f")] + pub force: bool, } #[derive(Deserialize)] diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index ed7adcd..186d291 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -11,7 +11,12 @@ use axum::{ use axum_extra::extract::CookieJar; use serde::Deserialize; use tera::Context; -use tetratto_core::model::{auth::User, communities::Community, permissions::FinePermission, Error}; +use tetratto_core::model::{ + auth::{DefaultProfileTabChoice, User}, + communities::Community, + permissions::FinePermission, + Error, +}; use tetratto_shared::hash::hash; use contrasted::{Color, MINIMUM_CONTRAST_THRESHOLD}; @@ -252,6 +257,10 @@ pub async fn posts_request( check_user_blocked_or_private!(user, other_user, data, jar); + let responses_only = props.responses_only + | (other_user.settings.default_profile_tab == DefaultProfileTabChoice::Responses + && !props.force); + // check for warning if props.warning { let lang = get_lang!(jar, data.0); @@ -356,7 +365,13 @@ pub async fn posts_request( ); // return - Ok(Html(data.1.render("profile/posts.html", &context).unwrap())) + if responses_only { + Ok(Html( + data.1.render("profile/responses.html", &context).unwrap(), + )) + } else { + Ok(Html(data.1.render("profile/posts.html", &context).unwrap())) + } } /// `/@{username}/replies` diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 3cadb2e..fbf229b 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -1,6 +1,8 @@ use super::common::NAME_REGEX; use oiseau::cache::Cache; -use crate::model::auth::{Achievement, AchievementRarity, Notification, UserConnections}; +use crate::model::auth::{ + Achievement, AchievementName, AchievementRarity, Notification, UserConnections, ACHIEVEMENTS, +}; use crate::model::moderation::AuditLogEntry; use crate::model::oauth::AuthGrant; use crate::model::permissions::SecondaryPermission; @@ -764,7 +766,13 @@ impl DataManager { /// Add an achievement to a user. /// /// Still returns `Ok` if the user already has the achievement. - pub async fn add_achievement(&self, user: &mut User, achievement: Achievement) -> Result<()> { + #[async_recursion::async_recursion] + pub async fn add_achievement( + &self, + user: &mut User, + achievement: Achievement, + check_for_final: bool, + ) -> Result<()> { if user.settings.disable_achievements { return Ok(()); } @@ -794,6 +802,15 @@ impl DataManager { self.update_user_achievements(user.id, user.achievements.to_owned()) .await?; + // check for final + if check_for_final { + if user.achievements.len() + 1 == ACHIEVEMENTS { + self.add_achievement(user, AchievementName::GetAllOtherAchievements.into(), false) + .await?; + } + } + + // ... Ok(()) } diff --git a/crates/core/src/database/memberships.rs b/crates/core/src/database/memberships.rs index 01f286b..610d0a0 100644 --- a/crates/core/src/database/memberships.rs +++ b/crates/core/src/database/memberships.rs @@ -242,8 +242,12 @@ impl DataManager { Ok(if data.role.check(CommunityPermission::REQUESTED) { "Join request sent".to_string() } else { - self.add_achievement(&mut user.clone(), AchievementName::JoinCommunity.into()) - .await?; + self.add_achievement( + &mut user.clone(), + AchievementName::JoinCommunity.into(), + true, + ) + .await?; "Community joined".to_string() }) diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index cc864cd..becb780 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -758,6 +758,37 @@ impl DataManager { Ok(res.unwrap()) } + /// Get all posts (that are answering a question) from the given user (from most recent). + /// + /// # Arguments + /// * `id` - the ID of the user the requested posts belong to + /// * `batch` - the limit of posts in each page + /// * `page` - the page number + pub async fn get_responses_by_user( + &self, + id: usize, + batch: usize, + page: usize, + ) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM posts WHERE owner = $1 AND replying_to = 0 AND NOT (context::json->>'is_profile_pinned')::boolean AND is_deleted = 0 AND NOT context::jsonb->>'answering' = '0' ORDER BY created DESC LIMIT $2 OFFSET $3", + &[&(id as i64), &(batch as i64), &((page * batch) as i64)], + |x| { Self::get_post_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("post".to_string())); + } + + Ok(res.unwrap()) + } + /// Calculate the GPA (great post average) of a given user. /// /// To be considered a "great post", a post must have a score ((likes - dislikes) / (likes + dislikes)) @@ -1066,6 +1097,45 @@ impl DataManager { Ok(res.unwrap()) } + /// Get all posts (that are answering a question) from the given user + /// with the given tag (from most recent). + /// + /// # Arguments + /// * `id` - the ID of the user the requested posts belong to + /// * `tag` - the tag to filter by + /// * `batch` - the limit of posts in each page + /// * `page` - the page number + pub async fn get_responses_by_user_tag( + &self, + id: usize, + tag: &str, + batch: usize, + page: usize, + ) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM posts WHERE owner = $1 AND context::json->>'tags' LIKE $2 AND is_deleted = 0 AND NOT context::jsonb->>'answering' = '0' ORDER BY created DESC LIMIT $3 OFFSET $4", + params![ + &(id as i64), + &format!("%\"{tag}\"%"), + &(batch as i64), + &((page * batch) as i64) + ], + |x| { Self::get_post_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("post".to_string())); + } + + Ok(res.unwrap()) + } + /// Get all posts from the given community (from most recent). /// /// # Arguments @@ -1661,8 +1731,12 @@ impl DataManager { } // award achievement - self.add_achievement(&mut owner, AchievementName::CreatePostWithTitle.into()) - .await?; + self.add_achievement( + &mut owner, + AchievementName::CreatePostWithTitle.into(), + true, + ) + .await?; } } @@ -1803,7 +1877,7 @@ impl DataManager { } // award achievement - self.add_achievement(&mut owner, AchievementName::CreateRepost.into()) + self.add_achievement(&mut owner, AchievementName::CreateRepost.into(), true) .await?; } diff --git a/crates/core/src/database/reactions.rs b/crates/core/src/database/reactions.rs index 0a61261..c26c3dc 100644 --- a/crates/core/src/database/reactions.rs +++ b/crates/core/src/database/reactions.rs @@ -162,26 +162,26 @@ impl DataManager { // achievements if user.id != post.owner { let mut owner = self.get_user_by_id(post.owner).await?; - self.add_achievement(&mut owner, AchievementName::Get1Like.into()) + self.add_achievement(&mut owner, AchievementName::Get1Like.into(), true) .await?; if post.likes >= 9 { - self.add_achievement(&mut owner, AchievementName::Get10Likes.into()) + self.add_achievement(&mut owner, AchievementName::Get10Likes.into(), true) .await?; } if post.likes >= 49 { - self.add_achievement(&mut owner, AchievementName::Get50Likes.into()) + self.add_achievement(&mut owner, AchievementName::Get50Likes.into(), true) .await?; } if post.likes >= 99 { - self.add_achievement(&mut owner, AchievementName::Get100Likes.into()) + self.add_achievement(&mut owner, AchievementName::Get100Likes.into(), true) .await?; } if post.dislikes >= 24 { - self.add_achievement(&mut owner, AchievementName::Get25Dislikes.into()) + self.add_achievement(&mut owner, AchievementName::Get25Dislikes.into(), true) .await?; } } diff --git a/crates/core/src/database/userfollows.rs b/crates/core/src/database/userfollows.rs index ffcd891..5428f67 100644 --- a/crates/core/src/database/userfollows.rs +++ b/crates/core/src/database/userfollows.rs @@ -262,33 +262,50 @@ impl DataManager { // check if we're staff if initiator.permissions.check(FinePermission::STAFF_BADGE) { - self.add_achievement(&mut other_user, AchievementName::FollowedByStaff.into()) - .await?; + self.add_achievement( + &mut other_user, + AchievementName::FollowedByStaff.into(), + true, + ) + .await?; } // other achivements - self.add_achievement(&mut other_user, AchievementName::Get1Follower.into()) + self.add_achievement(&mut other_user, AchievementName::Get1Follower.into(), true) .await?; if other_user.follower_count >= 9 { - self.add_achievement(&mut other_user, AchievementName::Get10Followers.into()) - .await?; + self.add_achievement( + &mut other_user, + AchievementName::Get10Followers.into(), + true, + ) + .await?; } if other_user.follower_count >= 49 { - self.add_achievement(&mut other_user, AchievementName::Get50Followers.into()) - .await?; + self.add_achievement( + &mut other_user, + AchievementName::Get50Followers.into(), + true, + ) + .await?; } if other_user.follower_count >= 99 { - self.add_achievement(&mut other_user, AchievementName::Get100Followers.into()) - .await?; + self.add_achievement( + &mut other_user, + AchievementName::Get100Followers.into(), + true, + ) + .await?; } if initiator.following_count >= 9 { self.add_achievement( &mut initiator.clone(), AchievementName::Follow10Users.into(), + true, ) .await?; } diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 91b67d9..2b47562 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -124,6 +124,20 @@ impl DefaultTimelineChoice { } } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum DefaultProfileTabChoice { + /// General posts (in any community) from the user. + Posts, + /// Responses to questions. + Responses, +} + +impl Default for DefaultProfileTabChoice { + fn default() -> Self { + Self::Posts + } +} + #[derive(Clone, Debug, Serialize, Deserialize, Default)] pub struct UserSettings { #[serde(default)] @@ -285,6 +299,9 @@ pub struct UserSettings { /// Automatically hide users that you've blocked on your other accounts from your timelines. #[serde(default)] pub hide_associated_blocked_users: bool, + /// Which tab is shown by default on the user's profile. + #[serde(default)] + pub default_profile_tab: DefaultProfileTabChoice, } fn mime_avif() -> String { @@ -504,10 +521,15 @@ pub struct ExternalConnectionData { } /// The total number of achievements needed to 100% Tetratto! -pub const ACHIEVEMENTS: usize = 30; +pub const ACHIEVEMENTS: usize = 34; /// "self-serve" achievements can be granted by the user through the API. -pub const SELF_SERVE_ACHIEVEMENTS: &[AchievementName] = - &[AchievementName::OpenTos, AchievementName::OpenPrivacyPolicy]; +pub const SELF_SERVE_ACHIEVEMENTS: &[AchievementName] = &[ + AchievementName::OpenReference, + AchievementName::OpenTos, + AchievementName::OpenPrivacyPolicy, + AchievementName::AcceptProfileWarning, + AchievementName::OpenSessionSettings, +]; #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] pub enum AchievementName { @@ -541,6 +563,10 @@ pub enum AchievementName { CreateRepost, OpenTos, OpenPrivacyPolicy, + OpenReference, + GetAllOtherAchievements, + AcceptProfileWarning, + OpenSessionSettings, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -583,6 +609,10 @@ impl AchievementName { Self::CreateRepost => "More than a like or comment...", Self::OpenTos => "Well informed!", Self::OpenPrivacyPolicy => "Privacy conscious", + Self::OpenReference => "What does this do?", + Self::GetAllOtherAchievements => "The final performance", + Self::AcceptProfileWarning => "I accept the risks!", + Self::OpenSessionSettings => "Am I alone in here?", } } @@ -618,6 +648,10 @@ impl AchievementName { Self::CreateRepost => "Create a repost or quote.", Self::OpenTos => "Open the terms of service.", Self::OpenPrivacyPolicy => "Open the privacy policy.", + Self::OpenReference => "Open the source code reference documentation.", + Self::GetAllOtherAchievements => "Get every other achievement.", + Self::AcceptProfileWarning => "Accept a profile warning.", + Self::OpenSessionSettings => "Open your session settings.", } } @@ -655,6 +689,10 @@ impl AchievementName { Self::CreateRepost => Common, Self::OpenTos => Uncommon, Self::OpenPrivacyPolicy => Uncommon, + Self::OpenReference => Uncommon, + Self::GetAllOtherAchievements => Rare, + Self::AcceptProfileWarning => Common, + Self::OpenSessionSettings => Common, } } } From c4de17058b6b5a81b1fff7a620048abd44ed63d8 Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 7 Jul 2025 14:45:30 -0400 Subject: [PATCH 22/93] add: littleweb base --- README.md | 2 + crates/app/src/main.rs | 22 ++- crates/app/src/public/js/atto.js | 15 +- .../routes/api/v1/auth/connections/stripe.rs | 52 ++++++ crates/app/src/routes/api/v1/mod.rs | 4 + crates/app/src/routes/mod.rs | 11 ++ crates/app/src/routes/pages/mod.rs | 4 + crates/core/src/config.rs | 9 + crates/core/src/database/common.rs | 3 + crates/core/src/database/domains.rs | 153 +++++++++++++++++ crates/core/src/database/drivers/common.rs | 3 + .../database/drivers/sql/create_domains.sql | 8 + .../database/drivers/sql/create_layouts.sql | 9 + .../database/drivers/sql/create_services.sql | 7 + crates/core/src/database/mod.rs | 1 + crates/core/src/model/littleweb.rs | 154 ++++++++++++++++++ crates/core/src/model/mod.rs | 1 + crates/core/src/model/permissions.rs | 2 + example/tetratto.toml | 1 + justfile | 4 + 20 files changed, 457 insertions(+), 8 deletions(-) create mode 100644 crates/core/src/database/domains.rs create mode 100644 crates/core/src/database/drivers/sql/create_domains.sql create mode 100644 crates/core/src/database/drivers/sql/create_layouts.sql create mode 100644 crates/core/src/database/drivers/sql/create_services.sql create mode 100644 crates/core/src/model/littleweb.rs diff --git a/README.md b/README.md index e1ac999..d1a3d80 100644 --- a/README.md +++ b/README.md @@ -35,6 +35,8 @@ A `docs` directory will be generated in the same directory that you ran the `tet You can configure your port through the `port` key of the configuration file. You can also run the server with the `PORT` environment variable, which will override whatever is set in the configuration file. You can also use the `CACHE_BREAKER` environment variable to specify a version number to be used in static asset links in order to break cache entries. +You can launch with the `LITTLEWEB=true` environment variable to start the littleweb viewer/fake DNS server. This should be used in combination with `PORT`, as well as set as the `lw_host` in your configuration file. This secondary server is required to allow users to view their littleweb projects. + ## Usage (as a user) Tetratto is very simple once you get the hang of it! At the top of the page (or bottom if you're on mobile), you'll see the navigation bar. Once logged in, you'll be able to access "Home", "Popular", and "Communities" from there! You can also press your profile picture (on the right) to view your own profile, settings, or log out! diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 75a9c02..baad195 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -113,9 +113,22 @@ async fn main() { tera.register_filter("emojis", render_emojis); let client = Client::new(); + let mut app = Router::new(); - let app = Router::new() - .merge(routes::routes(&config)) + // add correct routes + if var("LITTLEWEB").is_ok() { + app = app.merge(routes::lw_routes()); + } else { + app = app + .merge(routes::routes(&config)) + .layer(SetResponseHeaderLayer::if_not_present( + HeaderName::from_static("content-security-policy"), + HeaderValue::from_static("default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *.cloudflare.com; frame-ancestors 'self'"), + )); + } + + // add junk + app = app .layer(Extension(Arc::new(RwLock::new((database, tera, client))))) .layer(axum::extract::DefaultBodyLimit::max( var("BODY_LIMIT") @@ -128,12 +141,9 @@ async fn main() { .make_span_with(trace::DefaultMakeSpan::new().level(Level::INFO)) .on_response(trace::DefaultOnResponse::new().level(Level::INFO)), ) - .layer(SetResponseHeaderLayer::if_not_present( - HeaderName::from_static("content-security-policy"), - HeaderValue::from_static("default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *.cloudflare.com; frame-ancestors 'self'"), - )) .layer(CatchPanicLayer::new()); + // ... let listener = tokio::net::TcpListener::bind(format!("0.0.0.0:{}", config.port)) .await .unwrap(); diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index be40e7f..43a46b8 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -1277,11 +1277,22 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} } if ( - text.includes(`!`) + text.includes( + `!`, + ) || + document.documentElement.innerHTML.includes("observer_disconnect") ) { console.log("io_data_end; disconnect"); self.IO_DATA_OBSERVER.disconnect(); - self.IO_DATA_ELEMENT.innerHTML += text; + + if ( + !document.documentElement.innerHTML.includes( + "observer_disconnect", + ) + ) { + self.IO_DATA_ELEMENT.innerHTML += text; + } + self.IO_DATA_DISCONNECTED = true; return; } 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 b5f746c..e62a0e8 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -207,6 +207,58 @@ pub async fn stripe_webhook( return Json(e.into()); } } + EventType::InvoicePaymentFailed => { + // payment failed + let subscription = match req.data.object { + EventObject::Subscription(c) => c, + _ => unreachable!("cannot be this"), + }; + + let customer_id = subscription.customer.id(); + + let user = match data.get_user_by_stripe_id(customer_id.as_str()).await { + Ok(ua) => ua, + Err(e) => return Json(e.into()), + }; + + tracing::info!( + "unsubscribe (pay fail) {} (stripe: {})", + user.id, + customer_id + ); + let new_user_permissions = user.permissions - FinePermission::SUPPORTER; + + if let Err(e) = data + .update_user_role(user.id, new_user_permissions, user.clone(), true) + .await + { + return Json(e.into()); + } + + if data.0.0.security.enable_invite_codes && user.was_purchased && user.invite_code == 0 + { + // user doesn't come from an invite code, and is a purchased account + // this means their account must be locked if they stop paying + if let Err(e) = data + .update_user_awaiting_purchased_status(user.id, true, user.clone(), false) + .await + { + return Json(e.into()); + } + } + + if let Err(e) = data + .create_notification(Notification::new( + "It seems your recent payment has failed :(".to_string(), + "No worries! Your subscription is still active and will be retried. Your supporter status will resume when you have a successful payment." + .to_string(), + user.id, + )) + .await + { + return Json(e.into()); + } + } _ => return Json(Error::Unknown.into()), } diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 19a17ca..42bcd97 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -635,6 +635,10 @@ pub fn routes() -> Router { .route("/layouts/{id}/pages", post(layouts::update_pages_request)) } +pub fn lw_routes() -> Router { + Router::new() +} + #[derive(Deserialize)] pub struct LoginProps { pub username: String, diff --git a/crates/app/src/routes/mod.rs b/crates/app/src/routes/mod.rs index 80f5cbe..81746d9 100644 --- a/crates/app/src/routes/mod.rs +++ b/crates/app/src/routes/mod.rs @@ -46,3 +46,14 @@ pub fn routes(config: &Config) -> Router { // pages .merge(pages::routes()) } + +/// These routes are only used when you provide the `LITTLEWEB` environment variable. +/// +/// These routes are NOT for editing. These routes are only for viewing littleweb sites. +pub fn lw_routes() -> Router { + Router::new() + // api + .nest("/api/v1", api::v1::lw_routes()) + // pages + .merge(pages::lw_routes()) +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 6cf5431..07bd5a7 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -141,6 +141,10 @@ pub fn routes() -> Router { .route("/x/{note}", get(journals::global_view_request)) } +pub fn lw_routes() -> Router { + Router::new() +} + pub async fn render_error( e: Error, jar: &CookieJar, diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 3a3e7d6..85ff839 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -252,6 +252,10 @@ pub struct Config { /// so this host should be included in there as well. #[serde(default = "default_host")] pub host: String, + /// The main public host of the littleweb server. **Not** used to check against banned hosts, + /// so this host should be included in there as well. + #[serde(default = "default_lw_host")] + pub lw_host: String, /// Database security. #[serde(default = "default_security")] pub security: SecurityConfig, @@ -319,6 +323,10 @@ fn default_host() -> String { String::new() } +fn default_lw_host() -> String { + String::new() +} + fn default_security() -> SecurityConfig { SecurityConfig::default() } @@ -385,6 +393,7 @@ impl Default for Config { port: default_port(), banned_hosts: default_banned_hosts(), host: default_host(), + lw_host: default_lw_host(), database: default_database(), security: default_security(), dirs: default_dirs(), diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index aabb0d3..969b014 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -40,6 +40,9 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_NOTES).unwrap(); execute!(&conn, common::CREATE_TABLE_MESSAGE_REACTIONS).unwrap(); execute!(&conn, common::CREATE_TABLE_INVITE_CODES).unwrap(); + execute!(&conn, common::CREATE_TABLE_LAYOUTS).unwrap(); + execute!(&conn, common::CREATE_TABLE_DOMAINS).unwrap(); + execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap(); self.0 .1 diff --git a/crates/core/src/database/domains.rs b/crates/core/src/database/domains.rs new file mode 100644 index 0000000..0249f6f --- /dev/null +++ b/crates/core/src/database/domains.rs @@ -0,0 +1,153 @@ +use crate::model::{ + auth::User, + littleweb::{Domain, DomainData, DomainTld}, + permissions::{FinePermission, SecondaryPermission}, + Error, Result, +}; +use crate::{auto_method, DataManager}; +use oiseau::{cache::Cache, execute, get, params, query_row, query_rows, PostgresRow}; + +impl DataManager { + /// Get a [`Domain`] from an SQL row. + pub(crate) fn get_domain_from_row(x: &PostgresRow) -> Domain { + Domain { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + name: get!(x->3(String)), + tld: (get!(x->4(String)).as_str()).into(), + data: serde_json::from_str(&get!(x->5(String))).unwrap(), + } + } + + auto_method!(get_domain_by_id(usize as i64)@get_domain_from_row -> "SELECT * FROM domains WHERE id = $1" --name="domain" --returns=Domain --cache-key-tmpl="atto.domain:{}"); + + /// Get a domain given its name and TLD. + /// + /// # Arguments + /// * `name` + /// * `tld` + pub async fn get_domain_by_name_tld(&self, name: &str, tld: &DomainTld) -> Result { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_row!( + &conn, + "SELECT * FROM domains WHERE name = $1 AND tld = $2", + &[&name, &tld.to_string()], + |x| { Ok(Self::get_domain_from_row(x)) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("domain".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get all domains by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch domains for + pub async fn get_domains_by_user(&self, id: usize) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM domains WHERE owner = $1 ORDER BY created DESC", + &[&(id as i64)], + |x| { Self::get_domain_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("domain".to_string())); + } + + Ok(res.unwrap()) + } + + /// Create a new domain in the database. + /// + /// # Arguments + /// * `data` - a mock [`Domain`] object to insert + pub async fn create_domain(&self, data: Domain) -> Result { + // check values + if data.name.len() < 2 { + return Err(Error::DataTooShort("name".to_string())); + } else if data.name.len() > 128 { + return Err(Error::DataTooLong("name".to_string())); + } + + // check for existing + if self + .get_domain_by_name_tld(&data.name, &data.tld) + .await + .is_ok() + { + return Err(Error::MiscError( + "Domain + TLD already in use. Maybe try another TLD!".to_string(), + )); + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO domains VALUES ($1, $2, $3, $4, $5, $6)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &data.name, + &data.tld.to_string(), + &serde_json::to_string(&data.data).unwrap(), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_domain(&self, id: usize, user: &User) -> Result<()> { + let domain = self.get_domain_by_id(id).await?; + + // check user permission + if user.id != domain.owner + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_DOMAINS) + { + return Err(Error::NotAllowed); + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!(&conn, "DELETE FROM domains WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... + self.0.1.remove(format!("atto.domain:{}", id)).await; + Ok(()) + } + + auto_method!(update_domain_data(Vec<(String, DomainData)>)@get_domain_by_id:FinePermission::MANAGE_USERS; -> "UPDATE domains SET data = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.domain:{}"); +} diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index e1cfad7..efa3eae 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -27,3 +27,6 @@ pub const CREATE_TABLE_JOURNALS: &str = include_str!("./sql/create_journals.sql" pub const CREATE_TABLE_NOTES: &str = include_str!("./sql/create_notes.sql"); pub const CREATE_TABLE_MESSAGE_REACTIONS: &str = include_str!("./sql/create_message_reactions.sql"); pub const CREATE_TABLE_INVITE_CODES: &str = include_str!("./sql/create_invite_codes.sql"); +pub const CREATE_TABLE_LAYOUTS: &str = include_str!("./sql/create_layouts.sql"); +pub const CREATE_TABLE_DOMAINS: &str = include_str!("./sql/create_domains.sql"); +pub const CREATE_TABLE_SERVICES: &str = include_str!("./sql/create_services.sql"); diff --git a/crates/core/src/database/drivers/sql/create_domains.sql b/crates/core/src/database/drivers/sql/create_domains.sql new file mode 100644 index 0000000..fc0f190 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_domains.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS domains ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + name TEXT NOT NULL, + tld TEXT NOT NULL, + data TEXT NOT NULL +) diff --git a/crates/core/src/database/drivers/sql/create_layouts.sql b/crates/core/src/database/drivers/sql/create_layouts.sql new file mode 100644 index 0000000..3f28c0a --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_layouts.sql @@ -0,0 +1,9 @@ +CREATE TABLE IF NOT EXISTS layouts ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + title TEXT NOT NULL, + privacy TEXT NOT NULL, + pages TEXT NOT NULL, + replaces TEXT NOT NULL +) diff --git a/crates/core/src/database/drivers/sql/create_services.sql b/crates/core/src/database/drivers/sql/create_services.sql new file mode 100644 index 0000000..78277b5 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_services.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS services ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + name TEXT NOT NULL, + files TEXT NOT NULL +) diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 6877100..5e3cd5b 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -5,6 +5,7 @@ mod channels; mod common; mod communities; pub mod connections; +mod domains; mod drafts; mod drivers; mod emojis; diff --git a/crates/core/src/model/littleweb.rs b/crates/core/src/model/littleweb.rs new file mode 100644 index 0000000..474bec4 --- /dev/null +++ b/crates/core/src/model/littleweb.rs @@ -0,0 +1,154 @@ +use std::fmt::Display; +use serde::{Serialize, Deserialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Service { + pub id: usize, + pub created: usize, + pub owner: usize, + pub name: String, + pub files: Vec, +} + +/// A file type for [`ServiceFsEntry`] structs. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum ServiceFsMime { + #[serde(alias = "text/html")] + Html, + #[serde(alias = "text/css")] + Css, + #[serde(alias = "text/javascript")] + Js, + #[serde(alias = "application/json")] + Json, + #[serde(alias = "text/plain")] + Plain, +} + +impl Display for ServiceFsMime { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Html => "text/html", + Self::Css => "text/css", + Self::Js => "text/javascript", + Self::Json => "application/json", + Self::Plain => "text/plain", + }) + } +} + +/// A single entry in the file system of [`Service`]. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ServiceFsEntry { + pub name: String, + pub mime: ServiceFsMime, + pub children: Vec, + pub content: String, + /// SHA-256 checksum of the entry's content. + pub checksum: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum DomainTld { + Bunny, +} + +impl Display for DomainTld { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Self::Bunny => "bunny", + }) + } +} + +impl From<&str> for DomainTld { + fn from(value: &str) -> Self { + match value { + "bunny" => Self::Bunny, + _ => Self::Bunny, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Domain { + pub id: usize, + pub created: usize, + pub owner: usize, + pub name: String, + pub tld: DomainTld, + /// Data about the domain. This can only be configured by the domain's owner. + /// + /// Maximum of 4 entries. Stored in a structure of `(subdomain string, data)`. + pub data: Vec<(String, DomainData)>, +} + +impl Domain { + /// Get the domain's subdomain, name, TLD, and path segments from a string. + /// + /// If no subdomain is provided, the subdomain will be "@". This means that + /// domain data entries should use "@" as the root service. + pub fn from_str(value: &str) -> (&str, &str, DomainTld, Vec) { + // we're reversing this so it's predictable, as there might not always be a subdomain + // (we shouldn't have the variable entry be first, there is always going to be a tld) + let mut s: Vec<&str> = value.split(".").collect(); + s.reverse(); + let mut s = s.into_iter(); + + let tld = DomainTld::from(s.next().unwrap()); + let domain = s.next().unwrap(); + let subdomain = s.next().unwrap_or("@"); + + // get path + let no_protocol = value.replace("atto://", ""); + let mut chars = no_protocol.chars(); + let mut char = '.'; + + while char != '/' { + // we need to keep eating characters until we reach the first / + // (marking the start of the path) + char = chars.next().unwrap(); + } + + let path: String = chars.collect(); + + // return + ( + subdomain, + domain, + tld, + path.split("/").map(|x| x.to_owned()).collect(), + ) + } + + /// Update an HTML/JS/CSS string with the correct URL for all "atto://" protocol requests. + /// + /// This would not be needed if the JS custom protocol API wasn't awful. + pub fn http_assets(input: String) -> String { + // this is served over the littleweb api NOT the main api! + // + // littleweb requests MUST be on another subdomain so cookies are + // not shared with custom user HTML (since users can embed JS which can make POST requests) + // + // the littleweb routes are used by providing the "LITTLEWEB" env var + input.replace("\"atto://", "/api/v1/over_http?addr=atto://") + } + + /// Get the domain's service ID. + pub fn service(&self, subdomain: &str) -> Option { + let s = self.data.iter().find(|x| x.0 == subdomain)?; + match s.1 { + DomainData::Service(id) => Some(id), + _ => None, + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub enum DomainData { + /// The ID of the service this domain points to. The first service found will + /// always be used. This means having multiple service entires will be useless. + Service(usize), + /// A text entry with a maximum of 512 characters. + Text(String), +} diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index 3ff8379..e825340 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -7,6 +7,7 @@ pub mod communities; pub mod communities_permissions; pub mod journals; pub mod layouts; +pub mod littleweb; pub mod moderation; pub mod oauth; pub mod permissions; diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs index 1584083..55cf9cc 100644 --- a/crates/core/src/model/permissions.rs +++ b/crates/core/src/model/permissions.rs @@ -174,6 +174,8 @@ bitflags! { pub struct SecondaryPermission: u32 { const DEFAULT = 1 << 0; const ADMINISTRATOR = 1 << 1; + const MANAGE_DOMAINS = 1 << 2; + const MANAGE_SERVICES = 1 << 3; const _ = !0; } diff --git a/example/tetratto.toml b/example/tetratto.toml index 0f36100..bc7ff59 100644 --- a/example/tetratto.toml +++ b/example/tetratto.toml @@ -4,6 +4,7 @@ color = "#c9b1bc" port = 4118 banned_hosts = [] host = "http://localhost:4118" +lw_host = "http://localhost:4119" no_track = [] banned_usernames = [ "admin", diff --git a/justfile b/justfile index ad945c9..a83d0c4 100644 --- a/justfile +++ b/justfile @@ -8,3 +8,7 @@ fix: doc: cargo doc --document-private-items --no-deps + +test: + cd example && LITTLEWEB=true PORT=4119 cargo run & + cd example && cargo run From 3fc0872867b668ece6705c5cf05d892904ee754d Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 7 Jul 2025 16:32:18 -0400 Subject: [PATCH 23/93] add: littleweb api + scopes --- crates/app/src/langs/en-US.toml | 3 + .../src/public/html/littleweb/services.lisp | 92 ++++++++++ crates/app/src/routes/api/v1/domains.rs | 164 ++++++++++++++++++ crates/app/src/routes/api/v1/mod.rs | 38 +++- crates/app/src/routes/api/v1/services.rs | 104 +++++++++++ crates/core/src/database/mod.rs | 1 + crates/core/src/database/services.rs | 130 ++++++++++++++ crates/core/src/model/littleweb.rs | 65 +++++-- crates/core/src/model/oauth.rs | 12 ++ 9 files changed, 598 insertions(+), 11 deletions(-) create mode 100644 crates/app/src/public/html/littleweb/services.lisp create mode 100644 crates/app/src/routes/api/v1/domains.rs create mode 100644 crates/app/src/routes/api/v1/services.rs create mode 100644 crates/core/src/database/services.rs diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 0842e62..cfae86e 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -267,3 +267,6 @@ version = "1.0.0" "journals:action.publish" = "Publish" "journals:action.unpublish" = "Unpublish" "journals:action.view" = "View" + +"littleweb:label.create_new" = "Create new site" +"littleweb:label.my_services" = "My services" diff --git a/crates/app/src/public/html/littleweb/services.lisp b/crates/app/src/public/html/littleweb/services.lisp new file mode 100644 index 0000000..e4525ca --- /dev/null +++ b/crates/app/src/public/html/littleweb/services.lisp @@ -0,0 +1,92 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "My stacks - {{ config.name }}")) + +(text "{% endblock %} {% block body %} {{ macros::nav() }}") +(main + ("class" "flex flex-col gap-2") + (text "{{ macros::timelines_nav(selected=\"littleweb\") }} {% if user -%}") + (div + ("class" "card-nest") + (div + ("class" "card small") + (b + (str (text "littleweb:label.create_new")))) + (form + ("class" "card flex flex-col gap-2") + ("onsubmit" "create_service_from_form(event)") + (div + ("class" "flex flex-col gap-1") + (label + ("for" "name") + (text "{{ text \"communities:label.name\" }}")) + (input + ("type" "text") + ("name" "name") + ("id" "name") + ("placeholder" "name") + ("required" "") + ("minlength" "2") + ("maxlength" "32"))) + (button + ("class" "primary") + (text "{{ text \"communities:action.create\" }}")))) + (text "{%- endif %}") + (div + ("class" "card-nest w-full") + (div + ("class" "card small flex items-center justify-between gap-2") + (div + ("class" "flex items-center gap-2") + (icon (text "panel-top")) + (span + (str (text "littleweb:label.my_services"))))) + (div + ("class" "card flex flex-col gap-2") + (text "{% for item in list %}") + (a + ("href" "/services/{{ item.id }}") + ("class" "card secondary flex flex-col gap-2") + (div + ("class" "flex items-center gap-2") + (icon (text "globe")) + (b + (text "{{ item.name }}"))) + (span + (text "Created ") + (span + ("class" "date") + (text "{{ item.created }}")) + (text "; {{ item.files|length }} files"))) + (text "{% endfor %}")))) + +(script + (text "async function create_service_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"services::create\"]); + + fetch(\"/api/v1/services\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + name: e.target.name.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + setTimeout(() => { + window.location.href = `/services/${res.payload}`; + }, 100); + } + }); + }")) + +(text "{% endblock %}") diff --git a/crates/app/src/routes/api/v1/domains.rs b/crates/app/src/routes/api/v1/domains.rs new file mode 100644 index 0000000..aec1a01 --- /dev/null +++ b/crates/app/src/routes/api/v1/domains.rs @@ -0,0 +1,164 @@ +use crate::{ + get_user_from_token, + routes::api::v1::{CreateDomain, UpdateDomainData}, + State, +}; +use axum::{ + extract::{Path, Query}, + response::IntoResponse, + http::StatusCode, + Extension, Json, +}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{littleweb::Domain, oauth, ApiReturn, Error}; +use serde::Deserialize; + +pub async fn get_request( + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + match data.get_domain_by_id(id).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => return Json(e.into()), + } +} + +pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadDomains) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.get_domains_by_user(user.id).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateDomains) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_domain(Domain::new(req.name, req.tld, user.id)) + .await + { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Domain created".to_string(), + payload: x.id.to_string(), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_data_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageDomains) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_domain_data(id, &user, req.data).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Domain updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageDomains) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_domain(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Domain deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +#[derive(Deserialize)] +pub struct GetFileQuery { + pub addr: String, +} + +pub async fn get_file_request( + Extension(data): Extension, + Query(props): Query, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let (subdomain, domain, tld, path) = Domain::from_str(&props.addr); + + // resolve domain + let domain = match data.get_domain_by_name_tld(&domain, &tld).await { + Ok(x) => x, + Err(e) => { + return Err((StatusCode::BAD_REQUEST, e.to_string())); + } + }; + + // resolve service + let service = match domain.service(&subdomain) { + Some(id) => match data.get_service_by_id(id).await { + Ok(x) => x, + Err(e) => { + return Err((StatusCode::BAD_REQUEST, e.to_string())); + } + }, + None => { + return Err(( + StatusCode::NOT_FOUND, + Error::GeneralNotFound("service".to_string()).to_string(), + )); + } + }; + + // resolve file + match service.file(&path) { + Some(f) => Ok(( + [("Content-Type".to_string(), f.mime.to_string())], + f.content, + )), + None => { + return Err(( + StatusCode::NOT_FOUND, + Error::GeneralNotFound("file".to_string()).to_string(), + )); + } + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 42bcd97..59f4353 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -2,6 +2,7 @@ pub mod apps; pub mod auth; pub mod channels; pub mod communities; +pub mod domains; pub mod journals; pub mod layouts; pub mod notes; @@ -9,6 +10,7 @@ pub mod notifications; pub mod reactions; pub mod reports; pub mod requests; +pub mod services; pub mod stacks; pub mod uploads; pub mod util; @@ -28,6 +30,7 @@ use tetratto_core::model::{ communities_permissions::CommunityPermission, journals::JournalPrivacyPermission, layouts::{CustomizablePage, LayoutPage, LayoutPrivacy}, + littleweb::{DomainData, DomainTld, ServiceFsEntry}, oauth::AppScope, permissions::{FinePermission, SecondaryPermission}, reactions::AssetType, @@ -633,10 +636,22 @@ pub fn routes() -> Router { post(layouts::update_privacy_request), ) .route("/layouts/{id}/pages", post(layouts::update_pages_request)) + // services + .route("/services", get(services::list_request)) + .route("/services", post(services::create_request)) + .route("/services/{id}", get(services::get_request)) + .route("/services/{id}", delete(services::delete_request)) + .route("/services/{id}/files", post(services::update_files_request)) + // domains + .route("/domains", get(domains::list_request)) + .route("/domains", post(domains::create_request)) + .route("/domains/{id}", get(domains::get_request)) + .route("/domains/{id}", delete(domains::delete_request)) + .route("/domains/{id}/data", post(domains::update_data_request)) } pub fn lw_routes() -> Router { - Router::new() + Router::new().route("/file", get(domains::get_file_request)) } #[derive(Deserialize)] @@ -1055,3 +1070,24 @@ pub struct UpdateLayoutPrivacy { pub struct UpdateLayoutPages { pub pages: Vec, } + +#[derive(Deserialize)] +pub struct CreateService { + pub name: String, +} + +#[derive(Deserialize)] +pub struct UpdateServiceFiles { + pub files: Vec, +} + +#[derive(Deserialize)] +pub struct CreateDomain { + pub name: String, + pub tld: DomainTld, +} + +#[derive(Deserialize)] +pub struct UpdateDomainData { + pub data: Vec<(String, DomainData)>, +} diff --git a/crates/app/src/routes/api/v1/services.rs b/crates/app/src/routes/api/v1/services.rs new file mode 100644 index 0000000..36895d6 --- /dev/null +++ b/crates/app/src/routes/api/v1/services.rs @@ -0,0 +1,104 @@ +use crate::{ + get_user_from_token, + routes::api::v1::{UpdateServiceFiles, CreateService}, + State, +}; +use axum::{extract::Path, response::IntoResponse, Extension, Json}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{littleweb::Service, oauth, ApiReturn, Error}; + +pub async fn get_request( + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + match data.get_service_by_id(id).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => return Json(e.into()), + } +} + +pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadServices) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.get_services_by_user(user.id).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateServices) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.create_service(Service::new(req.name, user.id)).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Service created".to_string(), + payload: x.id.to_string(), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_files_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageServices) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_service_files(id, &user, req.files).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Service updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageServices) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_service(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Service deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 5e3cd5b..1009797 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -26,6 +26,7 @@ mod questions; mod reactions; mod reports; mod requests; +mod services; mod stackblocks; mod stacks; mod uploads; diff --git a/crates/core/src/database/services.rs b/crates/core/src/database/services.rs new file mode 100644 index 0000000..de67f74 --- /dev/null +++ b/crates/core/src/database/services.rs @@ -0,0 +1,130 @@ +use crate::model::{ + auth::User, + littleweb::{Service, ServiceFsEntry}, + permissions::{FinePermission, SecondaryPermission}, + Error, Result, +}; +use crate::{auto_method, DataManager}; +use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow}; + +impl DataManager { + /// Get a [`Service`] from an SQL row. + pub(crate) fn get_service_from_row(x: &PostgresRow) -> Service { + Service { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + name: get!(x->3(String)), + files: serde_json::from_str(&get!(x->4(String))).unwrap(), + } + } + + auto_method!(get_service_by_id(usize as i64)@get_service_from_row -> "SELECT * FROM services WHERE id = $1" --name="service" --returns=Service --cache-key-tmpl="atto.service:{}"); + + /// Get all services by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch services for + pub async fn get_services_by_user(&self, id: usize) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM services WHERE owner = $1 ORDER BY created DESC", + &[&(id as i64)], + |x| { Self::get_service_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("service".to_string())); + } + + Ok(res.unwrap()) + } + + const MAXIMUM_FREE_SERVICES: usize = 5; + + /// Create a new service in the database. + /// + /// # Arguments + /// * `data` - a mock [`Service`] object to insert + pub async fn create_service(&self, data: Service) -> Result { + // check values + if data.name.len() < 2 { + return Err(Error::DataTooShort("name".to_string())); + } else if data.name.len() > 128 { + return Err(Error::DataTooLong("name".to_string())); + } + + // check number of services + let owner = self.get_user_by_id(data.owner).await?; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + let services = self.get_services_by_user(data.owner).await?; + + if services.len() >= Self::MAXIMUM_FREE_SERVICES { + return Err(Error::MiscError( + "You already have the maximum number of services you can have".to_string(), + )); + } + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO services VALUES ($1, $2, $3, $4, $5)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &data.name, + &serde_json::to_string(&data.files).unwrap(), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_service(&self, id: usize, user: &User) -> Result<()> { + let service = self.get_service_by_id(id).await?; + + // check user permission + if user.id != service.owner + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_SERVICES) + { + return Err(Error::NotAllowed); + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!(&conn, "DELETE FROM services WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... + self.0.1.remove(format!("atto.service:{}", id)).await; + Ok(()) + } + + auto_method!(update_service_files(Vec)@get_service_by_id:FinePermission::MANAGE_USERS; -> "UPDATE services SET data = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.service:{}"); +} diff --git a/crates/core/src/model/littleweb.rs b/crates/core/src/model/littleweb.rs index 474bec4..79cffb1 100644 --- a/crates/core/src/model/littleweb.rs +++ b/crates/core/src/model/littleweb.rs @@ -1,5 +1,6 @@ use std::fmt::Display; use serde::{Serialize, Deserialize}; +use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Service { @@ -10,6 +11,42 @@ pub struct Service { pub files: Vec, } +impl Service { + /// Create a new [`Service`]. + pub fn new(name: String, owner: usize) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + owner, + name, + files: Vec::new(), + } + } + + /// Resolve a file from the virtual file system. + pub fn file(&self, path: &str) -> Option { + let segments = path.chars().filter(|x| x == &'/').count(); + + let mut path = path.split("/"); + let mut path_segment = path.next().unwrap(); + let mut i = 0; + + let mut f = &self.files; + + while let Some(nf) = f.iter().find(|x| x.name == path_segment) { + if i == segments - 1 { + return Some(nf.to_owned()); + } + + f = &nf.children; + path_segment = path.next().unwrap(); + i += 1; + } + + None + } +} + /// A file type for [`ServiceFsEntry`] structs. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum ServiceFsMime { @@ -84,14 +121,28 @@ pub struct Domain { } impl Domain { + /// Create a new [`Domain`]. + pub fn new(name: String, tld: DomainTld, owner: usize) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + owner, + name, + tld, + data: Vec::new(), + } + } + /// Get the domain's subdomain, name, TLD, and path segments from a string. /// /// If no subdomain is provided, the subdomain will be "@". This means that /// domain data entries should use "@" as the root service. - pub fn from_str(value: &str) -> (&str, &str, DomainTld, Vec) { + pub fn from_str(value: &str) -> (String, String, DomainTld, String) { + let no_protocol = value.replace("atto://", ""); + // we're reversing this so it's predictable, as there might not always be a subdomain // (we shouldn't have the variable entry be first, there is always going to be a tld) - let mut s: Vec<&str> = value.split(".").collect(); + let mut s: Vec<&str> = no_protocol.split(".").collect(); s.reverse(); let mut s = s.into_iter(); @@ -100,7 +151,6 @@ impl Domain { let subdomain = s.next().unwrap_or("@"); // get path - let no_protocol = value.replace("atto://", ""); let mut chars = no_protocol.chars(); let mut char = '.'; @@ -113,12 +163,7 @@ impl Domain { let path: String = chars.collect(); // return - ( - subdomain, - domain, - tld, - path.split("/").map(|x| x.to_owned()).collect(), - ) + (subdomain.to_owned(), domain.to_owned(), tld, path) } /// Update an HTML/JS/CSS string with the correct URL for all "atto://" protocol requests. @@ -131,7 +176,7 @@ impl Domain { // not shared with custom user HTML (since users can embed JS which can make POST requests) // // the littleweb routes are used by providing the "LITTLEWEB" env var - input.replace("\"atto://", "/api/v1/over_http?addr=atto://") + input.replace("\"atto://", "/api/v1/file?addr=atto://") } /// Get the domain's service ID. diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index 7d5ebb6..07a23c3 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -70,6 +70,10 @@ pub enum AppScope { UserReadNotes, /// Read the user's layouts. UserReadLayouts, + /// Read the user's domains. + UserReadDomains, + /// Read the user's services. + UserReadServices, /// Create posts as the user. UserCreatePosts, /// Create messages as the user. @@ -90,6 +94,10 @@ pub enum AppScope { UserCreateNotes, /// Create layouts on behalf of the user. UserCreateLayouts, + /// Create domains on behalf of the user. + UserCreateDomains, + /// Create services on behalf of the user. + UserCreateServices, /// Delete posts owned by the user. UserDeletePosts, /// Delete messages owned by the user. @@ -126,6 +134,10 @@ pub enum AppScope { UserManageNotes, /// Manage the user's layouts. UserManageLayouts, + /// Manage the user's domains. + UserManageDomains, + /// Manage the user's services. + UserManageServices, /// Edit posts created by the user. UserEditPosts, /// Edit drafts created by the user. From d67e7c9c3342cbfa6c7960aa964e92dd5b223daa Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 8 Jul 2025 13:35:23 -0400 Subject: [PATCH 24/93] add: littleweb full --- Cargo.lock | 9 +- crates/app/Cargo.toml | 9 +- crates/app/src/assets.rs | 13 + crates/app/src/langs/en-US.toml | 17 +- crates/app/src/main.rs | 2 +- crates/app/src/public/html/body.lisp | 36 ++ crates/app/src/public/html/components.lisp | 10 + .../src/public/html/littleweb/browser.lisp | 211 +++++++++++ .../app/src/public/html/littleweb/domain.lisp | 274 ++++++++++++++ .../src/public/html/littleweb/domains.lisp | 124 +++++++ .../src/public/html/littleweb/service.lisp | 347 ++++++++++++++++++ .../src/public/html/littleweb/services.lisp | 9 +- crates/app/src/public/html/macros.lisp | 2 +- crates/app/src/public/html/root.lisp | 2 + crates/app/src/public/js/atto.js | 5 +- crates/app/src/public/js/proto_links.js | 136 +++++++ crates/app/src/routes/api/v1/domains.rs | 36 +- crates/app/src/routes/api/v1/mod.rs | 19 +- crates/app/src/routes/api/v1/services.rs | 78 +++- crates/app/src/routes/assets.rs | 1 + crates/app/src/routes/mod.rs | 1 + crates/app/src/routes/pages/journals.rs | 2 +- crates/app/src/routes/pages/littleweb.rs | 211 +++++++++++ crates/app/src/routes/pages/mod.rs | 8 + crates/core/Cargo.toml | 8 +- crates/core/src/config.rs | 3 + crates/core/src/database/domains.rs | 40 +- crates/core/src/database/services.rs | 3 +- crates/core/src/model/littleweb.rs | 148 ++++++-- crates/l10n/Cargo.toml | 2 +- crates/shared/Cargo.toml | 2 +- crates/shared/src/markdown.rs | 2 + 32 files changed, 1699 insertions(+), 71 deletions(-) create mode 100644 crates/app/src/public/html/littleweb/browser.lisp create mode 100644 crates/app/src/public/html/littleweb/domain.lisp create mode 100644 crates/app/src/public/html/littleweb/domains.lisp create mode 100644 crates/app/src/public/html/littleweb/service.lisp create mode 100644 crates/app/src/public/js/proto_links.js create mode 100644 crates/app/src/routes/pages/littleweb.rs diff --git a/Cargo.lock b/Cargo.lock index f3f387d..c90535f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3249,7 +3249,7 @@ dependencies = [ [[package]] name = "tetratto" -version = "10.0.0" +version = "11.0.0" dependencies = [ "ammonia", "async-stripe", @@ -3280,7 +3280,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "10.0.0" +version = "11.0.0" dependencies = [ "async-recursion", "base16ct", @@ -3289,6 +3289,7 @@ dependencies = [ "emojis", "md-5", "oiseau", + "paste", "pathbufd", "regex", "reqwest", @@ -3302,7 +3303,7 @@ dependencies = [ [[package]] name = "tetratto-l10n" -version = "10.0.0" +version = "11.0.0" dependencies = [ "pathbufd", "serde", @@ -3311,7 +3312,7 @@ dependencies = [ [[package]] name = "tetratto-shared" -version = "10.0.0" +version = "11.0.0" dependencies = [ "ammonia", "chrono", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 8a775e8..8a7eb6e 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto" -version = "10.0.0" +version = "11.0.0" edition = "2024" [dependencies] @@ -9,7 +9,12 @@ serde = { version = "1.0.219", features = ["derive"] } tera = "1.20.0" tracing = "0.1.41" tracing-subscriber = { version = "0.3.19", features = ["env-filter"] } -tower-http = { version = "0.6.6", features = ["trace", "fs", "catch-panic", "set-header"] } +tower-http = { version = "0.6.6", features = [ + "trace", + "fs", + "catch-panic", + "set-header", +] } axum = { version = "0.8.4", features = ["macros", "ws"] } tokio = { version = "1.45.1", features = ["macros", "rt-multi-thread"] } axum-extra = { version = "0.10.1", features = ["cookie", "multipart"] } diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 1bc09ad..81671fe 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -41,6 +41,7 @@ pub const ME_JS: &str = include_str!("./public/js/me.js"); pub const STREAMS_JS: &str = include_str!("./public/js/streams.js"); pub const CARP_JS: &str = include_str!("./public/js/carp.js"); pub const LAYOUT_EDITOR_JS: &str = include_str!("./public/js/layout_editor.js"); +pub const PROTO_LINKS_JS: &str = include_str!("./public/js/proto_links.js"); // html pub const BODY: &str = include_str!("./public/html/body.lisp"); @@ -133,6 +134,12 @@ pub const DEVELOPER_LINK: &str = include_str!("./public/html/developer/link.lisp pub const JOURNALS_APP: &str = include_str!("./public/html/journals/app.lisp"); +pub const LITTLEWEB_SERVICES: &str = include_str!("./public/html/littleweb/services.lisp"); +pub const LITTLEWEB_DOMAINS: &str = include_str!("./public/html/littleweb/domains.lisp"); +pub const LITTLEWEB_SERVICE: &str = include_str!("./public/html/littleweb/service.lisp"); +pub const LITTLEWEB_DOMAIN: &str = include_str!("./public/html/littleweb/domain.lisp"); +pub const LITTLEWEB_BROWSER: &str = include_str!("./public/html/littleweb/browser.lisp"); + // langs pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); @@ -428,6 +435,12 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"journals/app.html"(crate::assets::JOURNALS_APP) -d "journals" --config=config --lisp plugins); + write_template!(html_path->"littleweb/services.html"(crate::assets::LITTLEWEB_SERVICES) -d "littleweb" --config=config --lisp plugins); + write_template!(html_path->"littleweb/domains.html"(crate::assets::LITTLEWEB_DOMAINS) --config=config --lisp plugins); + write_template!(html_path->"littleweb/service.html"(crate::assets::LITTLEWEB_SERVICE) --config=config --lisp plugins); + write_template!(html_path->"littleweb/domain.html"(crate::assets::LITTLEWEB_DOMAIN) --config=config --lisp plugins); + write_template!(html_path->"littleweb/browser.html"(crate::assets::LITTLEWEB_BROWSER) --config=config --lisp plugins); + html_path } diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index cfae86e..a852d5a 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -18,6 +18,7 @@ version = "1.0.0" "general:link.search" = "Search" "general:link.journals" = "Journals" "general:link.achievements" = "Achievements" +"general:link.little_web" = "Little web" "general:action.save" = "Save" "general:action.delete" = "Delete" "general:action.purge" = "Purge" @@ -29,6 +30,7 @@ version = "1.0.0" "general:action.open" = "Open" "general:action.view" = "View" "general:action.copy_link" = "Copy link" +"general:action.copy_id" = "Copy ID" "general:action.post" = "Post" "general:label.account" = "Account" "general:label.safety" = "Safety" @@ -269,4 +271,17 @@ version = "1.0.0" "journals:action.view" = "View" "littleweb:label.create_new" = "Create new site" -"littleweb:label.my_services" = "My services" +"littleweb:label.create_new_domain" = "Create new domain" +"littleweb:label.my_services" = "My sites" +"littleweb:label.my_domains" = "My domains" +"littleweb:label.browser" = "Browser" +"littleweb:label.tld" = "Top-level domain" +"littleweb:label.services" = "Sites" +"littleweb:label.domains" = "Domains" +"littleweb:label.domain_data" = "Domain data" +"littleweb:label.type" = "Type" +"littleweb:label.name" = "Name" +"littleweb:label.value" = "Value" +"littleweb:action.edit_site_name" = "Edit site name" +"littleweb:action.rename" = "Rename" +"littleweb:action.add" = "Add" diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index baad195..00ad85f 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -123,7 +123,7 @@ async fn main() { .merge(routes::routes(&config)) .layer(SetResponseHeaderLayer::if_not_present( HeaderName::from_static("content-security-policy"), - HeaderValue::from_static("default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *.cloudflare.com; frame-ancestors 'self'"), + HeaderValue::from_static("default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; worker-src * blob:; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *; frame-ancestors 'self'"), )); } diff --git a/crates/app/src/public/html/body.lisp b/crates/app/src/public/html/body.lisp index afc41b4..0e7caf9 100644 --- a/crates/app/src/public/html/body.lisp +++ b/crates/app/src/public/html/body.lisp @@ -94,6 +94,8 @@ atto[\"hooks::spotify_time_text\"](); // spotify durations atto[\"hooks::verify_emoji\"](); + fix_atto_links(); + if (document.getElementById(\"tokens\")) { trigger(\"me::render_token_picker\", [ document.getElementById(\"tokens\"), @@ -163,6 +165,40 @@ (icon (text "x")) (str (text "dialog:action.cancel")))))) +(dialog + ("id" "littleweb") + (div + ("class" "inner flex flex-col gap-2") + + (a + ("class" "button w-full lowered justify-start") + ("href" "/net") + (icon (text "globe")) + (str (text "littleweb:label.browser"))) + + (a + ("class" "button w-full lowered justify-start") + ("href" "/services") + (icon (text "panel-top")) + (str (text "littleweb:label.my_services"))) + + (a + ("class" "button w-full lowered justify-start") + ("href" "/domains") + (icon (text "panel-top")) + (str (text "littleweb:label.my_domains"))) + + (hr ("class" "margin")) + (div + ("class" "flex gap-2 justify-between") + (div null?) + (button + ("class" "lowered red") + ("type" "button") + ("onclick", "document.getElementById('littleweb').close()") + (icon (text "x")) + (str (text "dialog:action.cancel")))))) + (dialog ("id" "web_api_prompt") (div diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 626efc0..156875e 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -1112,6 +1112,12 @@ ("href" "/journals/0/0") (icon (text "notebook")) (str (text "general:link.journals"))) + (text "{% if config.lw_host -%}") + (button + ("onclick" "document.getElementById('littleweb').showModal()") + (icon (text "globe")) + (str (text "general:link.little_web"))) + (text "{%- endif %}") (text "{% if not user.settings.disable_achievements -%}") (a ("href" "/achievements") @@ -2333,6 +2339,10 @@ (text "Create infinite notes in each journal")) (li (text "Publish up to 50 notes")) + (li + (text "Create infinite Littleweb sites")) + (li + (text "Create infinite Littleweb domains")) (text "{% if config.security.enable_invite_codes -%}") (li diff --git a/crates/app/src/public/html/littleweb/browser.lisp b/crates/app/src/public/html/littleweb/browser.lisp new file mode 100644 index 0000000..76ac82b --- /dev/null +++ b/crates/app/src/public/html/littleweb/browser.lisp @@ -0,0 +1,211 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "{{ config.name }}")) + +(text "{% endblock %} {% block body %}") +(div + ("id" "panel") + ("class" "flex flex-row gap-2") + (a + ("class" "button camo") + ("href" "/") + (icon (text "house"))) + + (button + ("class" "lowered") + ("onclick" "back()") + (icon (text "arrow-left"))) + + (button + ("class" "lowered") + ("onclick" "forward()") + (icon (text "arrow-right"))) + + (button + ("class" "lowered") + ("onclick" "reload()") + (icon (text "rotate-cw"))) + + (form + ("class" "w-full flex gap-1 flex-row") + ("onsubmit" "event.preventDefault(); littleweb_navigate(event.target.uri.getAttribute('true_value'))") + (input + ("type" "uri") + ("class" "w-full") + ("true_value" "{{ path }}") + ("name" "uri") + ("id" "uri")) + + (button ("class" "lowered small square") (icon (text "arrow-right")))) + + (text "{% if user -%}") + (div + ("class" "dropdown") + (button + ("class" "flex-row camo") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("style" "gap: var(--pad-1) !important") + (text "{{ components::avatar(username=user.username, size=\"24px\") }}") + (icon_class (text "chevron-down") (text "dropdown-arrow"))) + + (text "{{ components::user_menu() }}")) + (text "{%- endif %}")) + +(iframe + ("id" "browser_iframe") + ("frameborder" "0") + ("src" "{% if path -%} {{ config.lw_host }}/api/v1/net/{{ path }} {%- endif %}")) + +(style + ("data-turbo-temporary" "true") + (text ":root { + --panel-height: 45px; + } + + html, + body { + padding: 0; + margin: 0; + overflow: hidden; + } + + #panel { + width: 100dvw; + height: var(--panel-height); + padding: var(--pad-2); + } + + #panel input { + border: none; + background: var(--color-lowered); + transition: background 0.15s; + } + + #panel input:focus { + background: var(--color-super-lowered); + } + + @media screen and (max-width: 900px) { + #panel input:focus { + position: fixed; + width: calc(100dvw - (62px + var(--pad-2) * 2)) !important; + left: var(--pad-2); + } + } + + #panel button:not(.inner *), + #panel a.button:not(.inner *), + #panel input { + --h: 28.2px; + height: var(--h); + min-height: var(--h); + max-height: var(--h); + font-size: 14px; + } + + #panel button:not(.inner *), + #panel a.button:not(.inner *) { + padding: var(--pad-1) var(--pad-2); + } + + iframe { + width: 100dvw; + height: calc(100dvh - var(--panel-height)); + }")) + +(script + (text "function littleweb_navigate(uri) { + if (!uri.includes(\".html\")) { + uri = `${uri}/index.html`; + } + + if (!uri.startsWith(\"atto://\")) { + uri = `atto://${uri}`; + } + + // ... + console.log(\"navigate\", uri); + document.getElementById(\"browser_iframe\").src = `{{ config.lw_host|safe }}/api/v1/net/${uri}`; + } + + document.getElementById(\"browser_iframe\").addEventListener(\"load\", (e) => { + console.log(\"web content loaded\"); + }); + + window.addEventListener(\"message\", (e) => { + if (typeof e.data !== \"string\") { + console.log(\"refuse message (bad type)\"); + return; + } + + const data = JSON.parse(e.data); + + if (!data.t) { + console.log(\"refuse message (not for tetratto)\"); + return; + } + + console.log(\"received message\"); + + if (data.event === \"change_url\") { + const uri = new URL(data.target).pathname.slice(\"/api/v1/net/\".length); + window.history.pushState(null, null, `/net/${uri.replace(\"atto://\", \"\")}`); + document.getElementById(\"uri\").setAttribute(\"true_value\", uri); + } + }); + + function back() { + post_message({ t: true, event: \"back\" }); + } + + function forward() { + post_message({ t: true, event: \"forward\" }); + } + + function reload() { + post_message({ t: true, event: \"reload\" }); + } + + function post_message(data) { + const origin = new URL(document.getElementById(\"browser_iframe\").src).origin; + document.getElementById(\"browser_iframe\").contentWindow.postMessage(JSON.stringify(data), origin); + } + + // handle dropdowns + window.addEventListener(\"blur\", () => { + trigger(\"atto::hooks::dropdown.close\"); + }); + + // url bar focus + document.getElementById(\"uri\").addEventListener(\"input\", (e) => { + e.target.setAttribute(\"true_value\", e.target.value); + }); + + let is_focused = false; + + document.getElementById(\"uri\").addEventListener(\"mouseenter\", (e) => { + e.target.value = e.target.getAttribute(\"true_value\").replace(\"/index.html\", \"\"); + }); + + document.getElementById(\"uri\").addEventListener(\"mouseleave\", (e) => { + if (is_focused) { + return; + } + + e.target.value = e.target.getAttribute(\"true_value\").replace(\"atto://\", \"\").split(\"/\")[0]; + }); + + document.getElementById(\"uri\").addEventListener(\"focus\", (e) => { + e.target.value = e.target.getAttribute(\"true_value\").replace(\"/index.html\", \"\"); + is_focused = true; + }); + + document.getElementById(\"uri\").addEventListener(\"blur\", (e) => { + e.target.value = e.target.getAttribute(\"true_value\").replace(\"atto://\", \"\").split(\"/\")[0]; + is_focused = false; + }); + + document.getElementById(\"uri\").value = document.getElementById(\"uri\").getAttribute(\"true_value\").replace(\"atto://\", \"\").split(\"/\")[0]")) + +(text "{% endblock %}") diff --git a/crates/app/src/public/html/littleweb/domain.lisp b/crates/app/src/public/html/littleweb/domain.lisp new file mode 100644 index 0000000..d8b01f1 --- /dev/null +++ b/crates/app/src/public/html/littleweb/domain.lisp @@ -0,0 +1,274 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "My services - {{ config.name }}")) + +(text "{% endblock %} {% block body %} {{ macros::nav() }}") +(main + ("class" "flex flex-col gap-2") + (text "{% if user -%}") + (div + ("class" "pillmenu") + (a ("href" "/services") (str (text "littleweb:label.services"))) + (a ("href" "/domains") ("class" "active") (str (text "littleweb:label.domains")))) + + (div + ("class" "card-nest") + (div + ("class" "card small") + (b + (text "{{ domain.name }}.{{ domain.tld|lower }}"))) + (div + ("class" "flex flex-col gap-2 card") + (code + ("class" "w-content") + (a + ("href" "atto://{{ domain.name }}.{{ domain.tld|lower }}") + (text "atto://{{ domain.name }}.{{ domain.tld|lower }}"))) + (div + ("class" "flex gap-2 flex-wrap") + (button + ("class" "red lowered") + ("onclick" "delete_domain()") + (icon (text "trash")) + (str (text "general:action.delete")))))) + (text "{%- endif %}") + (div + ("class" "card-nest w-full") + (div + ("class" "card small flex flex-col gap-2") + (div + ("class" "flex items-center justify-between gap-2") + (div + ("class" "flex items-center gap-2") + (icon (text "panel-top")) + (span + (str (text "littleweb:label.domain_data")))) + + (div + ("class" "flex gap-2") + (button + ("class" "small lowered") + ("title" "Help") + ("onclick" "document.getElementById('domain_help').classList.toggle('hidden')") + (icon (text "circle-question-mark"))) + + (button + ("class" "small") + ("onclick" "document.getElementById('add_data').classList.toggle('hidden')") + (icon (text "plus")) + (str (text "littleweb:action.add"))))) + + (div + ("class" "card w-full lowered flex flex-col gap-2 hidden no_p_margin") + ("id" "domain_help") + (p (text "To link your domain to a site, go to the site and press \"Copy ID\".")) + (p (text "After you have the site's ID, click \"Add\" on this page, then paste the ID into the \"value\" field.")) + (p (text "If you've ever managed a real domain's DNS, this should be familiar.")))) + (div + ("class" "card flex flex-col gap-2") + ; add data + (form + ("id" "add_data") + ("class" "card hidden w-full lowered flex flex-col gap-2") + ("onsubmit" "add_data_from_form(event)") + (div + ("class" "flex gap-2") + (div + ("class" "flex w-full flex-col gap-1") + (label + ("for" "name") + (str (text "littleweb:label.type"))) + (select + ("type" "text") + ("name" "type") + ("id" "type") + ("placeholder" "type") + ("required" "") + (option ("value" "Service") (text "Site ID")) + (option ("value" "Text") (text "Text")))) + (div + ("class" "flex w-full flex-col gap-1") + (label + ("for" "name") + (str (text "littleweb:label.name"))) + (input + ("type" "text") + ("name" "name") + ("id" "name") + ("placeholder" "name") + ("minlength" "1") + ("maxlength" "32")) + (span ("class" "fade") (text "Use \"@\" for root."))) + (div + ("class" "flex w-full flex-col gap-1") + (label + ("for" "value") + (str (text "littleweb:label.value"))) + (input + ("type" "text") + ("name" "value") + ("id" "value") + ("placeholder" "value") + ("required" "") + ("minlength" "2") + ("maxlength" "256")))) + (div + ("class" "flex w-full justify-between") + (div) + (button + (icon (text "check")) + (str (text "general:action.save"))))) + ; data + (table + (thead + (tr + (th (text "Name")) + (th (text "Type")) + (th (text "Value")) + (th (text "Actions")))) + + (tbody + (text "{% for item in domain.data -%}") + (tr + (td (text "{{ item[0] }}")) + (text "{% for k,v in item[1] -%}") + (td (text "{{ k }}")) + (td (text "{{ v }}")) + (text "{%- endfor %}") + (td + ("style" "overflow: auto") + (div + ("class" "dropdown") + (button + ("class" "camo small") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + (icon (text "ellipsis"))) + (div + ("class" "inner") + (button + ("onclick" "rename_data('{{ item[0] }}')") + (icon (text "pencil")) + (str (text "littleweb:action.rename"))) + + (button + ("class" "red") + ("onclick" "remove_data('{{ item[0] }}')") + (icon (text "trash")) + (str (text "general:action.delete"))))))) + (text "{%- endfor %}")))))) + +(script ("id" "domain_data") ("type" "application/json") (text "{{ domain.data|json_encode()|safe }}")) +(script + (text "globalThis.DOMAIN_DATA = JSON.parse(document.getElementById(\"domain_data\").innerText); + async function save_data() { + await trigger(\"atto::debounce\", [\"domains::update_data\"]); + fetch(\"/api/v1/domains/{{ domain.id }}/data\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + data: DOMAIN_DATA, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function add_data_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"domains::add_data\"]); + + const x = {}; + x[e.target.type.selectedOptions[0].value] = e.target.value.value; + + if (e.target.name.value === \"\") { + e.target.name.value = \"@\"; + } + + const name = e.target.name.value.replace(\" \", \"_\"); + if (DOMAIN_DATA.find((x) => x[0] === name)) { + return; + } + + DOMAIN_DATA.push([name, x]); + await save_data(); + e.target.reset(); + } + + async function delete_data(name) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"domains::delete_data\"]); + + delete DOMAIN_DATA.find((x) => x[0] === name); + await save_data(); + } + + async function delete_domain() { + await trigger(\"atto::debounce\", [\"domains::delete\"]); + + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/domains/{{ domain.id }}\", { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function rename_data(selector) { + await trigger(\"atto::debounce\", [\"domains::rename_data\"]); + + let name = await trigger(\"atto::prompt\", [\"New name:\"]); + + if (!name) { + return; + } + + DOMAIN_DATA.find((x) => x[0] === selector)[0] = name.replaceAll(\" \", \"_\"); + await save_data(); + + setTimeout(() => { + window.location.reload(); + }, 150); + } + + async function remove_data(name) { + await trigger(\"atto::debounce\", [\"domains::remove_data\"]); + + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + let i = 0; + DOMAIN_DATA.find((x) => { + i += 1; + return x[0] === name; + }); + + DOMAIN_DATA.splice(i - 1, 1); + await save_data(); + }")) + +(text "{% endblock %}") diff --git a/crates/app/src/public/html/littleweb/domains.lisp b/crates/app/src/public/html/littleweb/domains.lisp new file mode 100644 index 0000000..1a9b649 --- /dev/null +++ b/crates/app/src/public/html/littleweb/domains.lisp @@ -0,0 +1,124 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "My domains - {{ config.name }}")) + +(text "{% endblock %} {% block body %} {{ macros::nav() }}") +(main + ("class" "flex flex-col gap-2") + (text "{% if user -%}") + (div + ("class" "pillmenu") + (a ("href" "/services") (str (text "littleweb:label.services"))) + (a ("href" "/domains") ("class" "active") (str (text "littleweb:label.domains")))) + + (div + ("class" "card-nest") + (div + ("class" "card small") + (b + (str (text "littleweb:label.create_new_domain")))) + (form + ("class" "card flex flex-col gap-2") + ("onsubmit" "create_domain_from_form(event)") + (div + ("class" "flex flex-col gap-1") + (label + ("for" "name") + (text "{{ text \"communities:label.name\" }}")) + (input + ("type" "text") + ("name" "name") + ("id" "name") + ("placeholder" "name") + ("required" "") + ("minlength" "2") + ("maxlength" "32"))) + (div + ("class" "flex flex-col gap-1") + (label + ("for" "tld") + (str (text "littleweb:label.tld"))) + (select + ("type" "text") + ("name" "tld") + ("id" "tld") + ("placeholder" "tld") + ("required" "") + (text "{% for tld in tlds -%}") + (option ("value" "{{ tld }}") (text ".{{ tld|lower }}")) + (text "{%- endfor %}"))) + (button + ("class" "primary") + (text "{{ text \"communities:action.create\" }}")) + + (details + (summary + (icon (text "circle-alert")) + (text "Disclaimer")) + + (div + ("class" "card lowered no_p_margin") + (p (text "Domains are registered into {{ config.name }}'s closed web.")) + (p (text "This means that domains are only accessible through {{ config.name }}, as well as other supporting sites.")) + (p (text "If you would prefer a public-facing domain, those cost money and cannot be bought from {{ config.name }}.")) + (p (text "All domains have first-class support on {{ config.name }}, meaning all links to them will work properly on this site.")))))) + (text "{%- endif %}") + (div + ("class" "card-nest w-full") + (div + ("class" "card small flex items-center justify-between gap-2") + (div + ("class" "flex items-center gap-2") + (icon (text "panel-top")) + (span + (str (text "littleweb:label.my_domains"))))) + (div + ("class" "card flex flex-col gap-2") + (text "{% for item in list %}") + (a + ("href" "/domains/{{ item.id }}") + ("class" "card secondary flex flex-col gap-2") + (div + ("class" "flex items-center gap-2") + (icon (text "globe")) + (b + (text "{{ item.name }}.{{ item.tld|lower }}"))) + (span + (text "Created ") + (span + ("class" "date") + (text "{{ item.created }}")) + (text "; {{ item.data|length }} entries"))) + (text "{% endfor %}")))) + +(script + (text "async function create_domain_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"domains::create\"]); + + fetch(\"/api/v1/domains\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + name: e.target.name.value, + tld: e.target.tld.selectedOptions[0].value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + setTimeout(() => { + window.location.href = `/domains/${res.payload}`; + }, 100); + } + }); + }")) + +(text "{% endblock %}") diff --git a/crates/app/src/public/html/littleweb/service.lisp b/crates/app/src/public/html/littleweb/service.lisp new file mode 100644 index 0000000..b0b7ac9 --- /dev/null +++ b/crates/app/src/public/html/littleweb/service.lisp @@ -0,0 +1,347 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "My services - {{ config.name }}")) + +(text "{% endblock %} {% block body %} {{ macros::nav() }}") +(main + ("class" "flex flex-col gap-2") + (text "{% if user -%}") + (div + ("class" "pillmenu") + (a ("href" "/services") ("class" "active") (str (text "littleweb:label.services"))) + (a ("href" "/domains") (str (text "littleweb:label.domains")))) + + (div + ("class" "card-nest") + (div + ("class" "card small") + (b + (text "{{ service.name }}"))) + + (div + ("class" "flex gap-2 flex-wrap card") + (text "{% if file and file.children|length == 0 -%}") + (button + ("onclick" "update_content()") + (icon (text "check")) + (str (text "general:action.save"))) + (text "{%- endif %}") + + (button + ("class" "lowered") + ("onclick" "update_name()") + (icon (text "pencil")) + (str (text "littleweb:action.edit_site_name"))) + + (button + ("class" "lowered") + ("onclick" "trigger('atto::copy_text', ['{{ service.id }}'])") + (icon (text "copy")) + (str (text "general:action.copy_id"))) + + (button + ("class" "red lowered") + ("onclick" "delete_service()") + (icon (text "trash")) + (str (text "general:action.delete"))))) + (text "{%- endif %}") + (div + ("class" "card-nest w-full") + (div + ("class" "card small flex items-center justify-between gap-2") + (div + ("class" "flex items-center gap-2") + (icon (text "folder-open")) + (span (text "{% if path -%} {{ path }} {%- else -%} / {%- endif %}"))) + + (div + ("class" "flex items-center gap-2") + (button + ("class" "lowered small") + ("onclick" "go_up()") + (icon (text "arrow-up"))) + + (text "{% if not file or file.content|length == 0 -%}") + (button + ("class" "lowered small") + ("onclick" "create_file()") + (icon (text "plus")) + (str (text "communities:action.create"))) + (text "{%- endif %}"))) + (div + ("class" "card flex flex-col gap-2") + (text "{% if not file or file.children|length > 0 -%}") + ; directory browser + (table + (thead + (tr + (th (text "Name")) + (th (text "Type")) + (th (text "Children")) + (th (text "Actions")))) + + (tbody + (text "{% for item in files %}") + (tr + (td + ("class" "flex gap-2 items-center") + (text "{% if item.children|length > 0 -%}") + (icon (text "folder")) + (text "{% else %}") + (icon (text "file")) + (text "{%- endif %}") + + (a + ("href" "?path={{ path }}/{{ item.name }}") + ("data-turbo" "false") + (text "{{ item.name }}"))) + (td (text "{{ item.mime }}")) + (td (text "{{ item.children|length }}")) + (td + ("style" "overflow: auto") + (div + ("class" "dropdown") + (button + ("class" "camo small") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + (icon (text "ellipsis"))) + (div + ("class" "inner") + (button + ("onclick" "rename_file('{{ item.id }}')") + (icon (text "pencil")) + (str (text "littleweb:action.rename"))) + + (button + ("class" "red") + ("onclick" "remove_file('{{ item.id }}')") + (icon (text "trash")) + (str (text "general:action.delete"))))))) + (text "{% endfor %}"))) + (text "{% else %}") + ; file editor + (div ("id" "editor_container") ("class" "w-full") ("style" "height: 600px")) + (text "{%- endif %}")))) + +(script ("id" "all_service_files") ("type" "application/json") (text "{{ service.files|json_encode()|remove_script_tags|safe }}")) +(script ("id" "service_files") ("type" "application/json") (text "{{ files|json_encode()|remove_script_tags|safe }}")) +(script ("id" "id_path") ("type" "application/json") (text "{{ id_path|json_encode()|remove_script_tags|safe }}")) + +(script + (text "globalThis.SERVICE_FILES = JSON.parse(document.getElementById(\"service_files\").innerText); + globalThis.EXTENSION_MIMES = { + \"html\": \"text/html\", + \"js\": \"text/javascript\", + \"css\": \"text/css\", + \"json\": \"application/json\", + \"txt\": \"text/plain\", + } + + globalThis.MIME_MODES = { + \"Html\": \"html\", + \"Js\": \"javascript\", + \"Css\": \"css\", + \"Json\": \"json\", + \"Plain\": \"txt\", + } + + function go_up() { + const x = JSON.parse(document.getElementById(\"id_path\").innerText); + const y = JSON.parse(document.getElementById(\"all_service_files\").innerText); + + x.pop(); + let path = \"\"; + + for (id of x) { + path += `/${y.find((x) => x.id == id).name}`; + } + + window.location.href = `?path=${path}`; + } + + async function update_name() { + await trigger(\"atto::debounce\", [\"services::update_name\"]); + + const name = await trigger(\"atto::prompt\", [\"New name:\"]); + + if (!name) { + return; + } + + fetch(\"/api/v1/services/{{ service.id }}/name\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + name: e.target.name.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function delete_service() { + await trigger(\"atto::debounce\", [\"services::delete\"]); + + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/services/{{ service.id }}\", { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function update_content() { + await trigger(\"atto::debounce\", [\"services::update_content\"]); + const content = globalThis.editor.getValue(); + fetch(\"/api/v1/services/{{ service.id }}/content\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + content, + id_path: JSON.parse(document.getElementById(\"id_path\").innerText), + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function update_files() { + await trigger(\"atto::debounce\", [\"services::update_files\"]); + fetch(\"/api/v1/services/{{ service.id }}/files\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + files: SERVICE_FILES, + id_path: JSON.parse(document.getElementById(\"id_path\").innerText), + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + + async function create_file() { + await trigger(\"atto::debounce\", [\"services::create_file\"]); + + let name = await trigger(\"atto::prompt\", [\"Name:\"]); + + if (!name) { + return; + } + + const s = name.split(\".\"); + SERVICE_FILES.push({ + id: window.crypto.randomUUID(), + name, + mime: EXTENSION_MIMES[s[s.length - 1]] || EXTENSION_MIMES[\"txt\"], + children: [], + content: \"\", + }); + + await update_files(); + + setTimeout(() => { + window.location.reload(); + }, 150); + } + + async function rename_file(id) { + await trigger(\"atto::debounce\", [\"services::rename_file\"]); + + let name = await trigger(\"atto::prompt\", [\"New name:\"]); + + if (!name) { + return; + } + + const file_ref = SERVICE_FILES.find((x) => x.id === id); + file_ref.name = name; + + const s = name.split(\".\"); + file_ref.mime = EXTENSION_MIMES[s[s.length - 1]] || EXTENSION_MIMES[\"txt\"]; + + await update_files(); + } + + async function remove_file(id) { + await trigger(\"atto::debounce\", [\"services::remove_file\"]); + + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + let i = 0; + SERVICE_FILES.find((x) => { + i += 1; + return x.id === id; + }); + + SERVICE_FILES.splice(i - 1, 1); + await update_files(); + }")) + +(text "{% if file and file.mime != 'Plain' -%}") +(script ("src" "https://unpkg.com/monaco-editor@0.52.2/min/vs/loader.js")) +(script ("id" "file_content") ("type" "text/plain") (text "{{ file.content|remove_script_tags|safe }}")) +(script + (text "require.config({ paths: { vs: \"https://unpkg.com/monaco-editor@0.52.2/min/vs\" } }); + + require([\"vs/editor/editor.main\"], () => { + const shadow = document.getElementById(\"editor_container\").attachShadow({ + mode: \"closed\", + }); + + const inner = document.createElement(\"div\"); + inner.style.width = window.getComputedStyle(document.getElementById(\"editor_container\")).width; + inner.style.height = window.getComputedStyle(document.getElementById(\"editor_container\")).height; + shadow.appendChild(inner); + + const style = document.createElement(\"style\"); + style.innerText = '@import \"https://unpkg.com/monaco-editor@0.52.2/min/vs/editor/editor.main.css\";'; + shadow.appendChild(style); + + globalThis.editor = monaco.editor.create(inner, { + value: document.getElementById(\"file_content\").innerText.replaceAll(\"</script>\", \"\"), + language: MIME_MODES[\"{{ file.mime }}\"], + theme: \"vs-dark\", + }); + });")) +(text "{%- endif %}") +(text "{% endblock %}") diff --git a/crates/app/src/public/html/littleweb/services.lisp b/crates/app/src/public/html/littleweb/services.lisp index e4525ca..cca5af7 100644 --- a/crates/app/src/public/html/littleweb/services.lisp +++ b/crates/app/src/public/html/littleweb/services.lisp @@ -1,11 +1,16 @@ (text "{% extends \"root.html\" %} {% block head %}") (title - (text "My stacks - {{ config.name }}")) + (text "My services - {{ config.name }}")) (text "{% endblock %} {% block body %} {{ macros::nav() }}") (main ("class" "flex flex-col gap-2") - (text "{{ macros::timelines_nav(selected=\"littleweb\") }} {% if user -%}") + (text "{% if user -%}") + (div + ("class" "pillmenu") + (a ("href" "/services") ("class" "active") (str (text "littleweb:label.services"))) + (a ("href" "/domains") (str (text "littleweb:label.domains")))) + (div ("class" "card-nest") (div diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index a554351..f9d8a1f 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -71,7 +71,7 @@ (button ("class" "flex-row title") ("onclick" "trigger('atto::hooks::dropdown', [event])") - ("exlude" "dropdown") + ("exclude" "dropdown") ("style" "gap: var(--pad-1) !important") (text "{{ components::avatar(username=user.username, size=\"24px\") }}") (icon_class (text "chevron-down") (text "dropdown-arrow"))) diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp index 93312bb..6730dd8 100644 --- a/crates/app/src/public/html/root.lisp +++ b/crates/app/src/public/html/root.lisp @@ -35,10 +35,12 @@ globalThis.no_policy = false; globalThis.BUILD_CODE = \"{{ random_cache_breaker }}\"; + globalThis.TETRATTO_LINK_HANDLER_CTX = \"net\"; ") (script ("src" "/js/loader.js?v=tetratto-{{ random_cache_breaker }}" )) (script ("src" "/js/atto.js?v=tetratto-{{ random_cache_breaker }}" )) + (script ("defer" "true") ("src" "/js/proto_links.js?v=tetratto-{{ random_cache_breaker }}" )) (meta ("name" "theme-color") ("content" "{{ config.color }}")) (meta ("name" "description") ("content" "{{ config.description }}")) diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 43a46b8..1b2a4db 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -855,7 +855,8 @@ media_theme_pref(); anchor.href.startsWith("https://tetratto.com") || anchor.href.startsWith("https://buy.stripe.com") || anchor.href.startsWith("https://billing.stripe.com") || - anchor.href.startsWith("https://last.fm") + anchor.href.startsWith("https://last.fm") || + anchor.href.startsWith("atto://") ) { continue; } @@ -1333,6 +1334,8 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} atto["hooks::online_indicator"](); atto["hooks::verify_emoji"](); atto["hooks::check_reactions"](); + + fix_atto_links(); }); })(); diff --git a/crates/app/src/public/js/proto_links.js b/crates/app/src/public/js/proto_links.js new file mode 100644 index 0000000..ab5d938 --- /dev/null +++ b/crates/app/src/public/js/proto_links.js @@ -0,0 +1,136 @@ +if (!globalThis.TETRATTO_LINK_HANDLER_CTX) { + globalThis.TETRATTO_LINK_HANDLER_CTX = "embed"; +} + +// create little link preview box +function create_link_preview() { + globalThis.TETRATTO_LINK_PREVIEW = document.createElement("div"); + globalThis.TETRATTO_LINK_PREVIEW.style.position = "fixed"; + globalThis.TETRATTO_LINK_PREVIEW.style.bottom = "0.5rem"; + globalThis.TETRATTO_LINK_PREVIEW.style.left = "0.5rem"; + globalThis.TETRATTO_LINK_PREVIEW.style.background = "#323232"; + globalThis.TETRATTO_LINK_PREVIEW.style.color = "#ffffff"; + globalThis.TETRATTO_LINK_PREVIEW.style.borderRadius = "4px"; + globalThis.TETRATTO_LINK_PREVIEW.style.padding = "0.25rem 0.5rem"; + globalThis.TETRATTO_LINK_PREVIEW.style.display = "none"; + globalThis.TETRATTO_LINK_PREVIEW.id = "tetratto_link_preview"; + globalThis.TETRATTO_LINK_PREVIEW.setAttribute( + "data-turbo-permanent", + "true", + ); + document.body.appendChild(globalThis.TETRATTO_LINK_PREVIEW); +} + +/// Clean up all "atto://" links on the page. +function fix_atto_links() { + setTimeout(() => { + if (!document.getElementById("tetratto_link_preview")) { + create_link_preview(); + } + }, 500); + + if (TETRATTO_LINK_HANDLER_CTX === "embed") { + // relative links for embeds + const path = window.location.pathname.slice("/api/v1/net/".length); + + function fix_element( + selector = "a", + property = "href", + relative = true, + ) { + for (const y of Array.from(document.querySelectorAll(selector))) { + if (!y[property].startsWith(window.location.origin)) { + continue; + } + + let x = new URL(y[property]).pathname; + + if (!x.includes(".html")) { + x = `${x}/index.html`; + } + + if (relative) { + y[property] = + `atto://${path.replace("atto://", "").split("/")[0]}${x}`; + } else { + y[property] = + `/api/v1/net/atto://${path.replace("atto://", "").split("/")[0]}${x}`; + } + } + } + + fix_element("a", "href", true); + fix_element("link", "href", false); + fix_element("script", "src", false); + + // send message + window.top.postMessage( + JSON.stringify({ + t: true, + event: "change_url", + target: window.location.href, + }), + "*", + ); + + // handle messages + window.addEventListener("message", (e) => { + if (typeof e.data !== "string") { + console.log("refuse message (bad type)"); + return; + } + + const data = JSON.parse(e.data); + + if (!data.t) { + console.log("refuse message (not for tetratto)"); + return; + } + + console.log("received message"); + + if (data.event === "back") { + window.history.back(); + } else if (data.event === "forward") { + window.history.forward(); + } else if (data.event === "reload") { + window.location.reload(); + } + }); + } + + for (const anchor of Array.from(document.querySelectorAll("a"))) { + if ( + !anchor.href.startsWith("atto://") || + anchor.getAttribute("data-checked") === "true" + ) { + continue; + } + + const href = structuredClone(anchor.href); + + anchor.addEventListener("click", () => { + if (TETRATTO_LINK_HANDLER_CTX === "net") { + window.location.href = `/net/${href.replace("atto://", "")}`; + } else { + window.location.href = `/api/v1/net/${href}`; + } + }); + + anchor.addEventListener("mouseenter", () => { + TETRATTO_LINK_PREVIEW.innerText = href; + TETRATTO_LINK_PREVIEW.style.display = "block"; + }); + + anchor.addEventListener("mouseleave", () => { + TETRATTO_LINK_PREVIEW.style.display = "none"; + }); + + anchor.removeAttribute("href"); + anchor.style.cursor = "pointer"; + anchor.setAttribute("data-checked", "true"); + } +} + +fix_atto_links(); +create_link_preview(); diff --git a/crates/app/src/routes/api/v1/domains.rs b/crates/app/src/routes/api/v1/domains.rs index aec1a01..8cfd9dc 100644 --- a/crates/app/src/routes/api/v1/domains.rs +++ b/crates/app/src/routes/api/v1/domains.rs @@ -3,15 +3,12 @@ use crate::{ routes::api::v1::{CreateDomain, UpdateDomainData}, State, }; -use axum::{ - extract::{Path, Query}, - response::IntoResponse, - http::StatusCode, - Extension, Json, -}; +use axum::{extract::Path, response::IntoResponse, http::StatusCode, Extension, Json}; use axum_extra::extract::CookieJar; -use tetratto_core::model::{littleweb::Domain, oauth, ApiReturn, Error}; -use serde::Deserialize; +use tetratto_core::model::{ + littleweb::{Domain, ServiceFsMime}, + oauth, ApiReturn, Error, +}; pub async fn get_request( Path(id): Path, @@ -112,17 +109,12 @@ pub async fn delete_request( } } -#[derive(Deserialize)] -pub struct GetFileQuery { - pub addr: String, -} - pub async fn get_file_request( + Path(addr): Path, Extension(data): Extension, - Query(props): Query, ) -> impl IntoResponse { let data = &(data.read().await).0; - let (subdomain, domain, tld, path) = Domain::from_str(&props.addr); + let (subdomain, domain, tld, path) = Domain::from_str(&addr); // resolve domain let domain = match data.get_domain_by_name_tld(&domain, &tld).await { @@ -150,9 +142,19 @@ pub async fn get_file_request( // resolve file match service.file(&path) { - Some(f) => Ok(( + Some((f, _)) => Ok(( [("Content-Type".to_string(), f.mime.to_string())], - f.content, + if f.mime == ServiceFsMime::Html { + f.content.replace( + "", + &format!( + "", + data.0.0.host + ), + ) + } else { + f.content + }, )), None => { return Err(( diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 59f4353..506e74f 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -641,7 +641,12 @@ pub fn routes() -> Router { .route("/services", post(services::create_request)) .route("/services/{id}", get(services::get_request)) .route("/services/{id}", delete(services::delete_request)) + .route("/services/{id}/name", post(services::update_name_request)) .route("/services/{id}/files", post(services::update_files_request)) + .route( + "/services/{id}/content", + post(services::update_content_request), + ) // domains .route("/domains", get(domains::list_request)) .route("/domains", post(domains::create_request)) @@ -651,7 +656,7 @@ pub fn routes() -> Router { } pub fn lw_routes() -> Router { - Router::new().route("/file", get(domains::get_file_request)) + Router::new().route("/net/{*addr}", get(domains::get_file_request)) } #[derive(Deserialize)] @@ -1076,9 +1081,21 @@ pub struct CreateService { pub name: String, } +#[derive(Deserialize)] +pub struct UpdateServiceName { + pub name: String, +} + #[derive(Deserialize)] pub struct UpdateServiceFiles { pub files: Vec, + pub id_path: Vec, +} + +#[derive(Deserialize)] +pub struct UpdateServiceFileContent { + pub content: String, + pub id_path: Vec, } #[derive(Deserialize)] diff --git a/crates/app/src/routes/api/v1/services.rs b/crates/app/src/routes/api/v1/services.rs index 36895d6..252fe5a 100644 --- a/crates/app/src/routes/api/v1/services.rs +++ b/crates/app/src/routes/api/v1/services.rs @@ -1,6 +1,8 @@ use crate::{ get_user_from_token, - routes::api::v1::{UpdateServiceFiles, CreateService}, + routes::api::v1::{ + CreateService, UpdateServiceFileContent, UpdateServiceFiles, UpdateServiceName, + }, State, }; use axum::{extract::Path, response::IntoResponse, Extension, Json}; @@ -60,6 +62,28 @@ pub async fn create_request( } } +pub async fn update_name_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageServices) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_service_name(id, &user, &req.name).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Service updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + pub async fn update_files_request( jar: CookieJar, Extension(data): Extension, @@ -72,7 +96,57 @@ pub async fn update_files_request( None => return Json(Error::NotAllowed.into()), }; - match data.update_service_files(id, &user, req.files).await { + let mut service = match data.get_service_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if req.id_path.is_empty() { + service.files = req.files; + } else { + match service.file_mut(req.id_path) { + Some(f) => f.children = req.files, + None => return Json(Error::GeneralNotFound("file".to_string()).into()), + } + } + + match data.update_service_files(id, &user, service.files).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Service updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_content_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageServices) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let mut service = match data.get_service_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + // update + let file = match service.file_mut(req.id_path) { + Some(f) => f, + None => return Json(Error::GeneralNotFound("file".to_string()).into()), + }; + + file.content = req.content; + + // ... + match data.update_service_files(id, &user, service.files).await { Ok(_) => Json(ApiReturn { ok: true, message: "Service updated".to_string(), diff --git a/crates/app/src/routes/assets.rs b/crates/app/src/routes/assets.rs index 2e66c19..2aa1bc5 100644 --- a/crates/app/src/routes/assets.rs +++ b/crates/app/src/routes/assets.rs @@ -20,3 +20,4 @@ serve_asset!(me_js_request: ME_JS("text/javascript")); serve_asset!(streams_js_request: STREAMS_JS("text/javascript")); serve_asset!(carp_js_request: CARP_JS("text/javascript")); serve_asset!(layout_editor_js_request: LAYOUT_EDITOR_JS("text/javascript")); +serve_asset!(proto_links_request: PROTO_LINKS_JS("text/javascript")); diff --git a/crates/app/src/routes/mod.rs b/crates/app/src/routes/mod.rs index 81746d9..0872632 100644 --- a/crates/app/src/routes/mod.rs +++ b/crates/app/src/routes/mod.rs @@ -24,6 +24,7 @@ pub fn routes(config: &Config) -> Router { "/js/layout_editor.js", get(assets::layout_editor_js_request), ) + .route("/js/proto_links.js", get(assets::proto_links_request)) .nest_service( "/public", get_service(tower_http::services::ServeDir::new(&config.dirs.assets)), diff --git a/crates/app/src/routes/pages/journals.rs b/crates/app/src/routes/pages/journals.rs index ca48e78..cdfba32 100644 --- a/crates/app/src/routes/pages/journals.rs +++ b/crates/app/src/routes/pages/journals.rs @@ -365,7 +365,7 @@ pub async fn global_view_request( Ok(( [( "content-security-policy", - "default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *.cloudflare.com; frame-ancestors *", + "default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; worker-src * blob:; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *; frame-ancestors *", )], Html(data.1.render("journals/app.html", &context).unwrap()), )) diff --git a/crates/app/src/routes/pages/littleweb.rs b/crates/app/src/routes/pages/littleweb.rs new file mode 100644 index 0000000..9dc5907 --- /dev/null +++ b/crates/app/src/routes/pages/littleweb.rs @@ -0,0 +1,211 @@ +use super::render_error; +use crate::{assets::initial_context, get_lang, get_user_from_token, State}; +use axum::{ + response::{Html, IntoResponse}, + extract::{Query, Path}, + Extension, +}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{littleweb::TLDS_VEC, Error}; +use serde::Deserialize; + +/// `/services` +pub async fn services_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 list = match data.0.get_services_by_user(user.id).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + context.insert("list", &list); + + // return + Ok(Html( + data.1.render("littleweb/services.html", &context).unwrap(), + )) +} + +/// `/domains` +pub async fn domains_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 list = match data.0.get_domains_by_user(user.id).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + + context.insert("list", &list); + context.insert("tlds", &*TLDS_VEC); + + // return + Ok(Html( + data.1.render("littleweb/domains.html", &context).unwrap(), + )) +} + +#[derive(Deserialize)] +pub struct FileBrowserProps { + #[serde(default)] + path: String, +} + +/// `/services/{id}` +pub async fn service_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Query(props): Query, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + let service = match data.0.get_service_by_id(id).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + if user.id != service.owner { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + + context.insert("service", &service); + + match service.file(&props.path.replacen("/", "", 1)) { + Some((x, p)) => { + context.insert("id_path", &p); + context.insert("file", &x); + context.insert("files", &x.children); + } + None => { + context.insert("id_path", &Vec::<()>::new()); + context.insert("files", &service.files); + } + } + + let path_segments: Vec<&str> = props.path.split("/").collect(); + context.insert("path_segments", &path_segments); + context.insert("path", &props.path); + + // return + Ok(Html( + data.1.render("littleweb/service.html", &context).unwrap(), + )) +} + +/// `/domains/{id}` +pub async fn domain_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + let domain = match data.0.get_domain_by_id(id).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }; + + if user.id != domain.owner { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + context.insert("domain", &domain); + + // return + Ok(Html( + data.1.render("littleweb/domain.html", &context).unwrap(), + )) +} + +/// `/net` +pub async fn browser_home_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = get_user_from_token!(jar, data.0); + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &user).await; + context.insert("path", &""); + + // return + Html(data.1.render("littleweb/browser.html", &context).unwrap()) +} + +/// `/net/{uri}` +pub async fn browser_request( + jar: CookieJar, + Path(mut uri): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = get_user_from_token!(jar, data.0); + + if !uri.contains("/") { + uri = format!("{uri}/index.html"); + } + + if !uri.starts_with("atto://") { + uri = format!("atto://{uri}"); + } + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &user).await; + context.insert("path", &uri); + + // return + Html(data.1.render("littleweb/browser.html", &context).unwrap()) +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 07bd5a7..6ce6318 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -4,6 +4,7 @@ pub mod communities; pub mod developer; pub mod forge; pub mod journals; +pub mod littleweb; pub mod misc; pub mod mod_panel; pub mod profile; @@ -139,6 +140,13 @@ pub fn routes() -> Router { .route("/@{owner}/{journal}", get(journals::index_view_request)) .route("/@{owner}/{journal}/{note}", get(journals::view_request)) .route("/x/{note}", get(journals::global_view_request)) + // littleweb + .route("/services", get(littleweb::services_request)) + .route("/domains", get(littleweb::domains_request)) + .route("/services/{id}", get(littleweb::service_request)) + .route("/domains/{id}", get(littleweb::domain_request)) + .route("/net", get(littleweb::browser_home_request)) + .route("/net/{*uri}", get(littleweb::browser_request)) } pub fn lw_routes() -> Router { diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index e47db7a..ffbd2c2 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-core" -version = "10.0.0" +version = "11.0.0" edition = "2024" [dependencies] @@ -19,4 +19,8 @@ base16ct = { version = "0.2.0", features = ["alloc"] } base64 = "0.22.1" emojis = "0.7.0" regex = "1.11.1" -oiseau = { version = "0.1.2", default-features = false, features = ["postgres", "redis"] } +oiseau = { version = "0.1.2", default-features = false, features = [ + "postgres", + "redis", +] } +paste = "1.0.15" diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 85ff839..309c851 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -361,6 +361,9 @@ fn default_banned_usernames() -> Vec { "search".to_string(), "journals".to_string(), "links".to_string(), + "app".to_string(), + "services".to_string(), + "domains".to_string(), ] } diff --git a/crates/core/src/database/domains.rs b/crates/core/src/database/domains.rs index 0249f6f..672de1c 100644 --- a/crates/core/src/database/domains.rs +++ b/crates/core/src/database/domains.rs @@ -1,8 +1,11 @@ -use crate::model::{ - auth::User, - littleweb::{Domain, DomainData, DomainTld}, - permissions::{FinePermission, SecondaryPermission}, - Error, Result, +use crate::{ + database::NAME_REGEX, + model::{ + auth::User, + littleweb::{Domain, DomainData, DomainTld}, + permissions::{FinePermission, SecondaryPermission}, + Error, Result, + }, }; use crate::{auto_method, DataManager}; use oiseau::{cache::Cache, execute, get, params, query_row, query_rows, PostgresRow}; @@ -71,6 +74,8 @@ impl DataManager { Ok(res.unwrap()) } + const MAXIMUM_FREE_DOMAINS: usize = 5; + /// Create a new domain in the database. /// /// # Arguments @@ -83,6 +88,31 @@ impl DataManager { return Err(Error::DataTooLong("name".to_string())); } + // check number of domains + let owner = self.get_user_by_id(data.owner).await?; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + let domains = self.get_domains_by_user(data.owner).await?; + + if domains.len() >= Self::MAXIMUM_FREE_DOMAINS { + return Err(Error::MiscError( + "You already have the maximum number of domains you can have".to_string(), + )); + } + } + + // check name + let regex = regex::RegexBuilder::new(NAME_REGEX) + .multi_line(true) + .build() + .unwrap(); + + if regex.captures(&data.name).is_some() { + return Err(Error::MiscError( + "Domain name contains invalid characters".to_string(), + )); + } + // check for existing if self .get_domain_by_name_tld(&data.name, &data.tld) diff --git a/crates/core/src/database/services.rs b/crates/core/src/database/services.rs index de67f74..adadf7e 100644 --- a/crates/core/src/database/services.rs +++ b/crates/core/src/database/services.rs @@ -126,5 +126,6 @@ impl DataManager { Ok(()) } - auto_method!(update_service_files(Vec)@get_service_by_id:FinePermission::MANAGE_USERS; -> "UPDATE services SET data = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.service:{}"); + auto_method!(update_service_name(&str)@get_service_by_id:FinePermission::MANAGE_USERS; -> "UPDATE services SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.service:{}"); + auto_method!(update_service_files(Vec)@get_service_by_id:FinePermission::MANAGE_USERS; -> "UPDATE services SET files = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.service:{}"); } diff --git a/crates/core/src/model/littleweb.rs b/crates/core/src/model/littleweb.rs index 79cffb1..479a444 100644 --- a/crates/core/src/model/littleweb.rs +++ b/crates/core/src/model/littleweb.rs @@ -1,6 +1,8 @@ use std::fmt::Display; use serde::{Serialize, Deserialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; +use paste::paste; +use std::sync::LazyLock; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Service { @@ -24,18 +26,24 @@ impl Service { } /// Resolve a file from the virtual file system. - pub fn file(&self, path: &str) -> Option { + /// + /// # Returns + /// `(file, id path)` + pub fn file(&self, path: &str) -> Option<(ServiceFsEntry, Vec)> { let segments = path.chars().filter(|x| x == &'/').count(); let mut path = path.split("/"); let mut path_segment = path.next().unwrap(); + let mut ids = Vec::new(); let mut i = 0; let mut f = &self.files; while let Some(nf) = f.iter().find(|x| x.name == path_segment) { - if i == segments - 1 { - return Some(nf.to_owned()); + ids.push(nf.id.clone()); + + if i == segments { + return Some((nf.to_owned(), ids)); } f = &nf.children; @@ -45,6 +53,31 @@ impl Service { None } + + /// Resolve a file from the virtual file system (mutable). + /// + /// # Returns + /// `&mut file` + pub fn file_mut(&mut self, id_path: Vec) -> Option<&mut ServiceFsEntry> { + let total_segments = id_path.len(); + let mut i = 0; + + let mut f = &mut self.files; + for segment in id_path { + if let Some(nf) = f.iter_mut().find(|x| (**x).id == segment) { + if i == total_segments - 1 { + return Some(nf); + } + + f = &mut nf.children; + i += 1; + } else { + break; + } + } + + None + } } /// A file type for [`ServiceFsEntry`] structs. @@ -77,36 +110,92 @@ impl Display for ServiceFsMime { /// A single entry in the file system of [`Service`]. #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ServiceFsEntry { + /// Files use a UUID since they're generated on the client. + pub id: String, pub name: String, pub mime: ServiceFsMime, pub children: Vec, pub content: String, - /// SHA-256 checksum of the entry's content. - pub checksum: String, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum DomainTld { - Bunny, -} - -impl Display for DomainTld { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::Bunny => "bunny", - }) - } -} - -impl From<&str> for DomainTld { - fn from(value: &str) -> Self { - match value { - "bunny" => Self::Bunny, - _ => Self::Bunny, +macro_rules! domain_tld_display_match { + ($self:ident, $($tld:ident),+ $(,)?) => { + match $self { + $( + Self::$tld => stringify!($tld).to_lowercase(), + )+ } } } +macro_rules! domain_tld_strings { + ($($tld:ident),+ $(,)?) => { + $( + paste! { + /// Constant from macro. + const []: LazyLock = LazyLock::new(|| stringify!($tld).to_lowercase()); + } + )+ + } +} + +macro_rules! domain_tld_from_match { + ($value:ident, $($tld:ident),+ $(,)?) => { + { + $( + paste! { + let [<$tld:snake:lower>] = &*[]; + } + )+; + + // can't use match here, the expansion is going to look really ugly + $( + if $value == paste!{ [<$tld:snake:lower>] } { + return Self::$tld; + } + )+ + + return Self::Bunny; + } + } +} + +macro_rules! define_domain_tlds { + ($($tld:ident),+ $(,)?) => { + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] + pub enum DomainTld { + $($tld),+ + } + + domain_tld_strings!($($tld),+); + + impl From<&str> for DomainTld { + fn from(value: &str) -> Self { + domain_tld_from_match!( + value, $($tld),+ + ) + } + } + + impl Display for DomainTld { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // using this macro allows us to just copy and paste the enum variants + f.write_str(&domain_tld_display_match!( + self, $($tld),+ + )) + } + } + + /// This is VERY important so that I don't have to manually type them all for the UI dropdown. + pub const TLDS_VEC: LazyLock> = LazyLock::new(|| vec![$(stringify!($tld)),+]); + } +} + +define_domain_tlds!( + Bunny, Tet, Cool, Qwerty, Boy, Girl, Them, Quack, Bark, Meow, Silly, Wow, Neko, Yay, Lol, Love, + Fun, Gay, City, Woah, Clown, Apple, Yaoi, Yuri, World, Wav, Zero, Evil, Dragon, Yum, Site +); + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Domain { pub id: usize, @@ -142,12 +231,12 @@ impl Domain { // we're reversing this so it's predictable, as there might not always be a subdomain // (we shouldn't have the variable entry be first, there is always going to be a tld) - let mut s: Vec<&str> = no_protocol.split(".").collect(); + let mut s: Vec<&str> = no_protocol.split("/").next().unwrap().split(".").collect(); s.reverse(); let mut s = s.into_iter(); let tld = DomainTld::from(s.next().unwrap()); - let domain = s.next().unwrap(); + let domain = s.next().unwrap_or("default.bunny"); let subdomain = s.next().unwrap_or("@"); // get path @@ -157,7 +246,7 @@ impl Domain { while char != '/' { // we need to keep eating characters until we reach the first / // (marking the start of the path) - char = chars.next().unwrap(); + char = chars.next().unwrap_or('/'); } let path: String = chars.collect(); @@ -183,7 +272,10 @@ impl Domain { pub fn service(&self, subdomain: &str) -> Option { let s = self.data.iter().find(|x| x.0 == subdomain)?; match s.1 { - DomainData::Service(id) => Some(id), + DomainData::Service(ref id) => Some(match id.parse::() { + Ok(id) => id, + Err(_) => return None, + }), _ => None, } } @@ -193,7 +285,7 @@ impl Domain { pub enum DomainData { /// The ID of the service this domain points to. The first service found will /// always be used. This means having multiple service entires will be useless. - Service(usize), + Service(String), /// A text entry with a maximum of 512 characters. Text(String), } diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index 1b3e5e1..9544981 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-l10n" -version = "10.0.0" +version = "11.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 5fd4230..633984b 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto-shared" -version = "10.0.0" +version = "11.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 1ae665b..82d6b79 100644 --- a/crates/shared/src/markdown.rs +++ b/crates/shared/src/markdown.rs @@ -8,6 +8,7 @@ pub fn render_markdown(input: &str) -> String { compile: CompileOptions { allow_any_img_src: false, allow_dangerous_html: true, + allow_dangerous_protocol: true, gfm_task_list_item_checkable: false, gfm_tagfilter: false, ..Default::default() @@ -48,6 +49,7 @@ pub fn render_markdown(input: &str) -> String { ]) .rm_tags(&["script", "style", "link", "canvas"]) .add_tag_attributes("a", &["href", "target"]) + .add_url_schemes(&["atto"]) .clean(&html) .to_string() .replace( From 4ebd7e6c2b7aaed92360e4117f09b6c7892ecef1 Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 8 Jul 2025 14:36:14 -0400 Subject: [PATCH 25/93] fix: "ask anonymously" checkbox --- crates/app/src/public/html/components.lisp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 156875e..d8c192f 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -800,7 +800,7 @@ }")) (text "{%- endif %}")) - (text "{% if not is_global and allow_anonymous and not user -%}") + (text "{% if not is_global and allow_anonymous and user -%}") (div ("class" "flex gap-2 items-center") (input From 78c9b3349d3ab43298ab790002bee5f495b91caa Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 8 Jul 2025 15:21:57 -0400 Subject: [PATCH 26/93] add: better domain editor ui --- .../src/public/html/littleweb/browser.lisp | 3 +- .../app/src/public/html/littleweb/domain.lisp | 73 +++++------ .../src/public/html/littleweb/service.lisp | 115 +++++++++++------- crates/core/src/model/littleweb.rs | 3 +- 4 files changed, 112 insertions(+), 82 deletions(-) diff --git a/crates/app/src/public/html/littleweb/browser.lisp b/crates/app/src/public/html/littleweb/browser.lisp index 76ac82b..6da038f 100644 --- a/crates/app/src/public/html/littleweb/browser.lisp +++ b/crates/app/src/public/html/littleweb/browser.lisp @@ -91,6 +91,7 @@ position: fixed; width: calc(100dvw - (62px + var(--pad-2) * 2)) !important; left: var(--pad-2); + z-index: 2; } } @@ -101,7 +102,7 @@ height: var(--h); min-height: var(--h); max-height: var(--h); - font-size: 14px; + font-size: 16px; } #panel button:not(.inner *), diff --git a/crates/app/src/public/html/littleweb/domain.lisp b/crates/app/src/public/html/littleweb/domain.lisp index d8b01f1..96d2da7 100644 --- a/crates/app/src/public/html/littleweb/domain.lisp +++ b/crates/app/src/public/html/littleweb/domain.lisp @@ -72,7 +72,7 @@ ("class" "card hidden w-full lowered flex flex-col gap-2") ("onsubmit" "add_data_from_form(event)") (div - ("class" "flex gap-2") + ("class" "flex gap-2 flex-collapse") (div ("class" "flex w-full flex-col gap-1") (label @@ -119,44 +119,47 @@ (icon (text "check")) (str (text "general:action.save"))))) ; data - (table - (thead - (tr - (th (text "Name")) - (th (text "Type")) - (th (text "Value")) - (th (text "Actions")))) + (div + ("class" "w-full") + ("style" "max-width: 100%; overflow: auto") + (table + (thead + (tr + (th (text "Name")) + (th (text "Type")) + (th (text "Value")) + (th (text "Actions")))) - (tbody - (text "{% for item in domain.data -%}") - (tr - (td (text "{{ item[0] }}")) - (text "{% for k,v in item[1] -%}") - (td (text "{{ k }}")) - (td (text "{{ v }}")) - (text "{%- endfor %}") - (td - ("style" "overflow: auto") - (div - ("class" "dropdown") - (button - ("class" "camo small") - ("onclick" "trigger('atto::hooks::dropdown', [event])") - ("exclude" "dropdown") - (icon (text "ellipsis"))) + (tbody + (text "{% for item in domain.data -%}") + (tr + (td (text "{{ item[0] }}")) + (text "{% for k,v in item[1] -%}") + (td (text "{{ k }}")) + (td (text "{{ v }}")) + (text "{%- endfor %}") + (td + ("style" "overflow: auto") (div - ("class" "inner") + ("class" "dropdown") (button - ("onclick" "rename_data('{{ item[0] }}')") - (icon (text "pencil")) - (str (text "littleweb:action.rename"))) + ("class" "camo small") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + (icon (text "ellipsis"))) + (div + ("class" "inner") + (button + ("onclick" "rename_data('{{ item[0] }}')") + (icon (text "pencil")) + (str (text "littleweb:action.rename"))) - (button - ("class" "red") - ("onclick" "remove_data('{{ item[0] }}')") - (icon (text "trash")) - (str (text "general:action.delete"))))))) - (text "{%- endfor %}")))))) + (button + ("class" "red") + ("onclick" "remove_data('{{ item[0] }}')") + (icon (text "trash")) + (str (text "general:action.delete"))))))) + (text "{%- endfor %}"))))))) (script ("id" "domain_data") ("type" "application/json") (text "{{ domain.data|json_encode()|safe }}")) (script diff --git a/crates/app/src/public/html/littleweb/service.lisp b/crates/app/src/public/html/littleweb/service.lisp index b0b7ac9..8ef12e1 100644 --- a/crates/app/src/public/html/littleweb/service.lisp +++ b/crates/app/src/public/html/littleweb/service.lisp @@ -14,9 +14,24 @@ (div ("class" "card-nest") (div - ("class" "card small") - (b - (text "{{ service.name }}"))) + ("class" "card small flex flex-col gap-2") + (div + ("class" "flex w-full gap-2 justify-between") + (b + (text "{{ service.name }}")) + + (button + ("class" "small lowered") + ("title" "Help") + ("onclick" "document.getElementById('site_help').classList.toggle('hidden')") + (icon (text "circle-question-mark")))) + + (div + ("class" "card w-full lowered flex flex-col gap-2 hidden no_p_margin") + ("id" "site_help") + (p (text "Your site should include an \"index.html\" file in order to show content on its homepage.")) + (p (text "In the HTML editor, you can type `!` and use the provided suggestion to get an HTML boilerplate.")) + (p (text "After you've created a page, you can click \"Copy ID\" and go to manage a domain you own. On the domain management page, click \"Add\" and paste the ID you copied into the value field.")))) (div ("class" "flex gap-2 flex-wrap card") @@ -72,53 +87,56 @@ ("class" "card flex flex-col gap-2") (text "{% if not file or file.children|length > 0 -%}") ; directory browser - (table - (thead - (tr - (th (text "Name")) - (th (text "Type")) - (th (text "Children")) - (th (text "Actions")))) + (div + ("class" "w-full") + ("style" "max-width: 100%; overflow: auto") + (table + (thead + (tr + (th (text "Name")) + (th (text "Type")) + (th (text "Children")) + (th (text "Actions")))) - (tbody - (text "{% for item in files %}") - (tr - (td - ("class" "flex gap-2 items-center") - (text "{% if item.children|length > 0 -%}") - (icon (text "folder")) - (text "{% else %}") - (icon (text "file")) - (text "{%- endif %}") + (tbody + (text "{% for item in files %}") + (tr + (td + ("class" "flex gap-2 items-center") + (text "{% if item.children|length > 0 -%}") + (icon (text "folder")) + (text "{% else %}") + (icon (text "file")) + (text "{%- endif %}") - (a - ("href" "?path={{ path }}/{{ item.name }}") - ("data-turbo" "false") - (text "{{ item.name }}"))) - (td (text "{{ item.mime }}")) - (td (text "{{ item.children|length }}")) - (td - ("style" "overflow: auto") - (div - ("class" "dropdown") - (button - ("class" "camo small") - ("onclick" "trigger('atto::hooks::dropdown', [event])") - ("exclude" "dropdown") - (icon (text "ellipsis"))) + (a + ("href" "?path={{ path }}/{{ item.name }}") + ("data-turbo" "false") + (text "{{ item.name }}"))) + (td (text "{{ item.mime }}")) + (td (text "{{ item.children|length }}")) + (td + ("style" "overflow: auto") (div - ("class" "inner") + ("class" "dropdown") (button - ("onclick" "rename_file('{{ item.id }}')") - (icon (text "pencil")) - (str (text "littleweb:action.rename"))) + ("class" "camo small") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + (icon (text "ellipsis"))) + (div + ("class" "inner") + (button + ("onclick" "rename_file('{{ item.id }}')") + (icon (text "pencil")) + (str (text "littleweb:action.rename"))) - (button - ("class" "red") - ("onclick" "remove_file('{{ item.id }}')") - (icon (text "trash")) - (str (text "general:action.delete"))))))) - (text "{% endfor %}"))) + (button + ("class" "red") + ("onclick" "remove_file('{{ item.id }}')") + (icon (text "trash")) + (str (text "general:action.delete"))))))) + (text "{% endfor %}")))) (text "{% else %}") ; file editor (div ("id" "editor_container") ("class" "w-full") ("style" "height: 600px")) @@ -319,6 +337,7 @@ (text "{% if file and file.mime != 'Plain' -%}") (script ("src" "https://unpkg.com/monaco-editor@0.52.2/min/vs/loader.js")) +(script ("src" "https://unpkg.com/emmet-monaco-es/dist/emmet-monaco.min.js")) (script ("id" "file_content") ("type" "text/plain") (text "{{ file.content|remove_script_tags|safe }}")) (script (text "require.config({ paths: { vs: \"https://unpkg.com/monaco-editor@0.52.2/min/vs\" } }); @@ -337,10 +356,16 @@ style.innerText = '@import \"https://unpkg.com/monaco-editor@0.52.2/min/vs/editor/editor.main.css\";'; shadow.appendChild(style); + emmetMonaco.emmetHTML(); + emmetMonaco.emmetCSS(); + globalThis.editor = monaco.editor.create(inner, { value: document.getElementById(\"file_content\").innerText.replaceAll(\"</script>\", \"\"), language: MIME_MODES[\"{{ file.mime }}\"], theme: \"vs-dark\", + suggest: { + snippetsPreventQuickSuggestions: false, + }, }); });")) (text "{%- endif %}") diff --git a/crates/core/src/model/littleweb.rs b/crates/core/src/model/littleweb.rs index 479a444..77b8dc6 100644 --- a/crates/core/src/model/littleweb.rs +++ b/crates/core/src/model/littleweb.rs @@ -193,7 +193,8 @@ macro_rules! define_domain_tlds { define_domain_tlds!( Bunny, Tet, Cool, Qwerty, Boy, Girl, Them, Quack, Bark, Meow, Silly, Wow, Neko, Yay, Lol, Love, - Fun, Gay, City, Woah, Clown, Apple, Yaoi, Yuri, World, Wav, Zero, Evil, Dragon, Yum, Site + Fun, Gay, City, Woah, Clown, Apple, Yaoi, Yuri, World, Wav, Zero, Evil, Dragon, Yum, Site, All, + Me, Bug, Slop, Retro, Eye, Neo, Spring ); #[derive(Debug, Clone, Serialize, Deserialize)] From e7febc7c7e9ce606cece0cab5ca8b16e3463cdc9 Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 8 Jul 2025 15:33:51 -0400 Subject: [PATCH 27/93] add: allow direct "atto://" links to work for script tags --- crates/app/src/public/html/littleweb/domain.lisp | 3 ++- crates/app/src/public/html/littleweb/service.lisp | 3 ++- crates/app/src/routes/api/v1/domains.rs | 3 ++- crates/core/src/database/domains.rs | 2 +- crates/core/src/database/services.rs | 2 +- 5 files changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/app/src/public/html/littleweb/domain.lisp b/crates/app/src/public/html/littleweb/domain.lisp index 96d2da7..d4bc359 100644 --- a/crates/app/src/public/html/littleweb/domain.lisp +++ b/crates/app/src/public/html/littleweb/domain.lisp @@ -121,8 +121,9 @@ ; data (div ("class" "w-full") - ("style" "max-width: 100%; overflow: auto") + ("style" "max-width: 100%; overflow: auto; min-height: 512px") (table + ("class" "w-full") (thead (tr (th (text "Name")) diff --git a/crates/app/src/public/html/littleweb/service.lisp b/crates/app/src/public/html/littleweb/service.lisp index 8ef12e1..7cd9597 100644 --- a/crates/app/src/public/html/littleweb/service.lisp +++ b/crates/app/src/public/html/littleweb/service.lisp @@ -89,8 +89,9 @@ ; directory browser (div ("class" "w-full") - ("style" "max-width: 100%; overflow: auto") + ("style" "max-width: 100%; overflow: auto; min-height: 512px") (table + ("class" "w-full") (thead (tr (th (text "Name")) diff --git a/crates/app/src/routes/api/v1/domains.rs b/crates/app/src/routes/api/v1/domains.rs index 8cfd9dc..f9949db 100644 --- a/crates/app/src/routes/api/v1/domains.rs +++ b/crates/app/src/routes/api/v1/domains.rs @@ -154,7 +154,8 @@ pub async fn get_file_request( ) } else { f.content - }, + } + .replace("atto://", "/api/v1/net/atto://"), )), None => { return Err(( diff --git a/crates/core/src/database/domains.rs b/crates/core/src/database/domains.rs index 672de1c..737bd5f 100644 --- a/crates/core/src/database/domains.rs +++ b/crates/core/src/database/domains.rs @@ -74,7 +74,7 @@ impl DataManager { Ok(res.unwrap()) } - const MAXIMUM_FREE_DOMAINS: usize = 5; + const MAXIMUM_FREE_DOMAINS: usize = 10; /// Create a new domain in the database. /// diff --git a/crates/core/src/database/services.rs b/crates/core/src/database/services.rs index adadf7e..f28460d 100644 --- a/crates/core/src/database/services.rs +++ b/crates/core/src/database/services.rs @@ -45,7 +45,7 @@ impl DataManager { Ok(res.unwrap()) } - const MAXIMUM_FREE_SERVICES: usize = 5; + const MAXIMUM_FREE_SERVICES: usize = 10; /// Create a new service in the database. /// From 388ccbf58cae140b176eeeb5d286fad246852833 Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 8 Jul 2025 17:38:24 -0400 Subject: [PATCH 28/93] add: small littleweb browser changes --- .../src/public/html/littleweb/browser.lisp | 12 ++- .../src/public/html/littleweb/domains.lisp | 11 +++ .../src/public/html/littleweb/services.lisp | 11 +++ crates/app/src/public/html/mod/profile.lisp | 89 +++++++++++++++++++ crates/app/src/public/js/atto.js | 16 ++-- crates/app/src/public/js/proto_links.js | 2 +- crates/app/src/routes/api/v1/domains.rs | 7 +- crates/app/src/routes/pages/littleweb.rs | 65 ++++++++++++-- crates/core/src/model/littleweb.rs | 2 +- 9 files changed, 194 insertions(+), 21 deletions(-) diff --git a/crates/app/src/public/html/littleweb/browser.lisp b/crates/app/src/public/html/littleweb/browser.lisp index 6da038f..e3e201d 100644 --- a/crates/app/src/public/html/littleweb/browser.lisp +++ b/crates/app/src/public/html/littleweb/browser.lisp @@ -32,7 +32,7 @@ (input ("type" "uri") ("class" "w-full") - ("true_value" "{{ path }}") + ("true_value" "") ("name" "uri") ("id" "uri")) @@ -121,10 +121,6 @@ uri = `${uri}/index.html`; } - if (!uri.startsWith(\"atto://\")) { - uri = `atto://${uri}`; - } - // ... console.log(\"navigate\", uri); document.getElementById(\"browser_iframe\").src = `{{ config.lw_host|safe }}/api/v1/net/${uri}`; @@ -152,7 +148,8 @@ if (data.event === \"change_url\") { const uri = new URL(data.target).pathname.slice(\"/api/v1/net/\".length); window.history.pushState(null, null, `/net/${uri.replace(\"atto://\", \"\")}`); - document.getElementById(\"uri\").setAttribute(\"true_value\", uri); + document.getElementById(\"uri\").setAttribute(\"true_value\", `atto://${uri}`); + document.getElementById(\"uri\").value = uri.split(\"/\")[0]; } }); @@ -207,6 +204,7 @@ is_focused = false; }); - document.getElementById(\"uri\").value = document.getElementById(\"uri\").getAttribute(\"true_value\").replace(\"atto://\", \"\").split(\"/\")[0]")) + // navigate + littleweb_navigate(\"{{ path|safe }}\");")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/littleweb/domains.lisp b/crates/app/src/public/html/littleweb/domains.lisp index 1a9b649..e3a6c10 100644 --- a/crates/app/src/public/html/littleweb/domains.lisp +++ b/crates/app/src/public/html/littleweb/domains.lisp @@ -5,6 +5,17 @@ (text "{% endblock %} {% block body %} {{ macros::nav() }}") (main ("class" "flex flex-col gap-2") + + ; viewing other user's domains warning + (text "{% if profile.id != user.id -%}") + (div + ("class" "card w-full red flex gap-2 items-center") + (text "{{ icon \"skull\" }}") + (b + (text "Viewing other user's domains! Please be careful."))) + (text "{%- endif %}") + + ; ... (text "{% if user -%}") (div ("class" "pillmenu") diff --git a/crates/app/src/public/html/littleweb/services.lisp b/crates/app/src/public/html/littleweb/services.lisp index cca5af7..83a6179 100644 --- a/crates/app/src/public/html/littleweb/services.lisp +++ b/crates/app/src/public/html/littleweb/services.lisp @@ -5,6 +5,17 @@ (text "{% endblock %} {% block body %} {{ macros::nav() }}") (main ("class" "flex flex-col gap-2") + + ; viewing other user's services warning + (text "{% if profile.id != user.id -%}") + (div + ("class" "card w-full red flex gap-2 items-center") + (text "{{ icon \"skull\" }}") + (b + (text "Viewing other user's sites! Please be careful."))) + (text "{%- endif %}") + + ; ... (text "{% if user -%}") (div ("class" "pillmenu") diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index 9fb5ebf..529228a 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -50,6 +50,18 @@ (span ("class" "notification") (text "{{ profile.request_count }}"))) + (a + ("href" "/services?id={{ profile.id }}") + ("class" "button lowered") + (icon (text "globe")) + (span + (text "Sites"))) + (a + ("href" "/domains?id={{ profile.id }}") + ("class" "button lowered") + (icon (text "globe")) + (span + (text "Domains"))) (button ("class" "red lowered") ("onclick" "delete_account(event)") @@ -155,6 +167,33 @@ }); }; + globalThis.update_user_secondary_role = async (new_role) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(`/api/v1/auth/user/{{ profile.id }}/role/2`, { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + role: Number.parseInt(new_role), + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + ui.refresh_container(element, [\"actions\"]); setTimeout(() => { @@ -178,6 +217,11 @@ \"{{ profile.permissions }}\", \"input\", ], + [ + [\"secondary_role\", \"Secondary permission level\"], + \"{{ profile.secondary_permissions }}\", + \"input\", + ], ], null, { @@ -194,6 +238,9 @@ role: (new_role) => { return update_user_role(new_role); }, + secondary_role: (new_role) => { + return update_user_secondary_role(new_role); + }, }, ); }, 100); @@ -244,6 +291,24 @@ (div ("class" "card lowered flex flex-col gap-2") ("id" "permission_builder"))) + (div + ("class" "card-nest w-full") + (div + ("class" "card small flex items-center justify-between gap-2") + (div + ("class" "flex items-center gap-2") + (text "{{ icon \"blocks\" }}") + (span + (text "{{ text \"mod_panel:label.permissions_level_builder\" }}"))) + (button + ("class" "small lowered") + ("onclick" "update_user_secondary_role(Number.parseInt(document.getElementById('role').value))") + (text "{{ icon \"check\" }}") + (span + (text "{{ text \"general:action.save\" }}")))) + (div + ("class" "card lowered flex flex-col gap-2") + ("id" "secondary_permission_builder"))) (script (text "setTimeout(async () => { const get_permissions_html = await trigger( @@ -291,6 +356,30 @@ Number.parseInt(\"{{ profile.permissions }}\"), \"permission_builder\", ); + }, 250); + + setTimeout(async () => { + const get_permissions_html = await trigger( + \"ui::generate_permissions_ui\", + [ + { + // https://trisuaso.github.io/tetratto/tetratto/model/permissions/struct.SecondaryPermission.html + DEFAULT: 1 << 0, + ADMINISTRATOR: 1 << 1, + MANAGE_DOMAINS: 1 << 2, + MANAGE_SECONDARY: 1 << 3, + }, + \"secondary_role\", + \"add_permission_to_secondary_role\", + \"remove_permission_to_secondary_role\", + ], + ); + + document.getElementById(\"secondary_permission_builder\").innerHTML = + get_permissions_html( + Number.parseInt(\"{{ profile.secondary_permissions }}\"), + \"secondary_permission_builder\", + ); }, 250);"))) (text "{% endblock %}") diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 1b2a4db..f67cd2c 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -1069,7 +1069,13 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} // permissions ui self.define( "generate_permissions_ui", - (_, permissions, field_id = "role") => { + ( + _, + permissions, + field_id = "role", + add_name = "add_permission_to_role", + remove_name = "remove_permission_from_role", + ) => { function all_matching_permissions(role) { const matching = []; const not_matching = []; @@ -1099,7 +1105,7 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} function get_permissions_html(role, id) { const [matching, not_matching] = all_matching_permissions(role); - globalThis.remove_permission_from_role = (permission) => { + globalThis[remove_name] = (permission) => { matching.splice(matching.indexOf(permission), 1); not_matching.push(permission); @@ -1107,7 +1113,7 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} get_permissions_html(rebuild_role(matching), id); }; - globalThis.add_permission_to_role = (permission) => { + globalThis[add_name] = (permission) => { not_matching.splice(not_matching.indexOf(permission), 1); matching.push(permission); @@ -1120,14 +1126,14 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} for (const match of matching) { permissions_html += `
${match} ${permissions[match]} - +
`; } for (const match of not_matching) { permissions_html += `
${match} ${permissions[match]} - +
`; } diff --git a/crates/app/src/public/js/proto_links.js b/crates/app/src/public/js/proto_links.js index ab5d938..a2f3ffc 100644 --- a/crates/app/src/public/js/proto_links.js +++ b/crates/app/src/public/js/proto_links.js @@ -54,7 +54,7 @@ function fix_atto_links() { `atto://${path.replace("atto://", "").split("/")[0]}${x}`; } else { y[property] = - `/api/v1/net/atto://${path.replace("atto://", "").split("/")[0]}${x}`; + `/api/v1/net/${path.replace("atto://", "").split("/")[0]}${x}`; } } } diff --git a/crates/app/src/routes/api/v1/domains.rs b/crates/app/src/routes/api/v1/domains.rs index f9949db..51056a3 100644 --- a/crates/app/src/routes/api/v1/domains.rs +++ b/crates/app/src/routes/api/v1/domains.rs @@ -110,9 +110,14 @@ pub async fn delete_request( } pub async fn get_file_request( - Path(addr): Path, + Path(mut addr): Path, Extension(data): Extension, ) -> impl IntoResponse { + if !addr.starts_with("atto://") { + addr = format!("atto://{addr}"); + } + + // ... let data = &(data.read().await).0; let (subdomain, domain, tld, path) = Domain::from_str(&addr); diff --git a/crates/app/src/routes/pages/littleweb.rs b/crates/app/src/routes/pages/littleweb.rs index 9dc5907..d37a45a 100644 --- a/crates/app/src/routes/pages/littleweb.rs +++ b/crates/app/src/routes/pages/littleweb.rs @@ -1,18 +1,22 @@ use super::render_error; -use crate::{assets::initial_context, get_lang, get_user_from_token, State}; +use crate::{ + assets::initial_context, get_lang, get_user_from_token, + routes::pages::misc::NotificationsProps, State, +}; use axum::{ response::{Html, IntoResponse}, extract::{Query, Path}, Extension, }; use axum_extra::extract::CookieJar; -use tetratto_core::model::{littleweb::TLDS_VEC, Error}; +use tetratto_core::model::{littleweb::TLDS_VEC, permissions::SecondaryPermission, Error}; use serde::Deserialize; /// `/services` pub async fn services_request( jar: CookieJar, Extension(data): Extension, + Query(props): Query, ) -> impl IntoResponse { let data = data.read().await; let user = match get_user_from_token!(jar, data.0) { @@ -24,7 +28,26 @@ pub async fn services_request( } }; - let list = match data.0.get_services_by_user(user.id).await { + let profile = if props.id != 0 { + match data.0.get_user_by_id(props.id).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), + } + } else { + user.clone() + }; + + if profile.id != user.id + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_SERVICES) + { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + + let list = match data.0.get_services_by_user(profile.id).await { Ok(x) => x, Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), }; @@ -32,6 +55,7 @@ pub async fn services_request( let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; context.insert("list", &list); + context.insert("profile", &profile); // return Ok(Html( @@ -43,6 +67,7 @@ pub async fn services_request( pub async fn domains_request( jar: CookieJar, Extension(data): Extension, + Query(props): Query, ) -> impl IntoResponse { let data = data.read().await; let user = match get_user_from_token!(jar, data.0) { @@ -54,6 +79,25 @@ pub async fn domains_request( } }; + let profile = if props.id != 0 { + match data.0.get_user_by_id(props.id).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), + } + } else { + user.clone() + }; + + if profile.id != user.id + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_DOMAINS) + { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + let list = match data.0.get_domains_by_user(user.id).await { Ok(x) => x, Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), @@ -64,6 +108,7 @@ pub async fn domains_request( context.insert("list", &list); context.insert("tlds", &*TLDS_VEC); + context.insert("profile", &profile); // return Ok(Html( @@ -99,7 +144,11 @@ pub async fn service_request( Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), }; - if user.id != service.owner { + if user.id != service.owner + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_SERVICES) + { return Err(Html( render_error(Error::NotAllowed, &jar, &data, &None).await, )); @@ -153,7 +202,11 @@ pub async fn domain_request( Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), }; - if user.id != domain.owner { + if user.id != domain.owner + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_DOMAINS) + { return Err(Html( render_error(Error::NotAllowed, &jar, &data, &None).await, )); @@ -204,7 +257,7 @@ pub async fn browser_request( let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &user).await; - context.insert("path", &uri); + context.insert("path", &uri.replace("atto://", "")); // return Html(data.1.render("littleweb/browser.html", &context).unwrap()) diff --git a/crates/core/src/model/littleweb.rs b/crates/core/src/model/littleweb.rs index 77b8dc6..4c85024 100644 --- a/crates/core/src/model/littleweb.rs +++ b/crates/core/src/model/littleweb.rs @@ -194,7 +194,7 @@ macro_rules! define_domain_tlds { define_domain_tlds!( Bunny, Tet, Cool, Qwerty, Boy, Girl, Them, Quack, Bark, Meow, Silly, Wow, Neko, Yay, Lol, Love, Fun, Gay, City, Woah, Clown, Apple, Yaoi, Yuri, World, Wav, Zero, Evil, Dragon, Yum, Site, All, - Me, Bug, Slop, Retro, Eye, Neo, Spring + Me, Bug, Slop, Retro, Eye, Neo, Spring, Nurse, Pony ); #[derive(Debug, Clone, Serialize, Deserialize)] From 65e5d5f4e958fdc72c6fc33076507490d0170629 Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 8 Jul 2025 17:44:49 -0400 Subject: [PATCH 29/93] fix: user domains view for staff --- crates/app/src/public/html/mod/profile.lisp | 2 +- crates/app/src/routes/pages/littleweb.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index 529228a..5121fb9 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -367,7 +367,7 @@ DEFAULT: 1 << 0, ADMINISTRATOR: 1 << 1, MANAGE_DOMAINS: 1 << 2, - MANAGE_SECONDARY: 1 << 3, + MANAGE_SERVICES: 1 << 3, }, \"secondary_role\", \"add_permission_to_secondary_role\", diff --git a/crates/app/src/routes/pages/littleweb.rs b/crates/app/src/routes/pages/littleweb.rs index d37a45a..61560d7 100644 --- a/crates/app/src/routes/pages/littleweb.rs +++ b/crates/app/src/routes/pages/littleweb.rs @@ -98,7 +98,7 @@ pub async fn domains_request( )); } - let list = match data.0.get_domains_by_user(user.id).await { + let list = match data.0.get_domains_by_user(profile.id).await { Ok(x) => x, Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), }; From e72ccf913963cdf1d47fa81cd185611720295621 Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 8 Jul 2025 17:52:39 -0400 Subject: [PATCH 30/93] fix: mod panel secondary role builder --- crates/app/src/public/html/mod/profile.lisp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index 5121fb9..2121f1e 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -302,7 +302,7 @@ (text "{{ text \"mod_panel:label.permissions_level_builder\" }}"))) (button ("class" "small lowered") - ("onclick" "update_user_secondary_role(Number.parseInt(document.getElementById('role').value))") + ("onclick" "update_user_secondary_role(Number.parseInt(document.getElementById('secondary_role').value))") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}")))) From 22a2545aa0efedd839493c72aa3d811b1e9dc0a1 Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 8 Jul 2025 18:25:47 -0400 Subject: [PATCH 31/93] fix: littleweb browser page url bar --- crates/app/src/public/html/littleweb/browser.lisp | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/crates/app/src/public/html/littleweb/browser.lisp b/crates/app/src/public/html/littleweb/browser.lisp index e3e201d..8e298e2 100644 --- a/crates/app/src/public/html/littleweb/browser.lisp +++ b/crates/app/src/public/html/littleweb/browser.lisp @@ -148,7 +148,13 @@ if (data.event === \"change_url\") { const uri = new URL(data.target).pathname.slice(\"/api/v1/net/\".length); window.history.pushState(null, null, `/net/${uri.replace(\"atto://\", \"\")}`); - document.getElementById(\"uri\").setAttribute(\"true_value\", `atto://${uri}`); + + if (!uri.includes(\"atto://\")) { + document.getElementById(\"uri\").setAttribute(\"true_value\", `atto://${uri}`); + } else { + document.getElementById(\"uri\").setAttribute(\"true_value\", uri); + } + document.getElementById(\"uri\").value = uri.split(\"/\")[0]; } }); @@ -205,6 +211,8 @@ }); // navigate - littleweb_navigate(\"{{ path|safe }}\");")) + if ({{ path|length }} > 0) { + littleweb_navigate(\"{{ path|safe }}\"); + }")) (text "{% endblock %}") From 7ead0ce7757c5af9aed1ea17a832d10b9893a7a6 Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 8 Jul 2025 18:29:59 -0400 Subject: [PATCH 32/93] fix: don't change link hrefs in littleweb browser --- crates/app/src/public/js/proto_links.js | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/crates/app/src/public/js/proto_links.js b/crates/app/src/public/js/proto_links.js index a2f3ffc..87986b3 100644 --- a/crates/app/src/public/js/proto_links.js +++ b/crates/app/src/public/js/proto_links.js @@ -60,8 +60,7 @@ function fix_atto_links() { } fix_element("a", "href", true); - fix_element("link", "href", false); - fix_element("script", "src", false); + fix_element("img", "src", false); // send message window.top.postMessage( From 69067145cedca4ab1988c04ca8d4560fc01d654b Mon Sep 17 00:00:00 2001 From: trisua Date: Wed, 9 Jul 2025 21:44:49 -0400 Subject: [PATCH 33/93] fix: home timeline setting --- crates/app/src/public/html/profile/settings.lisp | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index b7f0947..5fe4010 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -134,10 +134,12 @@ ("selected" "{% if home == '/all/questions' -%}true{% else %}false{%- endif %}") (text "All (questions)")) (text "{% for stack in stacks %}") - (option - ("value" "{\\\"Stack\\\":\\\"{{ stack.id }}\\\"}") - ("selected" "{% if home is ending_with(stack.id|as_str) -%}true{% else %}false{%- endif %}") - (text "{{ stack.name }} (stack)")) + (text "") (text "{% endfor %}")) (span ("class" "fade") From 7960f1ed419386190fb6c4e7ea0592152f7e4ecc Mon Sep 17 00:00:00 2001 From: trisua Date: Wed, 9 Jul 2025 22:29:54 -0400 Subject: [PATCH 34/93] fix: nsfw posts in all/communities timelines --- .../src/routes/api/v1/communities/posts.rs | 2 +- crates/app/src/routes/pages/misc.rs | 4 +-- crates/core/src/database/posts.rs | 28 +++++++++++++++++-- 3 files changed, 28 insertions(+), 6 deletions(-) diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index d6554ff..b4b3896 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -723,7 +723,7 @@ pub async fn from_communities_request( }; match data - .get_posts_from_user_communities(user.id, 12, props.page) + .get_posts_from_user_communities(user.id, 12, props.page, &user) .await { Ok(posts) => { diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs index e65f4b5..5d017ef 100644 --- a/crates/app/src/routes/pages/misc.rs +++ b/crates/app/src/routes/pages/misc.rs @@ -58,7 +58,7 @@ pub async fn index_request( let list = match data .0 - .get_posts_from_user_communities(user.id, 12, req.page) + .get_posts_from_user_communities(user.id, 12, req.page, &user) .await { Ok(l) => match data @@ -725,7 +725,7 @@ pub async fn swiss_army_timeline_request( DefaultTimelineChoice::MyCommunities => { if let Some(ref ua) = user { data.0 - .get_posts_from_user_communities(ua.id, 12, req.page) + .get_posts_from_user_communities(ua.id, 12, req.page, ua) .await } else { return Err(Html( diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index becb780..dda4ae6 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -1452,6 +1452,14 @@ impl DataManager { false }; + // check if we should hide nsfw posts + let mut hide_nsfw: bool = true; + + if let Some(ua) = as_user { + hide_nsfw = !ua.settings.show_nsfw; + } + + // ... let conn = match self.0.connect().await { Ok(c) => c, Err(e) => return Err(Error::DatabaseConnection(e.to_string())), @@ -1460,12 +1468,17 @@ impl DataManager { let res = query_rows!( &conn, &format!( - "SELECT * FROM posts WHERE replying_to = 0{} AND NOT context LIKE '%\"is_nsfw\":true%'{} ORDER BY created DESC LIMIT $1 OFFSET $2", + "SELECT * FROM posts WHERE replying_to = 0{}{}{} ORDER BY created DESC LIMIT $1 OFFSET $2", if before_time > 0 { format!(" AND created < {before_time}") } else { String::new() }, + if hide_nsfw { + " AND NOT context LIKE '%\"is_nsfw\":true%'" + } else { + "" + }, if hide_answers { " AND context::jsonb->>'answering' = '0'" } else { @@ -1494,6 +1507,7 @@ impl DataManager { id: usize, batch: usize, page: usize, + user: &User, ) -> Result> { let memberships = self.get_memberships_by_owner(id).await?; let mut memberships = memberships.iter(); @@ -1508,6 +1522,9 @@ impl DataManager { query_string.push_str(&format!(" OR community = {}", membership.community)); } + // check if we should hide nsfw posts + let hide_nsfw: bool = !user.settings.show_nsfw; + // ... let conn = match self.0.connect().await { Ok(c) => c, @@ -1517,8 +1534,13 @@ impl DataManager { let res = query_rows!( &conn, &format!( - "SELECT * FROM posts WHERE (community = {} {query_string}) AND NOT context LIKE '%\"is_nsfw\":true%' AND replying_to = 0 AND is_deleted = 0 ORDER BY created DESC LIMIT $1 OFFSET $2", - first.community + "SELECT * FROM posts WHERE (community = {} {query_string}){} AND replying_to = 0 AND is_deleted = 0 ORDER BY created DESC LIMIT $1 OFFSET $2", + first.community, + if hide_nsfw { + " AND NOT context LIKE '%\"is_nsfw\":true%'" + } else { + "" + }, ), &[&(batch as i64), &((page * batch) as i64)], |x| { Self::get_post_from_row(x) } From 4e152b07be722e157d1e1d4366b1899727c06d82 Mon Sep 17 00:00:00 2001 From: trisua Date: Wed, 9 Jul 2025 22:59:28 -0400 Subject: [PATCH 35/93] add: littleweb (common) achievements --- crates/app/src/routes/api/v1/domains.rs | 12 +++++++++++- crates/app/src/routes/api/v1/services.rs | 13 +++++++++++-- crates/core/src/model/auth.rs | 8 ++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/crates/app/src/routes/api/v1/domains.rs b/crates/app/src/routes/api/v1/domains.rs index 51056a3..40ff713 100644 --- a/crates/app/src/routes/api/v1/domains.rs +++ b/crates/app/src/routes/api/v1/domains.rs @@ -6,6 +6,7 @@ use crate::{ use axum::{extract::Path, response::IntoResponse, http::StatusCode, Extension, Json}; use axum_extra::extract::CookieJar; use tetratto_core::model::{ + auth::AchievementName, littleweb::{Domain, ServiceFsMime}, oauth, ApiReturn, Error, }; @@ -48,11 +49,20 @@ pub async fn create_request( Json(req): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateDomains) { + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateDomains) { Some(ua) => ua, None => return Json(Error::NotAllowed.into()), }; + // award achievement + if let Err(e) = data + .add_achievement(&mut user, AchievementName::CreateDomain.into(), true) + .await + { + return Json(e.into()); + } + + // ... match data .create_domain(Domain::new(req.name, req.tld, user.id)) .await diff --git a/crates/app/src/routes/api/v1/services.rs b/crates/app/src/routes/api/v1/services.rs index 252fe5a..d1ffbf0 100644 --- a/crates/app/src/routes/api/v1/services.rs +++ b/crates/app/src/routes/api/v1/services.rs @@ -7,7 +7,7 @@ use crate::{ }; use axum::{extract::Path, response::IntoResponse, Extension, Json}; use axum_extra::extract::CookieJar; -use tetratto_core::model::{littleweb::Service, oauth, ApiReturn, Error}; +use tetratto_core::model::{auth::AchievementName, littleweb::Service, oauth, ApiReturn, Error}; pub async fn get_request( Path(id): Path, @@ -47,11 +47,20 @@ pub async fn create_request( Json(req): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateServices) { + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateServices) { Some(ua) => ua, None => return Json(Error::NotAllowed.into()), }; + // award achievement + if let Err(e) = data + .add_achievement(&mut user, AchievementName::CreateSite.into(), true) + .await + { + return Json(e.into()); + } + + // ... match data.create_service(Service::new(req.name, user.id)).await { Ok(x) => Json(ApiReturn { ok: true, diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 2b47562..4303640 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -567,6 +567,8 @@ pub enum AchievementName { GetAllOtherAchievements, AcceptProfileWarning, OpenSessionSettings, + CreateSite, + CreateDomain, } #[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] @@ -613,6 +615,8 @@ impl AchievementName { Self::GetAllOtherAchievements => "The final performance", Self::AcceptProfileWarning => "I accept the risks!", Self::OpenSessionSettings => "Am I alone in here?", + Self::CreateSite => "Littlewebmaster", + Self::CreateDomain => "LittleDNS", } } @@ -652,6 +656,8 @@ impl AchievementName { Self::GetAllOtherAchievements => "Get every other achievement.", Self::AcceptProfileWarning => "Accept a profile warning.", Self::OpenSessionSettings => "Open your session settings.", + Self::CreateSite => "Create a site.", + Self::CreateDomain => "Create a domain.", } } @@ -693,6 +699,8 @@ impl AchievementName { Self::GetAllOtherAchievements => Rare, Self::AcceptProfileWarning => Common, Self::OpenSessionSettings => Common, + Self::CreateSite => Common, + Self::CreateDomain => Common, } } } From bdd8f9a869191b80840767c034dab9b592350645 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 10 Jul 2025 13:32:43 -0400 Subject: [PATCH 36/93] add: hide_from_social_lists user setting --- .../app/src/public/html/profile/settings.lisp | 11 +++- crates/app/src/routes/api/v1/auth/social.rs | 10 +++- crates/app/src/routes/pages/profile.rs | 6 ++- crates/core/src/database/userfollows.rs | 50 +++++++++++++------ crates/core/src/model/auth.rs | 8 ++- 5 files changed, 64 insertions(+), 21 deletions(-) diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 5fe4010..6d08053 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -143,8 +143,7 @@ (text "{% endfor %}")) (span ("class" "fade") - (text "This represents the timeline the home button takes you - to.")))) + (text "This represents the timeline the home button takes you to.")))) (div ("class" "card-nest desktop") ("ui_ident" "notifications") @@ -1540,6 +1539,14 @@ \"{{ profile.settings.hide_associated_blocked_users }}\", \"checkbox\", ], + [ + [ + \"hide_from_social_lists\", + \"Hide my profile from social lists (followers/following)\", + ], + \"{{ profile.settings.hide_from_social_lists }}\", + \"checkbox\", + ], [[], \"Questions\", \"title\"], [ [ diff --git a/crates/app/src/routes/api/v1/auth/social.rs b/crates/app/src/routes/api/v1/auth/social.rs index b80bd14..88a78b5 100644 --- a/crates/app/src/routes/api/v1/auth/social.rs +++ b/crates/app/src/routes/api/v1/auth/social.rs @@ -278,7 +278,10 @@ pub async fn followers_request( Ok(f) => Json(ApiReturn { ok: true, message: "Success".to_string(), - payload: match data.fill_userfollows_with_initiator(f).await { + payload: match data + .fill_userfollows_with_initiator(f, &Some(user.clone()), id == user.id) + .await + { Ok(f) => Some(data.userfollows_user_filter(&f)), Err(e) => return Json(e.into()), }, @@ -310,7 +313,10 @@ pub async fn following_request( Ok(f) => Json(ApiReturn { ok: true, message: "Success".to_string(), - payload: match data.fill_userfollows_with_receiver(f).await { + payload: match data + .fill_userfollows_with_receiver(f, &Some(user.clone()), id == user.id) + .await + { Ok(f) => Some(data.userfollows_user_filter(&f)), Err(e) => return Json(e.into()), }, diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index 186d291..4d12556 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -70,6 +70,8 @@ pub async fn settings_request( .get_userfollows_by_initiator_all(profile.id) .await .unwrap_or(Vec::new()), + &None, + false, ) .await { @@ -718,7 +720,7 @@ pub async fn following_request( .get_userfollows_by_initiator(other_user.id, 12, props.page) .await { - Ok(l) => match data.0.fill_userfollows_with_receiver(l).await { + Ok(l) => match data.0.fill_userfollows_with_receiver(l, &user, true).await { Ok(l) => l, Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), }, @@ -813,7 +815,7 @@ pub async fn followers_request( .get_userfollows_by_receiver(other_user.id, 12, props.page) .await { - Ok(l) => match data.0.fill_userfollows_with_initiator(l).await { + Ok(l) => match data.0.fill_userfollows_with_initiator(l, &user, true).await { Ok(l) => l, Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), }, diff --git a/crates/core/src/database/userfollows.rs b/crates/core/src/database/userfollows.rs index 5428f67..63fcfbe 100644 --- a/crates/core/src/database/userfollows.rs +++ b/crates/core/src/database/userfollows.rs @@ -195,18 +195,29 @@ impl DataManager { pub async fn fill_userfollows_with_receiver( &self, userfollows: Vec, + as_user: &Option, + do_check: bool, ) -> Result> { let mut out: Vec<(UserFollow, User)> = Vec::new(); for userfollow in userfollows { let receiver = userfollow.receiver; - out.push(( - userfollow, - match self.get_user_by_id(receiver).await { - Ok(u) => u, - Err(_) => continue, - }, - )); + let user = match self.get_user_by_id(receiver).await { + Ok(u) => u, + Err(_) => continue, + }; + + if user.settings.hide_from_social_lists && do_check { + if let Some(ua) = as_user { + if !ua.permissions.check(FinePermission::MANAGE_USERS) { + continue; + } + } else { + continue; + } + } + + out.push((userfollow, user)); } Ok(out) @@ -216,18 +227,29 @@ impl DataManager { pub async fn fill_userfollows_with_initiator( &self, userfollows: Vec, + as_user: &Option, + do_check: bool, ) -> Result> { let mut out: Vec<(UserFollow, User)> = Vec::new(); for userfollow in userfollows { let initiator = userfollow.initiator; - out.push(( - userfollow, - match self.get_user_by_id(initiator).await { - Ok(u) => u, - Err(_) => continue, - }, - )); + let user = match self.get_user_by_id(initiator).await { + Ok(u) => u, + Err(_) => continue, + }; + + if user.settings.hide_from_social_lists && do_check { + if let Some(ua) = as_user { + if !ua.permissions.check(FinePermission::MANAGE_USERS) { + continue; + } + } else { + continue; + } + } + + out.push((userfollow, user)); } Ok(out) diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 4303640..3b14d41 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -302,6 +302,12 @@ pub struct UserSettings { /// Which tab is shown by default on the user's profile. #[serde(default)] pub default_profile_tab: DefaultProfileTabChoice, + /// If the user is hidden from followers/following tabs. + /// + /// The user will still impact the followers/following numbers, but will not + /// be shown in the UI (or API). + #[serde(default)] + pub hide_from_social_lists: bool, } fn mime_avif() -> String { @@ -521,7 +527,7 @@ pub struct ExternalConnectionData { } /// The total number of achievements needed to 100% Tetratto! -pub const ACHIEVEMENTS: usize = 34; +pub const ACHIEVEMENTS: usize = 36; /// "self-serve" achievements can be granted by the user through the API. pub const SELF_SERVE_ACHIEVEMENTS: &[AchievementName] = &[ AchievementName::OpenReference, From 14f3bf849eabb07f1abbf0e6d6aa831b0915f9ae Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 10 Jul 2025 18:43:54 -0400 Subject: [PATCH 37/93] add: post full unlist option --- crates/app/src/public/html/components.lisp | 12 ++++++++++++ crates/app/src/public/html/post/post.lisp | 7 ++++++- crates/app/src/public/html/profile/settings.lisp | 10 ++++++++++ crates/core/src/database/posts.rs | 12 ++++++++++-- crates/core/src/model/auth.rs | 4 ++++ crates/core/src/model/communities.rs | 3 +++ 6 files changed, 45 insertions(+), 3 deletions(-) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index d8c192f..77b28c8 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -173,6 +173,12 @@ ("class" "flex items-center") ("style" "color: var(--color-primary)") (text "{{ icon \"square-asterisk\" }}")) + (text "{%- endif %} {% if post.context.full_unlist -%}") + (span + ("title" "Unlisted") + ("class" "flex items-center") + ("style" "color: var(--color-primary)") + (icon (text "eye-off"))) (text "{%- endif %} {% if post.stack -%}") (a ("title" "Posted to a stack you're in") @@ -1507,6 +1513,7 @@ is_nsfw: false, content_warning: \"\", tags: [], + full_unlist: false, }; window.BLANK_INITIAL_SETTINGS = JSON.stringify( @@ -1543,6 +1550,11 @@ // window.POST_INITIAL_SETTINGS.is_nsfw.toString(), // \"checkbox\", // ], + [ + [\"full_unlist\", \"Unlist from timelines\"], + window.POST_INITIAL_SETTINGS.full_unlist.toString(), + \"checkbox\", + ], [ [\"content_warning\", \"Content warning\"], window.POST_INITIAL_SETTINGS.content_warning, diff --git a/crates/app/src/public/html/post/post.lisp b/crates/app/src/public/html/post/post.lisp index 11a5156..062db3d 100644 --- a/crates/app/src/public/html/post/post.lisp +++ b/crates/app/src/public/html/post/post.lisp @@ -201,7 +201,7 @@ \"checkbox\", ], [ - [\"is_nsfw\", \"Hide from public timelines\"], + [\"is_nsfw\", \"Mark as NSFW\"], \"{{ community.context.is_nsfw }}\", \"checkbox\", ], @@ -210,6 +210,11 @@ settings.content_warning, \"textarea\", ], + [ + [\"full_unlist\", \"Unlist from timelines\"], + \"{{ user.settings.auto_full_unlist }}\", + \"checkbox\", + ], [ [\"tags\", \"Tags\"], settings.tags.join(\", \"), diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 6d08053..4397155 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -1526,6 +1526,16 @@ \"{{ profile.settings.auto_unlist }}\", \"checkbox\", ], + [ + [\"auto_full_unlist\", \"Only publish my posts to my profile\"], + \"{{ profile.settings.auto_unlist }}\", + \"checkbox\", + ], + [ + [], + \"Your posts will still be visible in the \\\"Following\\\" timeline for users following you.\", + \"text\", + ], [ [\"all_timeline_hide_answers\", 'Hide posts answering questions from the \"All\" timeline'], \"{{ profile.settings.all_timeline_hide_answers }}\", diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index dda4ae6..f17bbea 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -1468,7 +1468,7 @@ impl DataManager { let res = query_rows!( &conn, &format!( - "SELECT * FROM posts WHERE replying_to = 0{}{}{} ORDER BY created DESC LIMIT $1 OFFSET $2", + "SELECT * FROM posts WHERE replying_to = 0{}{}{} AND NOT context LIKE '%\"full_unlist\":true%' ORDER BY created DESC LIMIT $1 OFFSET $2", if before_time > 0 { format!(" AND created < {before_time}") } else { @@ -1534,7 +1534,7 @@ impl DataManager { let res = query_rows!( &conn, &format!( - "SELECT * FROM posts WHERE (community = {} {query_string}){} AND replying_to = 0 AND is_deleted = 0 ORDER BY created DESC LIMIT $1 OFFSET $2", + "SELECT * FROM posts WHERE (community = {} {query_string}){} AND NOT context LIKE '%\"full_unlist\":true%' AND replying_to = 0 AND is_deleted = 0 ORDER BY created DESC LIMIT $1 OFFSET $2", first.community, if hide_nsfw { " AND NOT context LIKE '%\"is_nsfw\":true%'" @@ -1979,6 +1979,10 @@ impl DataManager { data.context.is_nsfw = true; } + if owner.settings.auto_full_unlist { + data.context.full_unlist = true; + } + // ... let conn = match self.0.connect().await { Ok(c) => c, @@ -2379,6 +2383,10 @@ impl DataManager { x.is_nsfw = true; } + if user.settings.auto_full_unlist { + x.full_unlist = true; + } + // ... let conn = match self.0.connect().await { Ok(c) => c, diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 3b14d41..c3d7de9 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -308,6 +308,10 @@ pub struct UserSettings { /// be shown in the UI (or API). #[serde(default)] pub hide_from_social_lists: bool, + /// Automatically hide your posts from all timelines except your profile + /// and the following timeline. + #[serde(default)] + pub auto_full_unlist: bool, } fn mime_avif() -> String { diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index 4df9795..8a4ab9a 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -190,6 +190,8 @@ pub struct PostContext { pub content_warning: String, #[serde(default)] pub tags: Vec, + #[serde(default)] + pub full_unlist: bool, } fn default_comments_enabled() -> bool { @@ -218,6 +220,7 @@ impl Default for PostContext { reactions_enabled: default_reactions_enabled(), content_warning: String::new(), tags: Vec::new(), + full_unlist: false, } } } From 9aee80493f6e7eb3fac8b73ddfb89b7e284e9f31 Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 11 Jul 2025 12:35:47 -0400 Subject: [PATCH 38/93] fix: anonymous post page panic --- crates/app/src/public/html/post/post.lisp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/app/src/public/html/post/post.lisp b/crates/app/src/public/html/post/post.lisp index 062db3d..b43fc82 100644 --- a/crates/app/src/public/html/post/post.lisp +++ b/crates/app/src/public/html/post/post.lisp @@ -125,7 +125,6 @@ (text "{{ icon \"settings\" }}") (span (text "{{ text \"communities:action.configure\" }}")))) - (text "{%- endif %}") (div ("class" "flex flex-col gap-2 hidden") ("data-tab" "configure") @@ -250,6 +249,7 @@ }, }); }, 250);"))) + (text "{%- endif %}") (text "{% if user and user.id == post.owner -%}") (div ("class" "card-nest w-full hidden") From cfcc2358f40f0a426bf4bc9c9f155f9db2f5390b Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 11 Jul 2025 18:56:49 -0400 Subject: [PATCH 39/93] add: service edit date + browser session ids --- crates/app/src/macros.rs | 14 +++++ crates/app/src/main.rs | 2 +- .../src/public/html/littleweb/browser.lisp | 7 ++- .../src/public/html/littleweb/services.lisp | 5 +- crates/app/src/public/js/proto_links.js | 4 +- crates/app/src/routes/api/v1/domains.rs | 59 +++++++++++++++---- crates/app/src/routes/api/v1/services.rs | 17 ++++-- crates/app/src/routes/pages/journals.rs | 2 +- crates/app/src/routes/pages/littleweb.rs | 33 ++++++++++- crates/core/src/database/auth.rs | 6 +- .../database/drivers/sql/create_services.sql | 3 +- .../src/database/drivers/sql/create_users.sql | 3 +- crates/core/src/database/services.rs | 5 +- crates/core/src/model/auth.rs | 11 ++++ crates/core/src/model/littleweb.rs | 2 + sql_changes/browser_session.sql | 2 + sql_changes/services_revision.sql | 2 + 17 files changed, 148 insertions(+), 29 deletions(-) create mode 100644 sql_changes/browser_session.sql create mode 100644 sql_changes/services_revision.sql diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 2c3c03c..2f5433d 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -140,6 +140,20 @@ macro_rules! get_user_from_token { None } }}; + + (--browser_session=$browser_session:expr, $db:expr) => {{ + // browser session id + match $db.get_user_by_browser_session(&$browser_session).await { + Ok(ua) => { + if ua.permissions.check_banned() { + None + } else { + Some(ua) + } + } + Err(_) => None, + } + }}; } #[macro_export] diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 00ad85f..b4ffbe6 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -123,7 +123,7 @@ async fn main() { .merge(routes::routes(&config)) .layer(SetResponseHeaderLayer::if_not_present( HeaderName::from_static("content-security-policy"), - HeaderValue::from_static("default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; worker-src * blob:; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *; frame-ancestors 'self'"), + HeaderValue::from_static("default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; worker-src * blob:; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' blob: *; frame-ancestors 'self'"), )); } diff --git a/crates/app/src/public/html/littleweb/browser.lisp b/crates/app/src/public/html/littleweb/browser.lisp index 8e298e2..67c64ab 100644 --- a/crates/app/src/public/html/littleweb/browser.lisp +++ b/crates/app/src/public/html/littleweb/browser.lisp @@ -55,7 +55,7 @@ (iframe ("id" "browser_iframe") ("frameborder" "0") - ("src" "{% if path -%} {{ config.lw_host }}/api/v1/net/{{ path }} {%- endif %}")) + ("src" "{% if path -%} {{ config.lw_host }}/api/v1/net/{{ path }}?s={{ session }} {%- endif %}")) (style ("data-turbo-temporary" "true") @@ -116,14 +116,15 @@ }")) (script - (text "function littleweb_navigate(uri) { + (text "globalThis.SECRET_SESSION = \"{{ session }}\"; + function littleweb_navigate(uri) { if (!uri.includes(\".html\")) { uri = `${uri}/index.html`; } // ... console.log(\"navigate\", uri); - document.getElementById(\"browser_iframe\").src = `{{ config.lw_host|safe }}/api/v1/net/${uri}`; + document.getElementById(\"browser_iframe\").src = `{{ config.lw_host|safe }}/api/v1/net/${uri}?s={{ session }}`; } document.getElementById(\"browser_iframe\").addEventListener(\"load\", (e) => { diff --git a/crates/app/src/public/html/littleweb/services.lisp b/crates/app/src/public/html/littleweb/services.lisp index 83a6179..3399685 100644 --- a/crates/app/src/public/html/littleweb/services.lisp +++ b/crates/app/src/public/html/littleweb/services.lisp @@ -73,7 +73,10 @@ (span ("class" "date") (text "{{ item.created }}")) - (text "; {{ item.files|length }} files"))) + (text "; Updated ") + (span + ("class" "date") + (text "{{ item.revision }}")))) (text "{% endfor %}")))) (script diff --git a/crates/app/src/public/js/proto_links.js b/crates/app/src/public/js/proto_links.js index 87986b3..9cf4940 100644 --- a/crates/app/src/public/js/proto_links.js +++ b/crates/app/src/public/js/proto_links.js @@ -54,7 +54,7 @@ function fix_atto_links() { `atto://${path.replace("atto://", "").split("/")[0]}${x}`; } else { y[property] = - `/api/v1/net/${path.replace("atto://", "").split("/")[0]}${x}`; + `/api/v1/net/${path.replace("atto://", "").split("/")[0]}${x}?s=${globalThis.SECRET_SESSION}`; } } } @@ -112,7 +112,7 @@ function fix_atto_links() { if (TETRATTO_LINK_HANDLER_CTX === "net") { window.location.href = `/net/${href.replace("atto://", "")}`; } else { - window.location.href = `/api/v1/net/${href}`; + window.location.href = `/api/v1/net/${href}?s=${globalThis.SECRET_SESSION}`; } }); diff --git a/crates/app/src/routes/api/v1/domains.rs b/crates/app/src/routes/api/v1/domains.rs index 40ff713..f1af2e6 100644 --- a/crates/app/src/routes/api/v1/domains.rs +++ b/crates/app/src/routes/api/v1/domains.rs @@ -3,13 +3,21 @@ use crate::{ routes::api::v1::{CreateDomain, UpdateDomainData}, State, }; -use axum::{extract::Path, response::IntoResponse, http::StatusCode, Extension, Json}; +use axum::{ + extract::{Path, Query}, + http::StatusCode, + response::IntoResponse, + Extension, Json, +}; use axum_extra::extract::CookieJar; use tetratto_core::model::{ auth::AchievementName, littleweb::{Domain, ServiceFsMime}, - oauth, ApiReturn, Error, + oauth, + permissions::FinePermission, + ApiReturn, Error, }; +use serde::Deserialize; pub async fn get_request( Path(id): Path, @@ -119,9 +127,16 @@ pub async fn delete_request( } } +#[derive(Deserialize)] +pub struct GetFileQuery { + #[serde(default, alias = "s")] + pub session: String, +} + pub async fn get_file_request( Path(mut addr): Path, Extension(data): Extension, + Query(props): Query, ) -> impl IntoResponse { if !addr.starts_with("atto://") { addr = format!("atto://{addr}"); @@ -129,8 +144,21 @@ pub async fn get_file_request( // ... let data = &(data.read().await).0; + let user = get_user_from_token!(--browser_session = props.session, data); let (subdomain, domain, tld, path) = Domain::from_str(&addr); + if path.starts_with("$") && user.is_none() { + return Err((StatusCode::BAD_REQUEST, Error::NotAllowed.to_string())); + } else if let Some(ref ua) = user + && path.starts_with("$paid") + && !ua.permissions.check(FinePermission::SUPPORTER) + { + return Err(( + StatusCode::BAD_REQUEST, + Error::RequiresSupporter.to_string(), + )); + } + // resolve domain let domain = match data.get_domain_by_name_tld(&domain, &tld).await { Ok(x) => x, @@ -160,17 +188,28 @@ pub async fn get_file_request( Some((f, _)) => Ok(( [("Content-Type".to_string(), f.mime.to_string())], if f.mime == ServiceFsMime::Html { - f.content.replace( - "", - &format!( - "", - data.0.0.host - ), - ) + f.content + .replace( + "", + &format!( + "", + data.0.0.host, props.session + ), + ) + .replace( + ".js\"", + &format!(".js?r={}&s={}\"", service.revision, props.session), + ) + .replace( + ".css\"", + &format!(".css?r={}&s={}\"", service.revision, props.session), + ) } else { f.content } - .replace("atto://", "/api/v1/net/atto://"), + .replace("atto://", "/api/v1/net/"), )), None => { return Err(( diff --git a/crates/app/src/routes/api/v1/services.rs b/crates/app/src/routes/api/v1/services.rs index d1ffbf0..a847338 100644 --- a/crates/app/src/routes/api/v1/services.rs +++ b/crates/app/src/routes/api/v1/services.rs @@ -8,6 +8,7 @@ use crate::{ use axum::{extract::Path, response::IntoResponse, Extension, Json}; use axum_extra::extract::CookieJar; use tetratto_core::model::{auth::AchievementName, littleweb::Service, oauth, ApiReturn, Error}; +use tetratto_shared::unix_epoch_timestamp; pub async fn get_request( Path(id): Path, @@ -156,11 +157,17 @@ pub async fn update_content_request( // ... match data.update_service_files(id, &user, service.files).await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Service updated".to_string(), - payload: (), - }), + Ok(_) => match data + .update_service_revision(id, unix_epoch_timestamp() as i64) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Service updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + }, Err(e) => Json(e.into()), } } diff --git a/crates/app/src/routes/pages/journals.rs b/crates/app/src/routes/pages/journals.rs index cdfba32..db76e93 100644 --- a/crates/app/src/routes/pages/journals.rs +++ b/crates/app/src/routes/pages/journals.rs @@ -365,7 +365,7 @@ pub async fn global_view_request( Ok(( [( "content-security-policy", - "default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; worker-src * blob:; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' *; frame-ancestors *", + "default-src 'self' *.spotify.com musicbrainz.org; img-src * data:; media-src *; font-src *; style-src 'unsafe-inline' 'self' *; script-src 'self' 'unsafe-inline' *; worker-src * blob:; object-src 'self' *; upgrade-insecure-requests; connect-src * localhost; frame-src 'self' blob: *; frame-ancestors *", )], Html(data.1.render("journals/app.html", &context).unwrap()), )) diff --git a/crates/app/src/routes/pages/littleweb.rs b/crates/app/src/routes/pages/littleweb.rs index 61560d7..9e347e1 100644 --- a/crates/app/src/routes/pages/littleweb.rs +++ b/crates/app/src/routes/pages/littleweb.rs @@ -11,6 +11,7 @@ use axum::{ use axum_extra::extract::CookieJar; use tetratto_core::model::{littleweb::TLDS_VEC, permissions::SecondaryPermission, Error}; use serde::Deserialize; +use tetratto_shared::hash::salt; /// `/services` pub async fn services_request( @@ -230,12 +231,26 @@ pub async fn browser_home_request( let data = data.read().await; let user = get_user_from_token!(jar, data.0); + // update session + let session = salt(); + + if let Some(ref ua) = user { + if let Err(e) = data.0.update_user_browser_session(ua.id, &session).await { + return Err(Html(render_error(e.into(), &jar, &data, &None).await)); + } + } + + // ... let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &user).await; + context.insert("path", &""); + context.insert("session", &session); // return - Html(data.1.render("littleweb/browser.html", &context).unwrap()) + Ok(Html( + data.1.render("littleweb/browser.html", &context).unwrap(), + )) } /// `/net/{uri}` @@ -255,10 +270,24 @@ pub async fn browser_request( uri = format!("atto://{uri}"); } + // update session + let session = salt(); + + if let Some(ref ua) = user { + if let Err(e) = data.0.update_user_browser_session(ua.id, &session).await { + return Err(Html(render_error(e.into(), &jar, &data, &None).await)); + } + } + + // ... let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &user).await; + + context.insert("session", &session); context.insert("path", &uri.replace("atto://", "")); // return - Html(data.1.render("littleweb/browser.html", &context).unwrap()) + Ok(Html( + data.1.render("littleweb/browser.html", &context).unwrap(), + )) } diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index fbf229b..88ef32e 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -116,12 +116,14 @@ impl DataManager { achievements: serde_json::from_str(&get!(x->23(String)).to_string()).unwrap(), awaiting_purchase: get!(x->24(i32)) as i8 == 1, was_purchased: get!(x->25(i32)) as i8 == 1, + browser_session: get!(x->26(String)), } } auto_method!(get_user_by_id(usize as i64)@get_user_from_row -> "SELECT * FROM users WHERE id = $1" --name="user" --returns=User --cache-key-tmpl="atto.user:{}"); auto_method!(get_user_by_username(&str)@get_user_from_row -> "SELECT * FROM users WHERE username = $1" --name="user" --returns=User --cache-key-tmpl="atto.user:{}"); auto_method!(get_user_by_username_no_cache(&str)@get_user_from_row -> "SELECT * FROM users WHERE username = $1" --name="user" --returns=User); + auto_method!(get_user_by_browser_session(&str)@get_user_from_row -> "SELECT * FROM users WHERE browser_session = $1" --name="user" --returns=User); /// Get a user given just their ID. Returns the void user if the user doesn't exist. /// @@ -271,7 +273,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26)", + "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27)", params![ &(data.id as i64), &(data.created as i64), @@ -299,6 +301,7 @@ impl DataManager { &serde_json::to_string(&data.achievements).unwrap(), &if data.awaiting_purchase { 1_i32 } else { 0_i32 }, &if data.was_purchased { 1_i32 } else { 0_i32 }, + &data.browser_session, ] ); @@ -993,6 +996,7 @@ impl DataManager { auto_method!(update_user_associated(Vec)@get_user_by_id -> "UPDATE users SET associated = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_achievements(Vec)@get_user_by_id -> "UPDATE users SET achievements = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_invite_code(i64)@get_user_by_id -> "UPDATE users SET invite_code = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); + auto_method!(update_user_browser_session(&str)@get_user_by_id -> "UPDATE users SET browser_session = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); auto_method!(get_user_by_stripe_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE stripe_id = $1" --name="user" --returns=User); auto_method!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); diff --git a/crates/core/src/database/drivers/sql/create_services.sql b/crates/core/src/database/drivers/sql/create_services.sql index 78277b5..ecb04d6 100644 --- a/crates/core/src/database/drivers/sql/create_services.sql +++ b/crates/core/src/database/drivers/sql/create_services.sql @@ -3,5 +3,6 @@ CREATE TABLE IF NOT EXISTS services ( created BIGINT NOT NULL, owner BIGINT NOT NULL, name TEXT NOT NULL, - files TEXT NOT NULL + files TEXT NOT NULL, + revision BIGINT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql index 3257a2d..0e24753 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -23,5 +23,6 @@ CREATE TABLE IF NOT EXISTS users ( secondary_permissions INT NOT NULL, achievements TEXT NOT NULL, awaiting_purchase INT NOT NULL, - was_purchased INT NOT NULL + was_purchased INT NOT NULL, + browser_session TEXT NOT NULL ) diff --git a/crates/core/src/database/services.rs b/crates/core/src/database/services.rs index f28460d..adc9bc6 100644 --- a/crates/core/src/database/services.rs +++ b/crates/core/src/database/services.rs @@ -16,6 +16,7 @@ impl DataManager { owner: get!(x->2(i64)) as usize, name: get!(x->3(String)), files: serde_json::from_str(&get!(x->4(String))).unwrap(), + revision: get!(x->5(i64)) as usize, } } @@ -80,13 +81,14 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO services VALUES ($1, $2, $3, $4, $5)", + "INSERT INTO services VALUES ($1, $2, $3, $4, $5, $6)", params![ &(data.id as i64), &(data.created as i64), &(data.owner as i64), &data.name, &serde_json::to_string(&data.files).unwrap(), + &(data.created as i64) ] ); @@ -128,4 +130,5 @@ impl DataManager { auto_method!(update_service_name(&str)@get_service_by_id:FinePermission::MANAGE_USERS; -> "UPDATE services SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.service:{}"); auto_method!(update_service_files(Vec)@get_service_by_id:FinePermission::MANAGE_USERS; -> "UPDATE services SET files = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.service:{}"); + auto_method!(update_service_revision(i64) -> "UPDATE services SET revision = $1 WHERE id = $2" --cache-key-tmpl="atto.service:{}"); } diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index c3d7de9..efea59a 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -70,6 +70,16 @@ pub struct User { /// used an invite code. #[serde(default)] pub was_purchased: bool, + /// This value is updated for every **new** littleweb browser session. + /// + /// This means the user can only have one of these sessions open at once + /// (unless this token is stored somewhere with a way to say we already have one, + /// but this does not happen yet). + /// + /// Without this token, the user can still use the browser, they just cannot + /// view pages which require authentication (all `$` routes). + #[serde(default)] + pub browser_session: String, } pub type UserConnections = @@ -357,6 +367,7 @@ impl User { achievements: Vec::new(), awaiting_purchase: false, was_purchased: false, + browser_session: String::new(), } } diff --git a/crates/core/src/model/littleweb.rs b/crates/core/src/model/littleweb.rs index 4c85024..f06154d 100644 --- a/crates/core/src/model/littleweb.rs +++ b/crates/core/src/model/littleweb.rs @@ -11,6 +11,7 @@ pub struct Service { pub owner: usize, pub name: String, pub files: Vec, + pub revision: usize, } impl Service { @@ -22,6 +23,7 @@ impl Service { owner, name, files: Vec::new(), + revision: unix_epoch_timestamp(), } } diff --git a/sql_changes/browser_session.sql b/sql_changes/browser_session.sql new file mode 100644 index 0000000..07570cd --- /dev/null +++ b/sql_changes/browser_session.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN browser_session TEXT NOT NULL DEFAULT ''; diff --git a/sql_changes/services_revision.sql b/sql_changes/services_revision.sql new file mode 100644 index 0000000..93150f0 --- /dev/null +++ b/sql_changes/services_revision.sql @@ -0,0 +1,2 @@ +ALTER TABLE services +ADD COLUMN revision BIGINT NOT NULL DEFAULT 0; From 4d49fc3cdf3c65971c1142925706b9d30ac92593 Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 11 Jul 2025 19:39:46 -0400 Subject: [PATCH 40/93] fix: littleweb browser url --- crates/app/src/public/html/littleweb/browser.lisp | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/crates/app/src/public/html/littleweb/browser.lisp b/crates/app/src/public/html/littleweb/browser.lisp index 67c64ab..9379357 100644 --- a/crates/app/src/public/html/littleweb/browser.lisp +++ b/crates/app/src/public/html/littleweb/browser.lisp @@ -125,6 +125,14 @@ // ... console.log(\"navigate\", uri); document.getElementById(\"browser_iframe\").src = `{{ config.lw_host|safe }}/api/v1/net/${uri}?s={{ session }}`; + + if (!uri.includes(\"atto://\")) { + document.getElementById(\"uri\").setAttribute(\"true_value\", `atto://${uri}`); + } else { + document.getElementById(\"uri\").setAttribute(\"true_value\", uri); + } + + document.getElementById(\"uri\").value = uri.replace(\"atto://\", \"\").split(\"/\")[0]; } document.getElementById(\"browser_iframe\").addEventListener(\"load\", (e) => { @@ -156,7 +164,7 @@ document.getElementById(\"uri\").setAttribute(\"true_value\", uri); } - document.getElementById(\"uri\").value = uri.split(\"/\")[0]; + document.getElementById(\"uri\").value = uri.replace(\"atto://\", \"\").split(\"/\")[0]; } }); From 6af56ed2b29b73ff90dfdacace7fcd1e5577f324 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 12 Jul 2025 00:07:37 -0400 Subject: [PATCH 41/93] fix: atto links (relative) --- crates/app/src/public/js/proto_links.js | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/crates/app/src/public/js/proto_links.js b/crates/app/src/public/js/proto_links.js index 9cf4940..9c8d9fd 100644 --- a/crates/app/src/public/js/proto_links.js +++ b/crates/app/src/public/js/proto_links.js @@ -31,7 +31,9 @@ function fix_atto_links() { if (TETRATTO_LINK_HANDLER_CTX === "embed") { // relative links for embeds - const path = window.location.pathname.slice("/api/v1/net/".length); + const path = window.location.pathname + .replace("atto://", "") + .slice("/api/v1/net/".length); function fix_element( selector = "a", @@ -43,15 +45,19 @@ function fix_atto_links() { continue; } - let x = new URL(y[property]).pathname; + const p = new URL(y[property]).pathname.replace("atto://", ""); + let x = p.startsWith("/api/v1/net/") + ? p.replace("/api/v1/net/", "") + : p.startsWith("/") + ? `${path.split("/")[0]}${p}` + : p; if (!x.includes(".html")) { x = `${x}/index.html`; } if (relative) { - y[property] = - `atto://${path.replace("atto://", "").split("/")[0]}${x}`; + y[property] = `atto://${x}`; } else { y[property] = `/api/v1/net/${path.replace("atto://", "").split("/")[0]}${x}?s=${globalThis.SECRET_SESSION}`; @@ -107,7 +113,6 @@ function fix_atto_links() { } const href = structuredClone(anchor.href); - anchor.addEventListener("click", () => { if (TETRATTO_LINK_HANDLER_CTX === "net") { window.location.href = `/net/${href.replace("atto://", "")}`; From 227cd3d2ac2355ad40f1f12b03ebd51d24782c4d Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 12 Jul 2025 14:44:50 -0400 Subject: [PATCH 42/93] fix: user follows panic --- crates/app/src/public/html/journals/app.lisp | 1 - crates/core/src/database/userfollows.rs | 8 ++++++-- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp index 255b2ec..c6ed985 100644 --- a/crates/app/src/public/html/journals/app.lisp +++ b/crates/app/src/public/html/journals/app.lisp @@ -379,7 +379,6 @@ ("name" "tags") ("id" "tags") ("placeholder" "tags") - ("required" "") ("minlength" "2") ("maxlength" "128") (text "{% for tag in note.tags -%} {{ tag }}, {% endfor %}")) diff --git a/crates/core/src/database/userfollows.rs b/crates/core/src/database/userfollows.rs index 63fcfbe..4b22835 100644 --- a/crates/core/src/database/userfollows.rs +++ b/crates/core/src/database/userfollows.rs @@ -400,9 +400,13 @@ impl DataManager { // decr counts (if we aren't deleting the user OR the user id isn't the deleted user id) if !is_deleting_user | (follow.initiator != user.id) { - self.decr_user_following_count(follow.initiator) + if self + .decr_user_following_count(follow.initiator) .await - .unwrap(); + .is_err() + { + println!("ERR_TETRATTO_DECR_FOLLOWS: could not decr initiator follow count") + } } if !is_deleting_user | (follow.receiver != user.id) { From fdaa81422abd502491772ca0f64a742fdb4442b1 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 12 Jul 2025 16:30:57 -0400 Subject: [PATCH 43/93] add: better stripe endpoint --- crates/app/src/public/html/components.lisp | 2 +- .../app/src/public/html/profile/private.lisp | 4 + .../app/src/public/html/profile/settings.lisp | 9 + .../routes/api/v1/auth/connections/stripe.rs | 118 +++-- crates/app/src/routes/api/v1/layouts.rs | 175 -------- crates/app/src/routes/api/v1/mod.rs | 34 -- crates/core/src/config.rs | 18 +- crates/core/src/database/common.rs | 1 - crates/core/src/database/drivers/common.rs | 1 - .../database/drivers/sql/create_layouts.sql | 9 - crates/core/src/database/layouts.rs | 117 ----- crates/core/src/database/mod.rs | 1 - crates/core/src/model/auth.rs | 3 + crates/core/src/model/layouts.rs | 403 ------------------ crates/core/src/model/mod.rs | 1 - 15 files changed, 118 insertions(+), 778 deletions(-) delete mode 100644 crates/app/src/routes/api/v1/layouts.rs delete mode 100644 crates/core/src/database/drivers/sql/create_layouts.sql delete mode 100644 crates/core/src/database/layouts.rs delete mode 100644 crates/core/src/model/layouts.rs diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 77b28c8..bd03879 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -2362,7 +2362,7 @@ (sup (a ("href" "#footnote-1") (text "1")))) (text "{%- endif %}")) (a - ("href" "{{ config.stripe.payment_link }}?client_reference_id={{ user.id }}") + ("href" "{{ config.stripe.payment_links.supporter }}?client_reference_id={{ user.id }}") ("class" "button") ("target" "_blank") (text "Become a supporter ({{ config.stripe.supporter_price_text }})")) diff --git a/crates/app/src/public/html/profile/private.lisp b/crates/app/src/public/html/profile/private.lisp index c5acd7d..83d533f 100644 --- a/crates/app/src/public/html/profile/private.lisp +++ b/crates/app/src/public/html/profile/private.lisp @@ -20,7 +20,11 @@ (div ("class" "card flex flex-col gap-2") (span + ("class" "fade") (text "{{ text \"auth:label.private_profile_message\" }}")) + (span + ("class" "no_p_margin") + (text "{{ profile.settings.private_biography|markdown|safe }}")) (div ("class" "card w-full secondary flex gap-2") (text "{% if user -%} {% if not is_following -%}") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 4397155..c5566c7 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -1433,6 +1433,15 @@ settings.biography, \"textarea\", ], + [ + [\"private_biography\", \"Private biography\"], + settings.private_biography, + \"textarea\", + { + embed_html: + 'This biography is only shown to users you are not following while your account is private.', + }, + ], [[\"status\", \"Status\"], settings.status, \"textarea\"], [ [\"warning\", \"Profile warning\"], diff --git a/crates/app/src/routes/api/v1/auth/connections/stripe.rs b/crates/app/src/routes/api/v1/auth/connections/stripe.rs index e62a0e8..3a4619e 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -17,9 +17,10 @@ pub async fn stripe_webhook( ) -> impl IntoResponse { let data = &(data.read().await).0; - if data.0.0.stripe.is_none() { - return Json(Error::MiscError("Disabled".to_string()).into()); - } + let stripe_cnf = match data.0.0.stripe { + Some(ref c) => c, + None => return Json(Error::MiscError("Disabled".to_string()).into()), + }; let sig = match headers.get("Stripe-Signature") { Some(s) => s, @@ -56,7 +57,7 @@ pub async fn stripe_webhook( Err(e) => return Json(e.into()), }; - tracing::info!("subscribe {} (stripe: {})", user.id, customer_id); + tracing::info!("payment {} (stripe: {})", user.id, customer_id); if let Err(e) = data .update_user_stripe_id(user.id, customer_id.as_str()) .await @@ -74,6 +75,48 @@ pub async fn stripe_webhook( }; let customer_id = invoice.customer.unwrap().id(); + let lines = invoice.lines.unwrap(); + + if lines.total_count.unwrap() > 1 { + if let Err(e) = data + .create_audit_log_entry(AuditLogEntry::new( + 0, + format!("too many invoice line items: stripe {customer_id}"), + )) + .await + { + return Json(e.into()); + } + + return Json(Error::MiscError("Too many line items".to_string()).into()); + } + + let item = match lines.data.get(0) { + Some(i) => i, + None => { + if let Err(e) = data + .create_audit_log_entry(AuditLogEntry::new( + 0, + format!("too few invoice line items: stripe {customer_id}"), + )) + .await + { + return Json(e.into()); + } + + return Json(Error::MiscError("Too few line items".to_string()).into()); + } + }; + + let product_id = item + .price + .as_ref() + .unwrap() + .product + .as_ref() + .unwrap() + .id() + .to_string(); // pull user and update role let mut retries: usize = 0; @@ -118,45 +161,54 @@ pub async fn stripe_webhook( } let user = user.unwrap(); - tracing::info!("found subscription user in {retries} tries"); - if user.permissions.check(FinePermission::SUPPORTER) { - return Json(ApiReturn { - ok: true, - message: "Already applied".to_string(), - payload: (), - }); - } + if product_id == stripe_cnf.product_ids.supporter { + // supporter + tracing::info!("found subscription user in {retries} tries"); - tracing::info!("invoice {} (stripe: {})", user.id, customer_id); - let new_user_permissions = user.permissions | FinePermission::SUPPORTER; + if user.permissions.check(FinePermission::SUPPORTER) { + return Json(ApiReturn { + ok: true, + message: "Already applied".to_string(), + payload: (), + }); + } - if let Err(e) = data - .update_user_role(user.id, new_user_permissions, user.clone(), true) - .await - { - return Json(e.into()); - } + tracing::info!("invoice {} (stripe: {})", user.id, customer_id); + let new_user_permissions = user.permissions | FinePermission::SUPPORTER; - if data.0.0.security.enable_invite_codes && user.awaiting_purchase { if let Err(e) = data - .update_user_awaiting_purchased_status(user.id, false, user.clone(), false) + .update_user_role(user.id, new_user_permissions, user.clone(), true) .await { return Json(e.into()); } - } - if let Err(e) = data - .create_notification(Notification::new( - "Welcome new supporter!".to_string(), - "Thank you for your support! Your account has been updated with your new role." - .to_string(), - user.id, - )) - .await - { - return Json(e.into()); + if data.0.0.security.enable_invite_codes && user.awaiting_purchase { + if let Err(e) = data + .update_user_awaiting_purchased_status(user.id, false, user.clone(), false) + .await + { + return Json(e.into()); + } + } + + if let Err(e) = data + .create_notification(Notification::new( + "Welcome new supporter!".to_string(), + "Thank you for your support! Your account has been updated with your new role." + .to_string(), + user.id, + )) + .await + { + return Json(e.into()); + } + } else { + tracing::error!( + "received an invalid stripe product id, please check config.stripe.product_ids" + ); + return Json(Error::MiscError("Unknown product ID".to_string()).into()); } } EventType::CustomerSubscriptionDeleted => { diff --git a/crates/app/src/routes/api/v1/layouts.rs b/crates/app/src/routes/api/v1/layouts.rs deleted file mode 100644 index b86bfd2..0000000 --- a/crates/app/src/routes/api/v1/layouts.rs +++ /dev/null @@ -1,175 +0,0 @@ -use crate::{ - get_user_from_token, - routes::{ - api::v1::{CreateLayout, UpdateLayoutName, UpdateLayoutPages, UpdateLayoutPrivacy}, - }, - State, -}; -use axum::{extract::Path, response::IntoResponse, Extension, Json}; -use axum_extra::extract::CookieJar; -use tetratto_core::{ - model::{ - layouts::{Layout, LayoutPrivacy}, - oauth, - permissions::FinePermission, - ApiReturn, Error, - }, -}; - -pub async fn get_request( - jar: CookieJar, - Path(id): Path, - Extension(data): Extension, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadStacks) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - let layout = match data.get_layout_by_id(id).await { - Ok(x) => x, - Err(e) => return Json(e.into()), - }; - - if layout.privacy == LayoutPrivacy::Public - && user.id != layout.owner - && !user.permissions.check(FinePermission::MANAGE_USERS) - { - return Json(Error::NotAllowed.into()); - } - - Json(ApiReturn { - ok: true, - message: "Success".to_string(), - payload: Some(layout), - }) -} - -pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadLayouts) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - match data.get_layouts_by_user(user.id).await { - Ok(x) => Json(ApiReturn { - ok: true, - message: "Success".to_string(), - payload: Some(x), - }), - Err(e) => Json(e.into()), - } -} - -pub async fn create_request( - jar: CookieJar, - Extension(data): Extension, - Json(req): Json, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateLayouts) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - match data - .create_layout(Layout::new(req.name, user.id, req.replaces)) - .await - { - Ok(s) => Json(ApiReturn { - ok: true, - message: "Layout created".to_string(), - payload: s.id.to_string(), - }), - Err(e) => Json(e.into()), - } -} - -pub async fn update_name_request( - jar: CookieJar, - Extension(data): Extension, - Path(id): Path, - Json(req): Json, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - match data.update_layout_title(id, &user, &req.name).await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Layout updated".to_string(), - payload: (), - }), - Err(e) => Json(e.into()), - } -} - -pub async fn update_privacy_request( - jar: CookieJar, - Extension(data): Extension, - Path(id): Path, - Json(req): Json, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - match data.update_layout_privacy(id, &user, req.privacy).await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Layout updated".to_string(), - payload: (), - }), - Err(e) => Json(e.into()), - } -} - -pub async fn update_pages_request( - jar: CookieJar, - Extension(data): Extension, - Path(id): Path, - Json(req): Json, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - match data.update_layout_pages(id, &user, req.pages).await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Layout updated".to_string(), - payload: (), - }), - Err(e) => Json(e.into()), - } -} - -pub async fn delete_request( - jar: CookieJar, - Extension(data): Extension, - Path(id): Path, -) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageLayouts) { - Some(ua) => ua, - None => return Json(Error::NotAllowed.into()), - }; - - match data.delete_layout(id, &user).await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "Layout deleted".to_string(), - payload: (), - }), - Err(e) => Json(e.into()), - } -} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 506e74f..b3496db 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -4,7 +4,6 @@ pub mod channels; pub mod communities; pub mod domains; pub mod journals; -pub mod layouts; pub mod notes; pub mod notifications; pub mod reactions; @@ -29,7 +28,6 @@ use tetratto_core::model::{ }, communities_permissions::CommunityPermission, journals::JournalPrivacyPermission, - layouts::{CustomizablePage, LayoutPage, LayoutPrivacy}, littleweb::{DomainData, DomainTld, ServiceFsEntry}, oauth::AppScope, permissions::{FinePermission, SecondaryPermission}, @@ -625,17 +623,6 @@ pub fn routes() -> Router { // uploads .route("/uploads/{id}", get(uploads::get_request)) .route("/uploads/{id}", delete(uploads::delete_request)) - // layouts - .route("/layouts", get(layouts::list_request)) - .route("/layouts", post(layouts::create_request)) - .route("/layouts/{id}", get(layouts::get_request)) - .route("/layouts/{id}", delete(layouts::delete_request)) - .route("/layouts/{id}/title", post(layouts::update_name_request)) - .route( - "/layouts/{id}/privacy", - post(layouts::update_privacy_request), - ) - .route("/layouts/{id}/pages", post(layouts::update_pages_request)) // services .route("/services", get(services::list_request)) .route("/services", post(services::create_request)) @@ -1055,27 +1042,6 @@ pub struct AwardAchievement { pub name: AchievementName, } -#[derive(Deserialize)] -pub struct CreateLayout { - pub name: String, - pub replaces: CustomizablePage, -} - -#[derive(Deserialize)] -pub struct UpdateLayoutName { - pub name: String, -} - -#[derive(Deserialize)] -pub struct UpdateLayoutPrivacy { - pub privacy: LayoutPrivacy, -} - -#[derive(Deserialize)] -pub struct UpdateLayoutPages { - pub pages: Vec, -} - #[derive(Deserialize)] pub struct CreateService { pub name: String, diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 309c851..44d1257 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -173,13 +173,13 @@ pub struct ConnectionsConfig { /// - Use testing card numbers: #[derive(Clone, Serialize, Deserialize, Debug, Default)] pub struct StripeConfig { - /// Payment link from the Stripe dashboard. + /// Payment links from the Stripe dashboard. /// /// 1. Create a product and set the price for your membership /// 2. Set the product price to a recurring subscription /// 3. Create a payment link for the new product /// 4. The payment link pasted into this config field should NOT include a query string - pub payment_link: String, + pub payment_links: StripePaymentLinks, /// To apply benefits to user accounts, you should then go into the Stripe developer /// "workbench" and create a new webhook. The webhook needs the scopes: /// `invoice.payment_succeeded`, `customer.subscription.deleted`, `checkout.session.completed`. @@ -194,6 +194,20 @@ pub struct StripeConfig { pub billing_portal_url: String, /// The text representation of the price of supporter. (like `$4 USD`) pub supporter_price_text: String, + /// Product IDs from the Stripe dashboard. + /// + /// These are checked when we receive a webhook to ensure we provide the correct product. + pub product_ids: StripeProductIds, +} + +#[derive(Clone, Serialize, Deserialize, Debug, Default)] +pub struct StripePaymentLinks { + pub supporter: String, +} + +#[derive(Clone, Serialize, Deserialize, Debug, Default)] +pub struct StripeProductIds { + pub supporter: String, } /// Manuals config (search help, etc) diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 969b014..f3d2668 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -40,7 +40,6 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_NOTES).unwrap(); execute!(&conn, common::CREATE_TABLE_MESSAGE_REACTIONS).unwrap(); execute!(&conn, common::CREATE_TABLE_INVITE_CODES).unwrap(); - execute!(&conn, common::CREATE_TABLE_LAYOUTS).unwrap(); execute!(&conn, common::CREATE_TABLE_DOMAINS).unwrap(); execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap(); diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index efa3eae..6a562e7 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -27,6 +27,5 @@ pub const CREATE_TABLE_JOURNALS: &str = include_str!("./sql/create_journals.sql" pub const CREATE_TABLE_NOTES: &str = include_str!("./sql/create_notes.sql"); pub const CREATE_TABLE_MESSAGE_REACTIONS: &str = include_str!("./sql/create_message_reactions.sql"); pub const CREATE_TABLE_INVITE_CODES: &str = include_str!("./sql/create_invite_codes.sql"); -pub const CREATE_TABLE_LAYOUTS: &str = include_str!("./sql/create_layouts.sql"); pub const CREATE_TABLE_DOMAINS: &str = include_str!("./sql/create_domains.sql"); pub const CREATE_TABLE_SERVICES: &str = include_str!("./sql/create_services.sql"); diff --git a/crates/core/src/database/drivers/sql/create_layouts.sql b/crates/core/src/database/drivers/sql/create_layouts.sql deleted file mode 100644 index 3f28c0a..0000000 --- a/crates/core/src/database/drivers/sql/create_layouts.sql +++ /dev/null @@ -1,9 +0,0 @@ -CREATE TABLE IF NOT EXISTS layouts ( - id BIGINT NOT NULL PRIMARY KEY, - created BIGINT NOT NULL, - owner BIGINT NOT NULL, - title TEXT NOT NULL, - privacy TEXT NOT NULL, - pages TEXT NOT NULL, - replaces TEXT NOT NULL -) diff --git a/crates/core/src/database/layouts.rs b/crates/core/src/database/layouts.rs deleted file mode 100644 index 052a733..0000000 --- a/crates/core/src/database/layouts.rs +++ /dev/null @@ -1,117 +0,0 @@ -use crate::model::{ - auth::User, - layouts::{Layout, LayoutPage, LayoutPrivacy}, - permissions::FinePermission, - Error, Result, -}; -use crate::{auto_method, DataManager}; -use oiseau::{PostgresRow, execute, get, query_rows, params, cache::Cache}; - -impl DataManager { - /// Get a [`Layout`] from an SQL row. - pub(crate) fn get_layout_from_row(x: &PostgresRow) -> Layout { - Layout { - id: get!(x->0(i64)) as usize, - created: get!(x->1(i64)) as usize, - owner: get!(x->2(i64)) as usize, - title: get!(x->3(String)), - privacy: serde_json::from_str(&get!(x->4(String))).unwrap(), - pages: serde_json::from_str(&get!(x->5(String))).unwrap(), - replaces: serde_json::from_str(&get!(x->6(String))).unwrap(), - } - } - - auto_method!(get_layout_by_id(usize as i64)@get_layout_from_row -> "SELECT * FROM layouts WHERE id = $1" --name="layout" --returns=Layout --cache-key-tmpl="atto.layout:{}"); - - /// Get all layouts by user. - /// - /// # Arguments - /// * `id` - the ID of the user to fetch layouts for - pub async fn get_layouts_by_user(&self, id: usize) -> Result> { - let conn = match self.0.connect().await { - Ok(c) => c, - Err(e) => return Err(Error::DatabaseConnection(e.to_string())), - }; - - let res = query_rows!( - &conn, - "SELECT * FROM layouts WHERE owner = $1 ORDER BY created DESC", - &[&(id as i64)], - |x| { Self::get_layout_from_row(x) } - ); - - if res.is_err() { - return Err(Error::GeneralNotFound("layout".to_string())); - } - - Ok(res.unwrap()) - } - - /// Create a new layout in the database. - /// - /// # Arguments - /// * `data` - a mock [`Layout`] object to insert - pub async fn create_layout(&self, data: Layout) -> Result { - // check values - if data.title.len() < 2 { - return Err(Error::DataTooShort("title".to_string())); - } else if data.title.len() > 32 { - return Err(Error::DataTooLong("title".to_string())); - } - - // ... - let conn = match self.0.connect().await { - Ok(c) => c, - Err(e) => return Err(Error::DatabaseConnection(e.to_string())), - }; - - let res = execute!( - &conn, - "INSERT INTO layouts VALUES ($1, $2, $3, $4, $5, $6, $7)", - params![ - &(data.id as i64), - &(data.created as i64), - &(data.owner as i64), - &data.title, - &serde_json::to_string(&data.privacy).unwrap(), - &serde_json::to_string(&data.pages).unwrap(), - &serde_json::to_string(&data.replaces).unwrap(), - ] - ); - - if let Err(e) = res { - return Err(Error::DatabaseError(e.to_string())); - } - - Ok(data) - } - - pub async fn delete_layout(&self, id: usize, user: &User) -> Result<()> { - let layout = self.get_layout_by_id(id).await?; - - // check user permission - if user.id != layout.owner && !user.permissions.check(FinePermission::MANAGE_STACKS) { - return Err(Error::NotAllowed); - } - - // ... - let conn = match self.0.connect().await { - Ok(c) => c, - Err(e) => return Err(Error::DatabaseConnection(e.to_string())), - }; - - let res = execute!(&conn, "DELETE FROM layouts WHERE id = $1", &[&(id as i64)]); - - if let Err(e) = res { - return Err(Error::DatabaseError(e.to_string())); - } - - // ... - self.0.1.remove(format!("atto.layout:{}", id)).await; - Ok(()) - } - - auto_method!(update_layout_title(&str)@get_layout_by_id:FinePermission::MANAGE_USERS; -> "UPDATE layouts SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.layout:{}"); - auto_method!(update_layout_privacy(LayoutPrivacy)@get_layout_by_id:FinePermission::MANAGE_USERS; -> "UPDATE layouts SET privacy = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.layout:{}"); - auto_method!(update_layout_pages(Vec)@get_layout_by_id:FinePermission::MANAGE_USERS; -> "UPDATE layouts SET pages = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.layout:{}"); -} diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 1009797..57873f9 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -13,7 +13,6 @@ mod invite_codes; mod ipbans; mod ipblocks; mod journals; -mod layouts; mod memberships; mod message_reactions; mod messages; diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index efea59a..a97b1fd 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -322,6 +322,9 @@ pub struct UserSettings { /// and the following timeline. #[serde(default)] pub auto_full_unlist: bool, + /// Biography shown on `profile/private.lisp` page. + #[serde(default)] + pub private_biography: String, } fn mime_avif() -> String { diff --git a/crates/core/src/model/layouts.rs b/crates/core/src/model/layouts.rs deleted file mode 100644 index a9d60a4..0000000 --- a/crates/core/src/model/layouts.rs +++ /dev/null @@ -1,403 +0,0 @@ -use std::{collections::HashMap, fmt::Display}; -use serde::{Deserialize, Serialize}; -use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; -use crate::model::auth::DefaultTimelineChoice; - -/// Each different page which can be customized. -#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq, Hash)] -pub enum CustomizablePage { - Home, - All, - Popular, -} - -/// Layouts allow you to customize almost every page in the Tetratto UI through -/// simple blocks. -#[derive(Serialize, Deserialize)] -pub struct Layout { - pub id: usize, - pub created: usize, - pub owner: usize, - pub title: String, - pub privacy: LayoutPrivacy, - pub pages: Vec, - pub replaces: CustomizablePage, -} - -impl Layout { - /// Create a new [`Layout`]. - pub fn new(title: String, owner: usize, replaces: CustomizablePage) -> Self { - Self { - id: Snowflake::new().to_string().parse::().unwrap(), - created: unix_epoch_timestamp(), - owner, - title, - privacy: LayoutPrivacy::Public, - pages: Vec::new(), - replaces, - } - } -} - -/// The privacy of the layout, which controls who has the ability to view it. -#[derive(Serialize, Deserialize, PartialEq, Eq)] -pub enum LayoutPrivacy { - Public, - Private, -} - -impl Display for Layout { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut out = String::new(); - - for (i, page) in self.pages.iter().enumerate() { - let mut x = page.to_string(); - - if i == 0 { - x = x.replace("%?%", ""); - } else { - x = x.replace("%?%", "hidden"); - } - - out.push_str(&x); - } - - f.write_str(&out) - } -} - -/// Layouts are able to contain subpages within them. -/// -/// Each layout is only allowed 2 subpages pages, meaning one main page and one extra. -#[derive(Serialize, Deserialize)] -pub struct LayoutPage { - pub name: String, - pub blocks: Vec, - pub css: String, - pub js: String, -} - -impl Display for LayoutPage { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&format!( - "
{}
", - { - let mut out = String::new(); - - for block in &self.blocks { - out.push_str(&block.to_string()); - } - - out - }, - self.css, - self.js - )) - } -} - -/// Blocks are the basis of each layout page. They are simple and composable. -#[derive(Serialize, Deserialize)] -pub struct LayoutBlock { - pub r#type: BlockType, - pub children: Vec, -} - -impl LayoutBlock { - pub fn render_children(&self) -> String { - let mut out = String::new(); - - for child in &self.children { - out.push_str(&child.to_string()); - } - - out - } -} - -impl Display for LayoutBlock { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let mut out = String::new(); - - // head - out.push_str(&match self.r#type { - BlockType::Block(ref x) => format!("<{} {}>", x.element, x), - BlockType::Flexible(ref x) => format!("<{} {}>", x.element, x), - BlockType::Markdown(ref x) => format!("<{} {}>", x.element, x), - BlockType::Timeline(ref x) => format!("<{} {}>", x.element, x), - }); - - // body - out.push_str(&match self.r#type { - BlockType::Block(_) => self.render_children(), - BlockType::Flexible(_) => self.render_children(), - BlockType::Markdown(ref x) => x.sub_options.content.to_string(), - BlockType::Timeline(ref x) => { - format!( - "
", - x.sub_options.timeline - ) - } - }); - - // tail - out.push_str(&self.r#type.unwrap_cloned().element.tail()); - - // ... - f.write_str(&out) - } -} - -/// Each different type of block has different attributes associated with it. -#[derive(Serialize, Deserialize)] -pub enum BlockType { - Block(GeneralBlockOptions), - Flexible(GeneralBlockOptions), - Markdown(GeneralBlockOptions), - Timeline(GeneralBlockOptions), -} - -impl BlockType { - pub fn unwrap(self) -> GeneralBlockOptions> { - match self { - Self::Block(x) => x.boxed(), - Self::Flexible(x) => x.boxed(), - Self::Markdown(x) => x.boxed(), - Self::Timeline(x) => x.boxed(), - } - } - - pub fn unwrap_cloned(&self) -> GeneralBlockOptions> { - match self { - Self::Block(x) => x.boxed_cloned::(), - Self::Flexible(x) => x.boxed_cloned::(), - Self::Markdown(x) => x.boxed_cloned::(), - Self::Timeline(x) => x.boxed_cloned::(), - } - } -} - -#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)] -pub enum HtmlElement { - Div, - Span, - Italics, - Bold, - Heading1, - Heading2, - Heading3, - Heading4, - Heading5, - Heading6, - Image, -} - -impl HtmlElement { - pub fn tail(&self) -> String { - match self { - Self::Image => String::new(), - _ => format!(""), - } - } -} - -impl Display for HtmlElement { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::Div => "div", - Self::Span => "span", - Self::Italics => "i", - Self::Bold => "b", - Self::Heading1 => "h1", - Self::Heading2 => "h2", - Self::Heading3 => "h3", - Self::Heading4 => "h4", - Self::Heading5 => "h5", - Self::Heading6 => "h6", - Self::Image => "img", - }) - } -} - -/// This trait is used to provide cloning capabilities to structs which DO implement -/// clone, but we aren't allowed to tell the compiler that they implement clone -/// (through a trait bound), as Clone is not dyn compatible. -/// -/// Implementations for this trait should really just take reference to another -/// value (T), then just run `.to_owned()` on it. This means T and F (Self) MUST -/// be the same type. -pub trait RefFrom { - fn ref_from(value: &T) -> Self; -} - -#[derive(Serialize, Deserialize)] -pub struct GeneralBlockOptions -where - T: Display, -{ - pub element: HtmlElement, - pub class_list: String, - pub id: String, - pub attributes: HashMap, - pub sub_options: T, -} - -impl GeneralBlockOptions { - pub fn boxed(self) -> GeneralBlockOptions> { - GeneralBlockOptions { - element: self.element, - class_list: self.class_list, - id: self.id, - attributes: self.attributes, - sub_options: Box::new(self.sub_options), - } - } - - pub fn boxed_cloned + 'static>( - &self, - ) -> GeneralBlockOptions> { - let x: F = F::ref_from(&self.sub_options); - GeneralBlockOptions { - element: self.element.clone(), - class_list: self.class_list.clone(), - id: self.id.clone(), - attributes: self.attributes.clone(), - sub_options: Box::new(x), - } - } -} - -impl Display for GeneralBlockOptions { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&format!( - "class=\"{} {}\" {} id={} {}", - self.class_list, - self.sub_options.to_string(), - { - let mut attrs = String::new(); - - for (k, v) in &self.attributes { - attrs.push_str(&format!("{k}=\"{v}\"")); - } - - attrs - }, - self.id, - if self.element == HtmlElement::Image { - "/" - } else { - "" - } - )) - } -} -#[derive(Clone, Serialize, Deserialize)] -pub struct EmptyBlockOptions; - -impl Display for EmptyBlockOptions { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("") - } -} - -impl RefFrom for EmptyBlockOptions { - fn ref_from(value: &EmptyBlockOptions) -> Self { - value.to_owned() - } -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct FlexibleBlockOptions { - pub gap: FlexibleBlockGap, - pub direction: FlexibleBlockDirection, - pub wrap: bool, - pub collapse: bool, -} - -impl Display for FlexibleBlockOptions { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(&format!( - "flex {} {} {} {}", - self.gap, - self.direction, - if self.wrap { "flex-wrap" } else { "" }, - if self.collapse { "flex-collapse" } else { "" } - )) - } -} - -impl RefFrom for FlexibleBlockOptions { - fn ref_from(value: &FlexibleBlockOptions) -> Self { - value.to_owned() - } -} - -#[derive(Clone, Serialize, Deserialize)] -pub enum FlexibleBlockGap { - Tight, - Comfortable, - Spacious, - Large, -} - -impl Display for FlexibleBlockGap { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::Tight => "gap-1", - Self::Comfortable => "gap-2", - Self::Spacious => "gap-3", - Self::Large => "gap-4", - }) - } -} - -#[derive(Clone, Serialize, Deserialize)] -pub enum FlexibleBlockDirection { - Row, - Column, -} - -impl Display for FlexibleBlockDirection { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str(match self { - Self::Row => "flex-row", - Self::Column => "flex-col", - }) - } -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct MarkdownBlockOptions { - pub content: String, -} - -impl Display for MarkdownBlockOptions { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("") - } -} - -impl RefFrom for MarkdownBlockOptions { - fn ref_from(value: &MarkdownBlockOptions) -> Self { - value.to_owned() - } -} - -#[derive(Clone, Serialize, Deserialize)] -pub struct TimelineBlockOptions { - pub timeline: DefaultTimelineChoice, -} - -impl Display for TimelineBlockOptions { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - f.write_str("w-full flex flex-col gap-2\" ui_ident=\"io_data_load") - } -} - -impl RefFrom for TimelineBlockOptions { - fn ref_from(value: &TimelineBlockOptions) -> Self { - value.to_owned() - } -} diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index e825340..2cd4955 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -6,7 +6,6 @@ pub mod channels; pub mod communities; pub mod communities_permissions; pub mod journals; -pub mod layouts; pub mod littleweb; pub mod moderation; pub mod oauth; From e4468e476842f563e72df24a26d6cb157753e08e Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 12 Jul 2025 18:06:36 -0400 Subject: [PATCH 44/93] add: user seller_data --- .gitignore | 1 + crates/app/Cargo.toml | 1 + crates/app/src/assets.rs | 1 - crates/app/src/main.rs | 18 +- crates/app/src/public/js/layout_editor.js | 762 ------------------ .../routes/api/v1/auth/connections/stripe.rs | 104 ++- crates/app/src/routes/api/v1/mod.rs | 8 + crates/app/src/routes/assets.rs | 1 - crates/app/src/routes/mod.rs | 4 - crates/app/src/routes/pages/mod.rs | 5 +- crates/core/src/config.rs | 2 + crates/core/src/database/auth.rs | 8 +- crates/core/src/model/auth.rs | 10 + sql_changes/users_seller_data.sql | 2 + 14 files changed, 150 insertions(+), 777 deletions(-) delete mode 100644 crates/app/src/public/js/layout_editor.js create mode 100644 sql_changes/users_seller_data.sql diff --git a/.gitignore b/.gitignore index f5f83f6..7bc86aa 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,3 @@ /target debug/ +.dev diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 8a7eb6e..a54b7a8 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -37,6 +37,7 @@ async-stripe = { version = "0.41.0", features = [ "webhook-events", "billing", "runtime-tokio-hyper", + "connect", ] } emojis = "0.7.0" webp = "0.3.0" diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 81671fe..e4088a1 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -40,7 +40,6 @@ pub const ATTO_JS: &str = include_str!("./public/js/atto.js"); pub const ME_JS: &str = include_str!("./public/js/me.js"); pub const STREAMS_JS: &str = include_str!("./public/js/streams.js"); pub const CARP_JS: &str = include_str!("./public/js/carp.js"); -pub const LAYOUT_EDITOR_JS: &str = include_str!("./public/js/layout_editor.js"); pub const PROTO_LINKS_JS: &str = include_str!("./public/js/proto_links.js"); // html diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index b4ffbe6..f7f7c06 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -8,6 +8,7 @@ mod routes; mod sanitize; use assets::{init_dirs, write_assets}; +use stripe::Client as StripeClient; use tetratto_core::model::{permissions::FinePermission, uploads::CustomEmoji}; pub use tetratto_core::*; @@ -27,7 +28,8 @@ use tracing::{Level, info}; use std::{collections::HashMap, env::var, net::SocketAddr, process::exit, sync::Arc}; use tokio::sync::RwLock; -pub(crate) type State = Arc>; +pub(crate) type InnerState = (DataManager, Tera, Client, Option); +pub(crate) type State = Arc>; fn render_markdown(value: &Value, _: &HashMap) -> tera::Result { Ok( @@ -115,6 +117,13 @@ async fn main() { let client = Client::new(); let mut app = Router::new(); + // cretae stripe client + let stripe_client = if let Some(ref stripe) = config.stripe { + Some(StripeClient::new(stripe.secret.clone())) + } else { + None + }; + // add correct routes if var("LITTLEWEB").is_ok() { app = app.merge(routes::lw_routes()); @@ -129,7 +138,12 @@ async fn main() { // add junk app = app - .layer(Extension(Arc::new(RwLock::new((database, tera, client))))) + .layer(Extension(Arc::new(RwLock::new(( + database, + tera, + client, + stripe_client, + ))))) .layer(axum::extract::DefaultBodyLimit::max( var("BODY_LIMIT") .unwrap_or("8388608".to_string()) diff --git a/crates/app/src/public/js/layout_editor.js b/crates/app/src/public/js/layout_editor.js deleted file mode 100644 index 13d3d8b..0000000 --- a/crates/app/src/public/js/layout_editor.js +++ /dev/null @@ -1,762 +0,0 @@ -/// Copy all the fields from one object to another. -function copy_fields(from, to) { - for (const field of Object.entries(from)) { - to[field[0]] = field[1]; - } - - return to; -} - -/// Simple template components. -const COMPONENT_TEMPLATES = { - EMPTY_COMPONENT: { component: "empty", options: {}, children: [] }, - FLEX_DEFAULT: { - component: "flex", - options: { - direction: "row", - gap: "2", - }, - children: [], - }, - FLEX_SIMPLE_ROW: { - component: "flex", - options: { - direction: "row", - gap: "2", - width: "full", - }, - children: [], - }, - FLEX_SIMPLE_COL: { - component: "flex", - options: { - direction: "col", - gap: "2", - width: "full", - }, - children: [], - }, - FLEX_MOBILE_COL: { - component: "flex", - options: { - collapse: "yes", - gap: "2", - width: "full", - }, - children: [], - }, - MARKDOWN_DEFAULT: { - component: "markdown", - options: { - text: "Hello, world!", - }, - }, - MARKDOWN_CARD: { - component: "markdown", - options: { - class: "card w-full", - text: "Hello, world!", - }, - }, -}; - -/// All available components with their label and JSON representation. -const COMPONENTS = [ - [ - "Markdown block", - COMPONENT_TEMPLATES.MARKDOWN_DEFAULT, - [["Card", COMPONENT_TEMPLATES.MARKDOWN_CARD]], - ], - [ - "Flex container", - COMPONENT_TEMPLATES.FLEX_DEFAULT, - [ - ["Simple rows", COMPONENT_TEMPLATES.FLEX_SIMPLE_ROW], - ["Simple columns", COMPONENT_TEMPLATES.FLEX_SIMPLE_COL], - ["Mobile columns", COMPONENT_TEMPLATES.FLEX_MOBILE_COL], - ], - ], - [ - "Profile tabs", - { - component: "tabs", - }, - ], - [ - "Profile feeds", - { - component: "feed", - }, - ], - [ - "Profile banner", - { - component: "banner", - }, - ], - [ - "Question box", - { - component: "ask", - }, - ], - [ - "Name & avatar", - { - component: "name", - }, - ], - [ - "About section", - { - component: "about", - }, - ], - [ - "Action buttons", - { - component: "actions", - }, - ], - [ - "CSS stylesheet", - { - component: "style", - options: { - data: "", - }, - }, - ], -]; - -// preload icons -trigger("app::icon", ["shapes"]); -trigger("app::icon", ["type"]); -trigger("app::icon", ["plus"]); -trigger("app::icon", ["move-up"]); -trigger("app::icon", ["move-down"]); -trigger("app::icon", ["trash"]); -trigger("app::icon", ["arrow-left"]); -trigger("app::icon", ["x"]); - -/// The location of an element as represented by array indexes. -class ElementPointer { - position = []; - - constructor(element) { - if (element) { - const pos = []; - - let target = element; - while (target.parentElement) { - const parent = target.parentElement; - - // push index - pos.push(Array.from(parent.children).indexOf(target) || 0); - - // update target - if (parent.id === "editor") { - break; - } - - target = parent; - } - - this.position = pos.reverse(); // indexes are added in reverse order because of how we traverse - } else { - this.position = []; - } - } - - get() { - return this.position; - } - - resolve(json, minus = 0) { - let out = json; - - if (this.position.length === 1) { - // this is the first element (this.position === [0]) - return out; - } - - const pos = this.position.slice(1, this.position.length); // the first one refers to the root element - - for (let i = 0; i < minus; i++) { - pos.pop(); - } - - for (const idx of pos) { - const child = ((out || { children: [] }).children || [])[idx]; - - if (!child) { - break; - } - - out = child; - } - - return out; - } -} - -/// The layout editor controller. -class LayoutEditor { - element; - json; - tree = ""; - current = { component: "empty" }; - pointer = new ElementPointer(); - - /// Create a new [`LayoutEditor`]. - constructor(element, json) { - this.element = element; - this.json = json; - - if (this.json.json) { - delete this.json.json; - } - - element.addEventListener("click", (e) => this.click(e, this)); - element.addEventListener("mouseover", (e) => { - e.stopImmediatePropagation(); - const ptr = new ElementPointer(e.target); - - if (document.getElementById("position")) { - document.getElementById( - "position", - ).parentElement.style.display = "flex"; - - document.getElementById("position").innerText = ptr - .get() - .join("."); - } - }); - - this.render(); - } - - /// Render layout. - render() { - fetch("/api/v0/auth/render_layout", { - method: "POST", - body: JSON.stringify({ - layout: this.json, - }), - headers: { - "Content-Type": "application/json", - }, - }) - .then((r) => r.json()) - .then((r) => { - this.element.innerHTML = r.block; - this.tree = r.tree; - - if (this.json.component !== "empty") { - // remove all "empty" components (if the root component isn't an empty) - for (const element of document.querySelectorAll( - '[data-component-name="empty"]', - )) { - element.remove(); - } - } - }); - } - - /// Editor clicked. - click(e, self) { - e.stopImmediatePropagation(); - trigger("app::hooks::dropdown.close"); - - const ptr = new ElementPointer(e.target); - self.current = ptr.resolve(self.json); - self.pointer = ptr; - - if (document.getElementById("current_position")) { - document.getElementById( - "current_position", - ).parentElement.style.display = "flex"; - - document.getElementById("current_position").innerText = ptr - .get() - .join("."); - } - - for (const element of document.querySelectorAll( - ".layout_editor_block.active", - )) { - element.classList.remove("active"); - } - - e.target.classList.add("active"); - self.screen("element"); - } - - /// Open sidebar. - open() { - document.getElementById("editor_sidebar").classList.add("open"); - document.getElementById("editor").style.transform = "scale(0.8)"; - } - - /// Close sidebar. - close() { - document.getElementById("editor_sidebar").style.animation = - "0.2s ease-in-out forwards to_left"; - - setTimeout(() => { - document.getElementById("editor_sidebar").classList.remove("open"); - document.getElementById("editor_sidebar").style.animation = - "0.2s ease-in-out forwards from_right"; - }, 250); - - document.getElementById("editor").style.transform = "scale(1)"; - } - - /// Render editor dialog. - screen(page = "element", data = {}) { - this.current.component = this.current.component.toLowerCase(); - - const sidebar = document.getElementById("editor_sidebar"); - sidebar.innerHTML = ""; - - // render page - if ( - page === "add" || - (page === "element" && this.current.component === "empty") - ) { - // add element - sidebar.appendChild( - (() => { - const heading = document.createElement("h3"); - heading.innerText = data.add_title || "Add component"; - return heading; - })(), - ); - - sidebar.appendChild(document.createElement("hr")); - - const container = document.createElement("div"); - container.className = "flex w-full gap-2 flex-wrap"; - - for (const component of data.components || COMPONENTS) { - container.appendChild( - (() => { - const button = document.createElement("button"); - button.classList.add("secondary"); - - trigger("app::icon", [ - data.icon || "shapes", - "icon", - ]).then((icon) => { - button.prepend(icon); - }); - - button.appendChild( - (() => { - const span = document.createElement("span"); - span.innerText = `${component[0]}${component[2] ? ` (${component[2].length + 1})` : ""}`; - return span; - })(), - ); - - button.addEventListener("click", () => { - if (component[2]) { - // render presets - return this.screen(page, { - back: ["add", {}], - add_title: "Select preset", - components: [ - ["Default", component[1]], - ...component[2], - ], - icon: "type", - }); - } - - // no presets - if ( - page === "element" && - this.current.component === "empty" - ) { - // replace with component - copy_fields(component[1], this.current); - } else { - // add component to children - this.current.children.push( - structuredClone(component[1]), - ); - } - - this.render(); - this.close(); - }); - - return button; - })(), - ); - } - - sidebar.appendChild(container); - } else if (page === "element") { - // edit element - const name = document.createElement("div"); - name.className = "flex flex-col gap-2"; - - name.appendChild( - (() => { - const heading = document.createElement("h3"); - heading.innerText = `Edit ${this.current.component}`; - return heading; - })(), - ); - - name.appendChild( - (() => { - const pos = document.createElement("div"); - pos.className = "notification w-content"; - pos.innerText = this.pointer.get().join("."); - return pos; - })(), - ); - - sidebar.appendChild(name); - sidebar.appendChild(document.createElement("hr")); - - // options - const options = document.createElement("div"); - options.className = "card flex flex-col gap-2 w-full"; - - const add_option = ( - label_text, - name, - valid = [], - input_element = "input", - ) => { - const card = document.createElement("details"); - card.className = "w-full"; - - const summary = document.createElement("summary"); - summary.className = "w-full"; - - const label = document.createElement("label"); - label.setAttribute("for", name); - label.className = "w-full"; - label.innerText = label_text; - label.style.cursor = "pointer"; - - label.addEventListener("click", () => { - // bubble to summary click - summary.click(); - }); - - const input_box = document.createElement("div"); - input_box.style.paddingLeft = "1rem"; - input_box.style.borderLeft = - "solid 2px var(--color-super-lowered)"; - - const input = document.createElement(input_element); - input.id = name; - input.setAttribute("name", name); - input.setAttribute("type", "text"); - - if (input_element === "input") { - input.setAttribute( - "value", - // biome-ignore lint/complexity/useOptionalChain: that's an unsafe change which would possibly break code - (this.current.options || {})[name] || "", - ); - } else { - // biome-ignore lint/complexity/useOptionalChain: that's an unsafe change which would possibly break code - input.innerHTML = (this.current.options || {})[name] || ""; - } - - // biome-ignore lint/complexity/useOptionalChain: that's an unsafe change which would possibly break code - if ((this.current.options || {})[name]) { - // open details if a value is set - card.setAttribute("open", ""); - } - - input.addEventListener("change", (e) => { - if ( - valid.length > 0 && - !valid.includes(e.target.value) && - e.target.value.length > 0 // anything can be set to empty - ) { - alert(`Must be one of: ${JSON.stringify(valid)}`); - return; - } - - if (!this.current.options) { - this.current.options = {}; - } - - this.current.options[name] = - e.target.value === "no" ? "" : e.target.value; - }); - - summary.appendChild(label); - card.appendChild(summary); - input_box.appendChild(input); - card.appendChild(input_box); - options.appendChild(card); - }; - - sidebar.appendChild(options); - - if (this.current.component === "flex") { - add_option("Gap", "gap", ["1", "2", "3", "4"]); - add_option("Direction", "direction", ["row", "col"]); - add_option("Do collapse", "collapse", ["yes", "no"]); - add_option("Width", "width", ["full", "content"]); - add_option("Class name", "class"); - add_option("Unique ID", "id"); - add_option("Style", "style", [], "textarea"); - } else if (this.current.component === "markdown") { - add_option("Content", "text", [], "textarea"); - add_option("Class name", "class"); - } else if (this.current.component === "divider") { - add_option("Class name", "class"); - } else if (this.current.component === "style") { - add_option("Style data", "data", [], "textarea"); - } else { - options.remove(); - } - - // action buttons - const buttons = document.createElement("div"); - buttons.className = "card w-full flex flex-wrap gap-2"; - - if (this.current.component === "flex") { - buttons.appendChild( - (() => { - const button = document.createElement("button"); - - trigger("app::icon", ["plus", "icon"]).then((icon) => { - button.prepend(icon); - }); - - button.appendChild( - (() => { - const span = document.createElement("span"); - span.innerText = "Add child"; - return span; - })(), - ); - - button.addEventListener("click", () => { - this.screen("add"); - }); - - return button; - })(), - ); - } - - buttons.appendChild( - (() => { - const button = document.createElement("button"); - - trigger("app::icon", ["move-up", "icon"]).then((icon) => { - button.prepend(icon); - }); - - button.appendChild( - (() => { - const span = document.createElement("span"); - span.innerText = "Move up"; - return span; - })(), - ); - - button.addEventListener("click", () => { - const idx = this.pointer.get().pop(); - const parent_ref = this.pointer.resolve( - this.json, - ).children; - - if (parent_ref[idx - 1] === undefined) { - alert("No space to move element."); - return; - } - - const clone = JSON.parse(JSON.stringify(this.current)); - const other_clone = JSON.parse( - JSON.stringify(parent_ref[idx - 1]), - ); - - copy_fields(clone, parent_ref[idx - 1]); // move here to here - copy_fields(other_clone, parent_ref[idx]); // move there to here - - this.close(); - this.render(); - }); - - return button; - })(), - ); - - buttons.appendChild( - (() => { - const button = document.createElement("button"); - - trigger("app::icon", ["move-down", "icon"]).then((icon) => { - button.prepend(icon); - }); - - button.appendChild( - (() => { - const span = document.createElement("span"); - span.innerText = "Move down"; - return span; - })(), - ); - - button.addEventListener("click", () => { - const idx = this.pointer.get().pop(); - const parent_ref = this.pointer.resolve( - this.json, - ).children; - - if (parent_ref[idx + 1] === undefined) { - alert("No space to move element."); - return; - } - - const clone = JSON.parse(JSON.stringify(this.current)); - const other_clone = JSON.parse( - JSON.stringify(parent_ref[idx + 1]), - ); - - copy_fields(clone, parent_ref[idx + 1]); // move here to here - copy_fields(other_clone, parent_ref[idx]); // move there to here - - this.close(); - this.render(); - }); - - return button; - })(), - ); - - buttons.appendChild( - (() => { - const button = document.createElement("button"); - button.classList.add("red"); - - trigger("app::icon", ["trash", "icon"]).then((icon) => { - button.prepend(icon); - }); - - button.appendChild( - (() => { - const span = document.createElement("span"); - span.innerText = "Delete"; - return span; - })(), - ); - - button.addEventListener("click", async () => { - if ( - !(await trigger("app::confirm", [ - "Are you sure you would like to do this?", - ])) - ) { - return; - } - - if (this.json === this.current) { - // this is the root element; replace with empty - copy_fields( - COMPONENT_TEMPLATES.EMPTY_COMPONENT, - this.current, - ); - } else { - // get parent - const idx = this.pointer.get().pop(); - const ref = this.pointer.resolve(this.json); - // remove element - ref.children.splice(idx, 1); - } - - this.render(); - this.close(); - }); - - return button; - })(), - ); - - sidebar.appendChild(buttons); - } else if (page === "tree") { - sidebar.innerHTML = this.tree; - } - - sidebar.appendChild(document.createElement("hr")); - - const buttons = document.createElement("div"); - buttons.className = "flex gap-2 flex-wrap"; - - if (data.back) { - buttons.appendChild( - (() => { - const button = document.createElement("button"); - button.className = "secondary"; - - trigger("app::icon", ["arrow-left", "icon"]).then( - (icon) => { - button.prepend(icon); - }, - ); - - button.appendChild( - (() => { - const span = document.createElement("span"); - span.innerText = "Back"; - return span; - })(), - ); - - button.addEventListener("click", () => { - this.screen(...data.back); - }); - - return button; - })(), - ); - } - - buttons.appendChild( - (() => { - const button = document.createElement("button"); - button.className = "red secondary"; - - trigger("app::icon", ["x", "icon"]).then((icon) => { - button.prepend(icon); - }); - - button.appendChild( - (() => { - const span = document.createElement("span"); - span.innerText = "Close"; - return span; - })(), - ); - - button.addEventListener("click", () => { - this.render(); - this.close(); - }); - - return button; - })(), - ); - - sidebar.appendChild(buttons); - - // ... - this.open(); - } -} - -define("ElementPointer", ElementPointer); -define("LayoutEditor", LayoutEditor); diff --git a/crates/app/src/routes/api/v1/auth/connections/stripe.rs b/crates/app/src/routes/api/v1/auth/connections/stripe.rs index 3a4619e..8343b1b 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -1,6 +1,7 @@ -use std::time::Duration; +use std::{str::FromStr, time::Duration}; use axum::{http::HeaderMap, response::IntoResponse, Extension, Json}; +use axum_extra::extract::CookieJar; use tetratto_core::model::{ auth::{User, Notification}, moderation::AuditLogEntry, @@ -8,7 +9,7 @@ use tetratto_core::model::{ ApiReturn, Error, }; use stripe::{EventObject, EventType}; -use crate::State; +use crate::{get_user_from_token, State}; pub async fn stripe_webhook( Extension(data): Extension, @@ -320,3 +321,102 @@ pub async fn stripe_webhook( payload: (), }) } + +pub async fn onboarding_account_link_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await); + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let client = match data.3 { + Some(ref c) => c, + None => return Json(Error::Unknown.into()), + }; + + match stripe::AccountLink::create( + &client, + stripe::CreateAccountLink { + account: match user.seller_data.account_id { + Some(id) => stripe::AccountId::from_str(&id).unwrap(), + None => return Json(Error::NotAllowed.into()), + }, + type_: stripe::AccountLinkType::AccountOnboarding, + collect: None, + expand: &[], + refresh_url: Some(&format!( + "{}/auth/connections_link/seller/refresh", + data.0.0.0.host + )), + return_url: Some(&format!( + "{}/auth/connections_link/seller/return", + data.0.0.0.host + )), + collection_options: None, + }, + ) + .await + { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Acceptable".to_string(), + payload: Some(x.url), + }), + Err(e) => Json(Error::MiscError(e.to_string()).into()), + } +} + +pub async fn create_seller_account_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await); + let mut user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let client = match data.3 { + Some(ref c) => c, + None => return Json(Error::Unknown.into()), + }; + + let account = match stripe::Account::create( + &client, + stripe::CreateAccount { + type_: Some(stripe::AccountType::Express), + capabilities: Some(stripe::CreateAccountCapabilities { + card_payments: Some(stripe::CreateAccountCapabilitiesCardPayments { + requested: Some(true), + }), + transfers: Some(stripe::CreateAccountCapabilitiesTransfers { + requested: Some(true), + }), + ..Default::default() + }), + ..Default::default() + }, + ) + .await + { + Ok(a) => a, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }; + + user.seller_data.account_id = Some(account.id.to_string()); + match data + .0 + .update_user_seller_data(user.id, user.seller_data) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Acceptable".to_string(), + payload: (), + }), + Err(e) => return Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index b3496db..164b17f 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -512,6 +512,14 @@ pub fn routes() -> Router { "/service_hooks/stripe", post(auth::connections::stripe::stripe_webhook), ) + .route( + "/service_hooks/stripe/seller/register", + post(auth::connections::stripe::create_seller_account_request), + ) + .route( + "/service_hooks/stripe/seller/onboarding", + post(auth::connections::stripe::onboarding_account_link_request), + ) // channels .route("/channels", post(channels::channels::create_request)) .route( diff --git a/crates/app/src/routes/assets.rs b/crates/app/src/routes/assets.rs index 2aa1bc5..d7843bd 100644 --- a/crates/app/src/routes/assets.rs +++ b/crates/app/src/routes/assets.rs @@ -19,5 +19,4 @@ serve_asset!(atto_js_request: ATTO_JS("text/javascript")); serve_asset!(me_js_request: ME_JS("text/javascript")); serve_asset!(streams_js_request: STREAMS_JS("text/javascript")); serve_asset!(carp_js_request: CARP_JS("text/javascript")); -serve_asset!(layout_editor_js_request: LAYOUT_EDITOR_JS("text/javascript")); serve_asset!(proto_links_request: PROTO_LINKS_JS("text/javascript")); diff --git a/crates/app/src/routes/mod.rs b/crates/app/src/routes/mod.rs index 0872632..e0fa067 100644 --- a/crates/app/src/routes/mod.rs +++ b/crates/app/src/routes/mod.rs @@ -20,10 +20,6 @@ pub fn routes(config: &Config) -> Router { .route("/js/me.js", get(assets::me_js_request)) .route("/js/streams.js", get(assets::streams_js_request)) .route("/js/carp.js", get(assets::carp_js_request)) - .route( - "/js/layout_editor.js", - get(assets::layout_editor_js_request), - ) .route("/js/proto_links.js", get(assets::proto_links_request)) .nest_service( "/public", diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 6ce6318..ed513f9 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -17,11 +17,10 @@ use axum::{ use axum_extra::extract::CookieJar; use serde::Deserialize; use tetratto_core::{ - DataManager, model::{Error, auth::User}, }; -use crate::{assets::initial_context, get_lang}; +use crate::{assets::initial_context, get_lang, InnerState}; pub fn routes() -> Router { Router::new() @@ -156,7 +155,7 @@ pub fn lw_routes() -> Router { pub async fn render_error( e: Error, jar: &CookieJar, - data: &(DataManager, tera::Tera, reqwest::Client), + data: &InnerState, user: &Option, ) -> String { let lang = get_lang!(jar, data.0); diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index 44d1257..d695c39 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -173,6 +173,8 @@ pub struct ConnectionsConfig { /// - Use testing card numbers: #[derive(Clone, Serialize, Deserialize, Debug, Default)] pub struct StripeConfig { + /// Your Stripe API secret. + pub secret: String, /// Payment links from the Stripe dashboard. /// /// 1. Create a product and set the price for your membership diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 88ef32e..9bfdd36 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -1,7 +1,8 @@ use super::common::NAME_REGEX; use oiseau::cache::Cache; use crate::model::auth::{ - Achievement, AchievementName, AchievementRarity, Notification, UserConnections, ACHIEVEMENTS, + Achievement, AchievementName, AchievementRarity, Notification, StripeSellerData, + UserConnections, ACHIEVEMENTS, }; use crate::model::moderation::AuditLogEntry; use crate::model::oauth::AuthGrant; @@ -117,6 +118,7 @@ impl DataManager { awaiting_purchase: get!(x->24(i32)) as i8 == 1, was_purchased: get!(x->25(i32)) as i8 == 1, browser_session: get!(x->26(String)), + seller_data: serde_json::from_str(&get!(x->27(String)).to_string()).unwrap(), } } @@ -273,7 +275,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27)", + "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28)", params![ &(data.id as i64), &(data.created as i64), @@ -302,6 +304,7 @@ impl DataManager { &if data.awaiting_purchase { 1_i32 } else { 0_i32 }, &if data.was_purchased { 1_i32 } else { 0_i32 }, &data.browser_session, + &serde_json::to_string(&data.seller_data).unwrap(), ] ); @@ -997,6 +1000,7 @@ impl DataManager { auto_method!(update_user_achievements(Vec)@get_user_by_id -> "UPDATE users SET achievements = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_invite_code(i64)@get_user_by_id -> "UPDATE users SET invite_code = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); auto_method!(update_user_browser_session(&str)@get_user_by_id -> "UPDATE users SET browser_session = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); + auto_method!(update_user_seller_data(StripeSellerData)@get_user_by_id -> "UPDATE users SET seller_data = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(get_user_by_stripe_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE stripe_id = $1" --name="user" --returns=User); auto_method!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index a97b1fd..1119d8b 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -80,6 +80,9 @@ pub struct User { /// view pages which require authentication (all `$` routes). #[serde(default)] pub browser_session: String, + /// Stripe connected account information (for Tetratto marketplace). + #[serde(default)] + pub seller_data: StripeSellerData, } pub type UserConnections = @@ -327,6 +330,12 @@ pub struct UserSettings { pub private_biography: String, } +#[derive(Clone, Debug, Serialize, Deserialize, Default)] +pub struct StripeSellerData { + #[serde(default)] + pub account_id: Option, +} + fn mime_avif() -> String { "image/avif".to_string() } @@ -371,6 +380,7 @@ impl User { awaiting_purchase: false, was_purchased: false, browser_session: String::new(), + seller_data: StripeSellerData::default(), } } diff --git a/sql_changes/users_seller_data.sql b/sql_changes/users_seller_data.sql new file mode 100644 index 0000000..fa8a1f0 --- /dev/null +++ b/sql_changes/users_seller_data.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN seller_data TEXT NOT NULL DEFAULT '{}'; From aea764948c3e8bd4a9b4fe6b2c724b50dec55897 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 12 Jul 2025 21:05:45 -0400 Subject: [PATCH 45/93] add: ability to create seller account --- crates/app/src/assets.rs | 8 ++ crates/app/src/langs/en-US.toml | 6 ++ crates/app/src/public/css/style.css | 8 ++ .../public/html/auth/seller_connection.lisp | 25 +++++ crates/app/src/public/html/macros.lisp | 14 +++ .../src/public/html/marketplace/seller.lisp | 79 +++++++++++++++ .../app/src/public/images/vendor/stripe.svg | 1 + crates/app/src/public/js/me.js | 57 +++++++++++ .../routes/api/v1/auth/connections/stripe.rs | 43 +++++++++ crates/app/src/routes/api/v1/mod.rs | 4 + crates/app/src/routes/pages/marketplace.rs | 95 +++++++++++++++++++ crates/app/src/routes/pages/mod.rs | 14 +++ crates/core/src/model/auth.rs | 2 + 13 files changed, 356 insertions(+) create mode 100644 crates/app/src/public/html/auth/seller_connection.lisp create mode 100644 crates/app/src/public/html/marketplace/seller.lisp create mode 100644 crates/app/src/public/images/vendor/stripe.svg create mode 100644 crates/app/src/routes/pages/marketplace.rs diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index e4088a1..ad0f49b 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -58,6 +58,7 @@ pub const AUTH_BASE: &str = include_str!("./public/html/auth/base.lisp"); pub const AUTH_LOGIN: &str = include_str!("./public/html/auth/login.lisp"); pub const AUTH_REGISTER: &str = include_str!("./public/html/auth/register.lisp"); pub const AUTH_CONNECTION: &str = include_str!("./public/html/auth/connection.lisp"); +pub const AUTH_SELLER_CONNECTION: &str = include_str!("./public/html/auth/seller_connection.lisp"); pub const PROFILE_BASE: &str = include_str!("./public/html/profile/base.lisp"); pub const PROFILE_POSTS: &str = include_str!("./public/html/profile/posts.lisp"); @@ -139,6 +140,8 @@ pub const LITTLEWEB_SERVICE: &str = include_str!("./public/html/littleweb/servic pub const LITTLEWEB_DOMAIN: &str = include_str!("./public/html/littleweb/domain.lisp"); pub const LITTLEWEB_BROWSER: &str = include_str!("./public/html/littleweb/browser.lisp"); +pub const MARKETPLACE_SELLER: &str = include_str!("./public/html/marketplace/seller.lisp"); + // langs pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); @@ -146,6 +149,7 @@ pub const LANG_EN_US: &str = include_str!("./langs/en-US.toml"); pub const VENDOR_SPOTIFY_ICON: &str = include_str!("./public/images/vendor/spotify.svg"); pub const VENDOR_LAST_FM_ICON: &str = include_str!("./public/images/vendor/last-fm.svg"); +pub const VENDOR_STRIPE_ICON: &str = include_str!("./public/images/vendor/stripe.svg"); pub const TETRATTO_BUNNY: &[u8] = include_bytes!("./public/images/tetratto_bunny.webp"); @@ -343,6 +347,7 @@ pub(crate) fn lisp_plugins() -> HashMap Elemen pub(crate) async fn write_assets(config: &Config) -> PathBufD { vendor_icon!("spotify", VENDOR_SPOTIFY_ICON, config.dirs.icons); vendor_icon!("last_fm", VENDOR_LAST_FM_ICON, config.dirs.icons); + vendor_icon!("stripe", VENDOR_STRIPE_ICON, config.dirs.icons); bin_icon!("tetratto_bunny.webp", TETRATTO_BUNNY, config.dirs.assets); // ... @@ -364,6 +369,7 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"auth/login.html"(crate::assets::AUTH_LOGIN) --config=config --lisp plugins); write_template!(html_path->"auth/register.html"(crate::assets::AUTH_REGISTER) --config=config --lisp plugins); write_template!(html_path->"auth/connection.html"(crate::assets::AUTH_CONNECTION) --config=config --lisp plugins); + write_template!(html_path->"auth/seller_connection.html"(crate::assets::AUTH_SELLER_CONNECTION) --config=config --lisp plugins); write_template!(html_path->"profile/base.html"(crate::assets::PROFILE_BASE) -d "profile" --config=config --lisp plugins); write_template!(html_path->"profile/posts.html"(crate::assets::PROFILE_POSTS) --config=config --lisp plugins); @@ -440,6 +446,8 @@ pub(crate) async fn write_assets(config: &Config) -> PathBufD { write_template!(html_path->"littleweb/domain.html"(crate::assets::LITTLEWEB_DOMAIN) --config=config --lisp plugins); write_template!(html_path->"littleweb/browser.html"(crate::assets::LITTLEWEB_BROWSER) --config=config --lisp plugins); + write_template!(html_path->"marketplace/seller.html"(crate::assets::MARKETPLACE_SELLER) -d "marketplace" --config=config --lisp plugins); + html_path } diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index a852d5a..94fa6f8 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -285,3 +285,9 @@ version = "1.0.0" "littleweb:action.edit_site_name" = "Edit site name" "littleweb:action.rename" = "Rename" "littleweb:action.add" = "Add" + +"marketplace:label.products" = "Products" +"marketplace:label.status" = "Status" +"marketplace:action.get_started" = "Get started" +"marketplace:action.finsh_setting_up_account" = "Finish setting up my account" +"marketplace:action.open_seller_dashboard" = "Open seller dashboard" diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 24c41bd..c4c5185 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -583,6 +583,9 @@ input[type="checkbox"]:checked { border-radius: 6px; height: max-content; font-weight: 600; + display: flex; + justify-content: center; + align-items: center; } .notification.tr { @@ -597,6 +600,11 @@ input[type="checkbox"]:checked { padding: 0; } +.notification .icon { + width: 100%; + height: 100%; +} + /* chip */ .chip { background: var(--color-primary); diff --git a/crates/app/src/public/html/auth/seller_connection.lisp b/crates/app/src/public/html/auth/seller_connection.lisp new file mode 100644 index 0000000..43381da --- /dev/null +++ b/crates/app/src/public/html/auth/seller_connection.lisp @@ -0,0 +1,25 @@ +(text "{% extends \"auth/base.html\" %} {% block head %}") +(title + (text "Connection")) + +(text "{% endblock %} {% block title %}Connection{% endblock %} {% block content %}") +(div + ("class" "w-full flex-col gap-2") + ("id" "status") + (b + (text "Working..."))) + +(text "{% if connection_type == \"refresh\" %}") +(script + ("defer" "true") + (text "setTimeout(async () => { + trigger(\"seller::onboarding\"); + }, 1000);")) +(text "{% elif connection_type == \"return\" %}") +(script + ("defer" "true") + (text "setTimeout(async () => { + document.getElementById(\"status\").innerHTML = + `Account updated. You can now close this tab.`; + }, 1000);")) +(text "{%- endif %} {% endblock %}") diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index f9d8a1f..980ee7f 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -331,3 +331,17 @@ (span (text "{{ text \"settings:tab.connections\" }}"))) (text "{%- endmacro %}") + +(text "{% macro seller_settings_nav_options() -%}") +(a + ("data-tab-button" "account") + ("class" "active") + ("href" "#/account") + (icon (text "smile")) + (span (str (text "settings:tab.account")))) +(a + ("data-tab-button" "products") + ("href" "#/products") + (icon (text "package")) + (span (str (text "marketplace:label.products")))) +(text "{%- endmacro %}") diff --git a/crates/app/src/public/html/marketplace/seller.lisp b/crates/app/src/public/html/marketplace/seller.lisp new file mode 100644 index 0000000..0efa4f0 --- /dev/null +++ b/crates/app/src/public/html/marketplace/seller.lisp @@ -0,0 +1,79 @@ +(text "{% extends \"root.html\" %} {% block head %}") +(title + (text "Seller settings - {{ config.name }}")) +(text "{% endblock %} {% block body %} {{ macros::nav() }}") +(main + ("class" "flex flex-col gap-2") + + ; nav + (div + ("class" "mobile_nav mobile") + ; primary nav + (div + ("class" "dropdown") + ("style" "width: max-content") + (button + ("class" "raised small") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + (icon (text "sliders-horizontal")) + (span ("class" "current_tab_text") (text "account"))) + (div + ("class" "inner left") + (text "{{ macros::seller_settings_nav_options() }}")))) + + ; nav desktop + (div + ("class" "desktop pillmenu") + (text "{{ macros::seller_settings_nav_options() }}")) + + ; ... + (div + ("class" "card w-full lowered flex flex-col gap-2") + ("data-tab" "account") + (div + ("class" "card-nest w-full") + (div + ("class" "card small flex items-center gap-2") + (div + ("class" "notification") + ("style" "width: 46px") + (icon (text "stripe"))) + + (b (str (text "marketplace:label.status")))) + + (div + ("class" "card") + (text "{% if user.seller_data.account_id -%}") + (text "{% if user.seller_data.completed_onboarding -%}") + ; completed onboarding + has stripe account linked + (button + ("onclick" "trigger('seller::login')") + (icon (text "arrow-right")) + (str (text "marketplace:action.open_seller_dashboard"))) + (text "{% else %}") + ; not completed onboarding + (p (text "You've not finished setting up your Stripe account.")) + (p (text "Please complete onboarding to accept payments.")) + + (button + ("onclick" "trigger('seller::onboarding')") + (icon (text "arrow-right")) + (str (text "marketplace:action.finsh_setting_up_account"))) + (text "{%- endif %}") + (text "{% else %}") + ; doesn't have a stripe account linked + (button + ("onclick" "trigger('seller::register')") + (icon (text "arrow-right")) + (str (text "marketplace:action.get_started"))) + (text "{%- endif %}")))) + + (div + ("class" "card w-full lowered hidden flex flex-col gap-2") + ("data-tab" "products") + (div + ("class" "card w-full flex flex-wrap gap-2") + ))) + +(text "{% endblock %}") diff --git a/crates/app/src/public/images/vendor/stripe.svg b/crates/app/src/public/images/vendor/stripe.svg new file mode 100644 index 0000000..415271d --- /dev/null +++ b/crates/app/src/public/images/vendor/stripe.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index 4fd2150..e7fa2d6 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -1201,3 +1201,60 @@ ]); }); })(); + +(() => { + const self = reg_ns("seller"); + + self.define("register", async () => { + await trigger("atto::debounce", ["seller::register"]); + + if ( + !(await trigger("atto::confirm", [ + "Are you sure you want to do this?", + ])) + ) { + return; + } + + const res = await ( + await fetch("/api/v1/service_hooks/stripe/seller/register", { + method: "POST", + }) + ).json(); + + trigger("atto::toast", [res.ok ? "success" : "error", res.message]); + self.onboarding(); + }); + + self.define("onboarding", async () => { + await trigger("atto::debounce", ["seller::onboarding"]); + + const res = await ( + await fetch("/api/v1/service_hooks/stripe/seller/onboarding", { + method: "POST", + }) + ).json(); + + trigger("atto::toast", [res.ok ? "success" : "error", res.message]); + + if (res.ok) { + window.location.href = res.payload; + } + }); + + self.define("login", async () => { + await trigger("atto::debounce", ["seller::login"]); + + const res = await ( + await fetch("/api/v1/service_hooks/stripe/seller/login", { + method: "POST", + }) + ).json(); + + trigger("atto::toast", [res.ok ? "success" : "error", res.message]); + + if (res.ok) { + window.location.href = res.payload; + } + }); +})(); diff --git a/crates/app/src/routes/api/v1/auth/connections/stripe.rs b/crates/app/src/routes/api/v1/auth/connections/stripe.rs index 8343b1b..33e60b4 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -332,6 +332,10 @@ pub async fn onboarding_account_link_request( None => return Json(Error::NotAllowed.into()), }; + if user.seller_data.account_id.is_some() { + return Json(Error::NotAllowed.into()); + } + let client = match data.3 { Some(ref c) => c, None => return Json(Error::Unknown.into()), @@ -379,6 +383,10 @@ pub async fn create_seller_account_request( None => return Json(Error::NotAllowed.into()), }; + if user.seller_data.account_id.is_some() { + return Json(Error::NotAllowed.into()); + } + let client = match data.3 { Some(ref c) => c, None => return Json(Error::Unknown.into()), @@ -420,3 +428,38 @@ pub async fn create_seller_account_request( Err(e) => return Json(e.into()), } } + +pub async fn login_link_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await); + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if user.seller_data.account_id.is_none() | !user.seller_data.completed_onboarding { + return Json(Error::NotAllowed.into()); + } + + let client = match data.3 { + Some(ref c) => c, + None => return Json(Error::Unknown.into()), + }; + + match stripe::LoginLink::create( + &client, + &stripe::AccountId::from_str(&user.seller_data.account_id.unwrap()).unwrap(), + &data.0.0.0.host, + ) + .await + { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Acceptable".to_string(), + payload: Some(x.url), + }), + Err(e) => Json(Error::MiscError(e.to_string()).into()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 164b17f..ccc91c8 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -520,6 +520,10 @@ pub fn routes() -> Router { "/service_hooks/stripe/seller/onboarding", post(auth::connections::stripe::onboarding_account_link_request), ) + .route( + "/service_hooks/stripe/seller/login", + post(auth::connections::stripe::login_link_request), + ) // channels .route("/channels", post(channels::channels::create_request)) .route( diff --git a/crates/app/src/routes/pages/marketplace.rs b/crates/app/src/routes/pages/marketplace.rs new file mode 100644 index 0000000..69a2b3d --- /dev/null +++ b/crates/app/src/routes/pages/marketplace.rs @@ -0,0 +1,95 @@ +use super::render_error; +use crate::{assets::initial_context, get_lang, get_user_from_token, State}; +use axum::{ + response::{Html, IntoResponse}, + Extension, +}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::Error; + +/// `/settings/seller` +pub async fn seller_settings_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + let lang = get_lang!(jar, data.0); + let context = initial_context(&data.0.0.0, lang, &Some(user)).await; + + // return + Ok(Html( + data.1.render("marketplace/seller.html", &context).unwrap(), + )) +} + +pub async fn connection_return_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let mut user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + // update user + user.seller_data.completed_onboarding = true; + if let Err(e) = data + .0 + .update_user_seller_data(user.id, user.seller_data.clone()) + .await + { + return Err(Html(render_error(e, &jar, &data, &None).await)); + } + + // ... + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + context.insert("connection_type", "return"); + + // return + Ok(Html( + data.1 + .render("auth/seller_connection.html", &context) + .unwrap(), + )) +} + +pub async fn connection_reload_request( + jar: CookieJar, + Extension(data): Extension, +) -> impl IntoResponse { + let data = data.read().await; + let user = match get_user_from_token!(jar, data.0) { + Some(ua) => ua, + None => { + return Err(Html( + render_error(Error::NotAllowed, &jar, &data, &None).await, + )); + } + }; + + let lang = get_lang!(jar, data.0); + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + context.insert("connection_type", "reload"); + + // return + Ok(Html( + data.1 + .render("auth/seller_connection.html", &context) + .unwrap(), + )) +} diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index ed513f9..83e29ad 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -5,6 +5,7 @@ pub mod developer; pub mod forge; pub mod journals; pub mod littleweb; +pub mod marketplace; pub mod misc; pub mod mod_panel; pub mod profile; @@ -76,6 +77,14 @@ pub fn routes() -> Router { "/auth/connections_link/app/{id}", get(developer::connection_callback_request), ) + .route( + "/auth/connections_link/seller/reload", + get(marketplace::connection_reload_request), + ) + .route( + "/auth/connections_link/seller/return", + get(marketplace::connection_return_request), + ) // profile .route("/settings", get(profile::settings_request)) .route("/@{username}", get(profile::posts_request)) @@ -146,6 +155,11 @@ pub fn routes() -> Router { .route("/domains/{id}", get(littleweb::domain_request)) .route("/net", get(littleweb::browser_home_request)) .route("/net/{*uri}", get(littleweb::browser_request)) + // marketplace + .route( + "/settings/seller", + get(marketplace::seller_settings_request), + ) } pub fn lw_routes() -> Router { diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 1119d8b..4fb1882 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -334,6 +334,8 @@ pub struct UserSettings { pub struct StripeSellerData { #[serde(default)] pub account_id: Option, + #[serde(default)] + pub completed_onboarding: bool, } fn mime_avif() -> String { From 2705608903c3c298007f7b2e01036152589457c8 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 13 Jul 2025 00:05:28 -0400 Subject: [PATCH 46/93] add: product types --- .../app/src/public/html/profile/settings.lisp | 2 +- crates/app/src/routes/api/v1/auth/profile.rs | 35 ++++++++--- crates/core/src/database/auth.rs | 4 +- crates/core/src/model/mod.rs | 1 + crates/core/src/model/products.rs | 58 +++++++++++++++++++ 5 files changed, 89 insertions(+), 11 deletions(-) create mode 100644 crates/core/src/model/products.rs diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index c5566c7..e3d775b 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -1537,7 +1537,7 @@ ], [ [\"auto_full_unlist\", \"Only publish my posts to my profile\"], - \"{{ profile.settings.auto_unlist }}\", + \"{{ profile.settings.auto_full_unlist }}\", \"checkbox\", ], [ diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index 8104c71..bb874fe 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -1,4 +1,4 @@ -use std::time::Duration; +use std::{str::FromStr, time::Duration}; use crate::{ get_user_from_token, model::{ApiReturn, Error}, @@ -451,8 +451,8 @@ pub async fn delete_user_request( Extension(data): Extension, Json(req): Json, ) -> impl IntoResponse { - let data = &(data.read().await).0; - let user = match get_user_from_token!(jar, data) { + let data = &(data.read().await); + let user = match get_user_from_token!(jar, data.0) { Some(ua) => ua, None => return Json(Error::NotAllowed.into()), }; @@ -461,6 +461,7 @@ pub async fn delete_user_request( return Json(Error::NotAllowed.into()); } else if user.permissions.check(FinePermission::MANAGE_USERS) { if let Err(e) = data + .0 .create_audit_log_entry(AuditLogEntry::new( user.id, format!("invoked `delete_user` with x value `{id}`"), @@ -472,14 +473,32 @@ pub async fn delete_user_request( } match data + .0 .delete_user(id, &req.password, user.permissions.check_manager()) .await { - Ok(_) => Json(ApiReturn { - ok: true, - message: "User deleted".to_string(), - payload: (), - }), + Ok(ua) => { + // delete stripe user + if let Some(stripe_id) = ua.seller_data.account_id + && let Some(ref client) = data.3 + { + if let Err(e) = stripe::Account::delete( + &client, + &stripe::AccountId::from_str(&stripe_id).unwrap(), + ) + .await + { + return Json(Error::MiscError(e.to_string()).into()); + } + } + + // ... + Json(ApiReturn { + ok: true, + message: "User deleted".to_string(), + payload: (), + }) + } Err(e) => Json(e.into()), } } diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 9bfdd36..b8651ca 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -321,7 +321,7 @@ impl DataManager { /// * `id` - the ID of the user /// * `password` - the current password of the user /// * `force` - if we should delete even if the given password is incorrect - pub async fn delete_user(&self, id: usize, password: &str, force: bool) -> Result<()> { + pub async fn delete_user(&self, id: usize, password: &str, force: bool) -> Result { let user = self.get_user_by_id(id).await?; if (hash_salted(password.to_string(), user.salt.clone()) != user.password) && !force { @@ -581,7 +581,7 @@ impl DataManager { } // ... - Ok(()) + Ok(user) } pub async fn update_user_verified_status(&self, id: usize, x: bool, user: User) -> Result<()> { diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index 2cd4955..b86ebfa 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -10,6 +10,7 @@ pub mod littleweb; pub mod moderation; pub mod oauth; pub mod permissions; +pub mod products; pub mod reactions; pub mod requests; pub mod socket; diff --git a/crates/core/src/model/products.rs b/crates/core/src/model/products.rs new file mode 100644 index 0000000..e7b5b41 --- /dev/null +++ b/crates/core/src/model/products.rs @@ -0,0 +1,58 @@ +use serde::{Serialize, Deserialize}; +use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Product { + pub id: usize, + pub created: usize, + pub owner: usize, + pub title: String, + pub description: String, + pub likes: usize, + pub dislikes: usize, + pub r#type: ProductType, + pub stripe_id: String, + pub price: ProductPrice, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum ProductType { + /// Text + images. + Message, + /// When a commission product is purchased, the creator will receive a request + /// prompting them to respond with text + images. + /// + /// This is the only product type which does not immediately return data to the + /// customer, as seller input is required. + /// + /// If the request is deleted, the purchase should be immediately refunded. + Commission, +} + +/// Price in USD. `(dollars, cents)`. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ProductPrice(u64, u64); + +impl Product { + /// Create a new [`Product`]. + pub fn new( + owner: usize, + title: String, + description: String, + price: ProductPrice, + r#type: ProductType, + ) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + created: unix_epoch_timestamp(), + owner, + title, + description, + likes: 0, + dislikes: 0, + r#type, + stripe_id: String::new(), + price, + } + } +} From ea135265155c81ed5e2f8a392a289ff5986b6bc6 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 13 Jul 2025 00:50:16 -0400 Subject: [PATCH 47/93] add: product types --- crates/app/src/public/css/style.css | 2 +- crates/app/src/public/html/mod/profile.lisp | 1 + crates/core/src/database/common.rs | 1 + crates/core/src/database/drivers/common.rs | 1 + .../database/drivers/sql/create_products.sql | 12 ++ crates/core/src/database/mod.rs | 1 + crates/core/src/database/products.rs | 138 ++++++++++++++++++ crates/core/src/model/permissions.rs | 1 + crates/core/src/model/products.rs | 14 +- 9 files changed, 163 insertions(+), 8 deletions(-) create mode 100644 crates/core/src/database/drivers/sql/create_products.sql create mode 100644 crates/core/src/database/products.rs diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index c4c5185..53162c6 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -600,7 +600,7 @@ input[type="checkbox"]:checked { padding: 0; } -.notification .icon { +.notification:not(.chip) .icon { width: 100%; height: 100%; } diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index 2121f1e..408b391 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -368,6 +368,7 @@ ADMINISTRATOR: 1 << 1, MANAGE_DOMAINS: 1 << 2, MANAGE_SERVICES: 1 << 3, + MANAGE_PRODUCTS: 1 << 4, }, \"secondary_role\", \"add_permission_to_secondary_role\", diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index f3d2668..e7cd0ef 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -42,6 +42,7 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_INVITE_CODES).unwrap(); execute!(&conn, common::CREATE_TABLE_DOMAINS).unwrap(); execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap(); + execute!(&conn, common::CREATE_TABLE_PRODUCTS).unwrap(); self.0 .1 diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index 6a562e7..7bee30a 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -29,3 +29,4 @@ pub const CREATE_TABLE_MESSAGE_REACTIONS: &str = include_str!("./sql/create_mess pub const CREATE_TABLE_INVITE_CODES: &str = include_str!("./sql/create_invite_codes.sql"); pub const CREATE_TABLE_DOMAINS: &str = include_str!("./sql/create_domains.sql"); pub const CREATE_TABLE_SERVICES: &str = include_str!("./sql/create_services.sql"); +pub const CREATE_TABLE_PRODUCTS: &str = include_str!("./sql/create_products.sql"); diff --git a/crates/core/src/database/drivers/sql/create_products.sql b/crates/core/src/database/drivers/sql/create_products.sql new file mode 100644 index 0000000..ff45afc --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_products.sql @@ -0,0 +1,12 @@ +CREATE TABLE IF NOT EXISTS products ( + id BIGINT NOT NULL PRIMARY KEY, + created BIGINT NOT NULL, + owner BIGINT NOT NULL, + name TEXT NOT NULL, + description TEXT NOT NULL, + likes INT NOT NULL, + dislikes INT NOT NULL, + product_type TEXT NOT NULL, + stripe_id TEXT NOT NULL, + price TEXT NOT NULL +) diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 57873f9..730c54a 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -21,6 +21,7 @@ mod notifications; mod polls; mod pollvotes; mod posts; +mod products; mod questions; mod reactions; mod reports; diff --git a/crates/core/src/database/products.rs b/crates/core/src/database/products.rs new file mode 100644 index 0000000..10eb566 --- /dev/null +++ b/crates/core/src/database/products.rs @@ -0,0 +1,138 @@ +use crate::model::{ + auth::User, + products::Product, + permissions::{FinePermission, SecondaryPermission}, + Error, Result, +}; +use crate::{auto_method, DataManager}; +use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow}; + +impl DataManager { + /// Get a [`Product`] from an SQL row. + pub(crate) fn get_product_from_row(x: &PostgresRow) -> Product { + Product { + id: get!(x->0(i64)) as usize, + created: get!(x->1(i64)) as usize, + owner: get!(x->2(i64)) as usize, + name: get!(x->3(String)), + description: get!(x->4(String)), + likes: get!(x->5(i32)) as isize, + dislikes: get!(x->6(i32)) as isize, + product_type: serde_json::from_str(&get!(x->7(String))).unwrap(), + stripe_id: get!(x->8(String)), + price: serde_json::from_str(&get!(x->9(String))).unwrap(), + } + } + + auto_method!(get_product_by_id(usize as i64)@get_product_from_row -> "SELECT * FROM products WHERE id = $1" --name="product" --returns=Product --cache-key-tmpl="atto.product:{}"); + + /// Get all products by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch products for + pub async fn get_products_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 products WHERE owner = $1 ORDER BY created DESC", + &[&(id as i64)], + |x| { Self::get_product_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("product".to_string())); + } + + Ok(res.unwrap()) + } + + const MAXIMUM_FREE_PRODUCTS: usize = 15; + + /// Create a new product in the database. + /// + /// # Arguments + /// * `data` - a mock [`Product`] object to insert + pub async fn create_product(&self, data: Product) -> Result { + // check values + if data.name.len() < 2 { + return Err(Error::DataTooShort("name".to_string())); + } else if data.name.len() > 128 { + return Err(Error::DataTooLong("name".to_string())); + } + + // check number of products + let owner = self.get_user_by_id(data.owner).await?; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + let products = self.get_products_by_user(data.owner).await?; + + if products.len() >= Self::MAXIMUM_FREE_PRODUCTS { + return Err(Error::MiscError( + "You already have the maximum number of products you can have".to_string(), + )); + } + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + params![ + &(data.id as i64), + &(data.created as i64), + &(data.owner as i64), + &data.name, + &data.description, + &0_i32, + &0_i32, + &serde_json::to_string(&data.product_type).unwrap(), + &data.stripe_id, + &serde_json::to_string(&data.price).unwrap(), + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_product(&self, id: usize, user: &User) -> Result<()> { + let product = self.get_product_by_id(id).await?; + + // check user permission + if user.id != product.owner + && !user + .secondary_permissions + .check(SecondaryPermission::MANAGE_PRODUCTS) + { + return Err(Error::NotAllowed); + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!(&conn, "DELETE FROM products WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... + self.0.1.remove(format!("atto.product:{}", id)).await; + Ok(()) + } +} diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs index 55cf9cc..bbaca18 100644 --- a/crates/core/src/model/permissions.rs +++ b/crates/core/src/model/permissions.rs @@ -176,6 +176,7 @@ bitflags! { const ADMINISTRATOR = 1 << 1; const MANAGE_DOMAINS = 1 << 2; const MANAGE_SERVICES = 1 << 3; + const MANAGE_PRODUCTS = 1 << 4; const _ = !0; } diff --git a/crates/core/src/model/products.rs b/crates/core/src/model/products.rs index e7b5b41..1b54ba3 100644 --- a/crates/core/src/model/products.rs +++ b/crates/core/src/model/products.rs @@ -6,11 +6,11 @@ pub struct Product { pub id: usize, pub created: usize, pub owner: usize, - pub title: String, + pub name: String, pub description: String, - pub likes: usize, - pub dislikes: usize, - pub r#type: ProductType, + pub likes: isize, + pub dislikes: isize, + pub product_type: ProductType, pub stripe_id: String, pub price: ProductPrice, } @@ -37,7 +37,7 @@ impl Product { /// Create a new [`Product`]. pub fn new( owner: usize, - title: String, + name: String, description: String, price: ProductPrice, r#type: ProductType, @@ -46,11 +46,11 @@ impl Product { id: Snowflake::new().to_string().parse::().unwrap(), created: unix_epoch_timestamp(), owner, - title, + name, description, likes: 0, dislikes: 0, - r#type, + product_type: r#type, stripe_id: String::new(), price, } From 2be2409d661b9f90a2f5ae17dcd292cd002ab0d5 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 13 Jul 2025 12:42:28 -0400 Subject: [PATCH 48/93] fix: InvoicePaymentFailed event --- crates/app/src/routes/api/v1/auth/connections/stripe.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 33e60b4..16db8d6 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -262,12 +262,12 @@ pub async fn stripe_webhook( } EventType::InvoicePaymentFailed => { // payment failed - let subscription = match req.data.object { - EventObject::Subscription(c) => c, + let invoice = match req.data.object { + EventObject::Invoice(i) => i, _ => unreachable!("cannot be this"), }; - let customer_id = subscription.customer.id(); + let customer_id = invoice.customer.expect("TETRATTO_STRIPE_NO_CUSTOMER").id(); let user = match data.get_user_by_stripe_id(customer_id.as_str()).await { Ok(ua) => ua, From cf2af1e1e9e65a57b2f567ce223fd6a82899acd3 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 13 Jul 2025 15:28:55 -0400 Subject: [PATCH 49/93] add: products api --- crates/app/src/routes/api/v1/mod.rs | 36 ++++ crates/app/src/routes/api/v1/products.rs | 162 ++++++++++++++++++ .../database/drivers/sql/create_products.sql | 1 - crates/core/src/database/products.rs | 10 +- crates/core/src/model/oauth.rs | 6 + crates/core/src/model/products.rs | 35 +++- 6 files changed, 241 insertions(+), 9 deletions(-) create mode 100644 crates/app/src/routes/api/v1/products.rs diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index ccc91c8..38f915e 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -6,6 +6,7 @@ pub mod domains; pub mod journals; pub mod notes; pub mod notifications; +pub mod products; pub mod reactions; pub mod reports; pub mod requests; @@ -31,6 +32,7 @@ use tetratto_core::model::{ littleweb::{DomainData, DomainTld, ServiceFsEntry}, oauth::AppScope, permissions::{FinePermission, SecondaryPermission}, + products::{ProductType, ProductPrice}, reactions::AssetType, stacks::{StackMode, StackPrivacy, StackSort}, }; @@ -652,6 +654,17 @@ pub fn routes() -> Router { .route("/domains/{id}", get(domains::get_request)) .route("/domains/{id}", delete(domains::delete_request)) .route("/domains/{id}/data", post(domains::update_data_request)) + // products + .route("/products", get(products::list_request)) + .route("/products", post(products::create_request)) + .route("/products/{id}", get(products::get_request)) + .route("/products/{id}", delete(products::delete_request)) + .route("/products/{id}/name", post(products::update_name_request)) + .route( + "/products/{id}/description", + post(products::update_description_request), + ) + .route("/products/{id}/price", post(products::update_price_request)) } pub fn lw_routes() -> Router { @@ -1086,3 +1099,26 @@ pub struct CreateDomain { pub struct UpdateDomainData { pub data: Vec<(String, DomainData)>, } + +#[derive(Deserialize)] +pub struct CreateProduct { + pub name: String, + pub description: String, + pub product_type: ProductType, + pub price: ProductPrice, +} + +#[derive(Deserialize)] +pub struct UpdateProductName { + pub name: String, +} + +#[derive(Deserialize)] +pub struct UpdateProductDescription { + pub description: String, +} + +#[derive(Deserialize)] +pub struct UpdateProductPrice { + pub price: ProductPrice, +} diff --git a/crates/app/src/routes/api/v1/products.rs b/crates/app/src/routes/api/v1/products.rs new file mode 100644 index 0000000..5812127 --- /dev/null +++ b/crates/app/src/routes/api/v1/products.rs @@ -0,0 +1,162 @@ +use crate::{ + get_user_from_token, + routes::api::v1::{ + CreateProduct, UpdateProductDescription, UpdateProductName, UpdateProductPrice, + }, + State, +}; +use axum::{extract::Path, response::IntoResponse, Extension, Json}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{products::Product, oauth, ApiReturn, Error}; + +pub async fn get_request( + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + match data.get_product_by_id(id).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => return Json(e.into()), + } +} + +pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.get_products_by_user(user.id).await { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .create_product(Product::new( + user.id, + req.name, + req.description, + req.price, + req.product_type, + )) + .await + { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Product created".to_string(), + payload: x.id.to_string(), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_name_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_product_name(id, &user, &req.name).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_description_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .update_product_description(id, &user, &req.description) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_price_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_product_price(id, &user, req.price).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProducts) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.delete_product(id, &user).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/core/src/database/drivers/sql/create_products.sql b/crates/core/src/database/drivers/sql/create_products.sql index ff45afc..54bec8d 100644 --- a/crates/core/src/database/drivers/sql/create_products.sql +++ b/crates/core/src/database/drivers/sql/create_products.sql @@ -7,6 +7,5 @@ CREATE TABLE IF NOT EXISTS products ( likes INT NOT NULL, dislikes INT NOT NULL, product_type TEXT NOT NULL, - stripe_id TEXT NOT NULL, price TEXT NOT NULL ) diff --git a/crates/core/src/database/products.rs b/crates/core/src/database/products.rs index 10eb566..a9833f0 100644 --- a/crates/core/src/database/products.rs +++ b/crates/core/src/database/products.rs @@ -1,7 +1,7 @@ use crate::model::{ auth::User, - products::Product, permissions::{FinePermission, SecondaryPermission}, + products::{Product, ProductPrice}, Error, Result, }; use crate::{auto_method, DataManager}; @@ -19,7 +19,6 @@ impl DataManager { likes: get!(x->5(i32)) as isize, dislikes: get!(x->6(i32)) as isize, product_type: serde_json::from_str(&get!(x->7(String))).unwrap(), - stripe_id: get!(x->8(String)), price: serde_json::from_str(&get!(x->9(String))).unwrap(), } } @@ -85,7 +84,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + "INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", params![ &(data.id as i64), &(data.created as i64), @@ -95,7 +94,6 @@ impl DataManager { &0_i32, &0_i32, &serde_json::to_string(&data.product_type).unwrap(), - &data.stripe_id, &serde_json::to_string(&data.price).unwrap(), ] ); @@ -135,4 +133,8 @@ impl DataManager { self.0.1.remove(format!("atto.product:{}", id)).await; Ok(()) } + + auto_method!(update_product_name(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET name = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); + auto_method!(update_product_description(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET description = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); + auto_method!(update_product_price(ProductPrice)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET price = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.product:{}"); } diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index 07a23c3..72884ae 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -74,6 +74,8 @@ pub enum AppScope { UserReadDomains, /// Read the user's services. UserReadServices, + /// Read the user's products. + UserReadProducts, /// Create posts as the user. UserCreatePosts, /// Create messages as the user. @@ -98,6 +100,8 @@ pub enum AppScope { UserCreateDomains, /// Create services on behalf of the user. UserCreateServices, + /// Create products on behalf of the user. + UserCreateProducts, /// Delete posts owned by the user. UserDeletePosts, /// Delete messages owned by the user. @@ -138,6 +142,8 @@ pub enum AppScope { UserManageDomains, /// Manage the user's services. UserManageServices, + /// Manage the user's products. + UserManageProducts, /// Edit posts created by the user. UserEditPosts, /// Edit drafts created by the user. diff --git a/crates/core/src/model/products.rs b/crates/core/src/model/products.rs index 1b54ba3..5e28b76 100644 --- a/crates/core/src/model/products.rs +++ b/crates/core/src/model/products.rs @@ -1,3 +1,5 @@ +use std::fmt::Display; + use serde::{Serialize, Deserialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; @@ -11,14 +13,13 @@ pub struct Product { pub likes: isize, pub dislikes: isize, pub product_type: ProductType, - pub stripe_id: String, pub price: ProductPrice, } #[derive(Debug, Clone, Serialize, Deserialize)] pub enum ProductType { /// Text + images. - Message, + Data, /// When a commission product is purchased, the creator will receive a request /// prompting them to respond with text + images. /// @@ -26,12 +27,39 @@ pub enum ProductType { /// customer, as seller input is required. /// /// If the request is deleted, the purchase should be immediately refunded. + /// + /// Commissions are paid beforehand to prevent theft. This means it is vital + /// that refunds are enforced. Commission, } +/// A currency. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum Currency { + USD, + EUR, + GBP, +} + +impl Display for Currency { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(match self { + Currency::USD => "$", + Currency::EUR => "€", + Currency::GBP => "£", + }) + } +} + /// Price in USD. `(dollars, cents)`. #[derive(Debug, Clone, Serialize, Deserialize)] -pub struct ProductPrice(u64, u64); +pub struct ProductPrice(u64, u64, Currency); + +impl Display for ProductPrice { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&format!("{}{}.{}", self.2, self.0, self.1)) + } +} impl Product { /// Create a new [`Product`]. @@ -51,7 +79,6 @@ impl Product { likes: 0, dislikes: 0, product_type: r#type, - stripe_id: String::new(), price, } } From f94570f74cab431477f2998467c41cd3f2d2130d Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 13 Jul 2025 17:54:12 -0400 Subject: [PATCH 50/93] add: settings presets --- crates/app/src/langs/en-US.toml | 2 + crates/app/src/public/css/style.css | 19 ++- .../src/public/html/littleweb/browser.lisp | 2 +- crates/app/src/public/html/macros.lisp | 4 +- crates/app/src/public/html/profile/base.lisp | 2 +- .../app/src/public/html/profile/blocked.lisp | 2 +- .../app/src/public/html/profile/private.lisp | 2 +- .../app/src/public/html/profile/settings.lisp | 157 +++++++++++++++++- .../app/src/public/html/timelines/home.lisp | 12 ++ 9 files changed, 186 insertions(+), 16 deletions(-) diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 94fa6f8..abc24e1 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -32,6 +32,7 @@ version = "1.0.0" "general:action.copy_link" = "Copy link" "general:action.copy_id" = "Copy ID" "general:action.post" = "Post" +"general:action.apply" = "Apply" "general:label.account" = "Account" "general:label.safety" = "Safety" "general:label.share" = "Share" @@ -161,6 +162,7 @@ version = "1.0.0" "settings:tab.sessions" = "Sessions" "settings:tab.connections" = "Connections" "settings:tab.images" = "Images" +"settings:tab.presets" = "Presets" "settings:label.change_password" = "Change password" "settings:label.current_password" = "Current password" "settings:label.delete_account" = "Delete account" diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index 53162c6..f8a4a8a 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -939,7 +939,7 @@ dialog::backdrop { transition: transform 0.15s; } -.dropdown:has(.inner.open) .dropdown-arrow { +.dropdown:has(.inner.open) .dropdown_arrow { transform: rotateZ(180deg); } @@ -1119,7 +1119,7 @@ details[open] > summary { margin-bottom: var(--pad-1); } -details[open] > summary::after { +details[open]:not(.accordion) > summary::after { top: 0; left: 0; width: 5px; @@ -1142,8 +1142,7 @@ details.accordion { } details.accordion summary { - background: var(--background); - border: solid 1px var(--color-super-lowered); + background: var(--color-lowered); border-radius: var(--radius); padding: var(--pad-3) var(--pad-4); margin: 0; @@ -1151,11 +1150,15 @@ details.accordion summary { user-select: none; } -details.accordion summary .icon { +details.accordion summary:hover { + background: var(--color-super-lowered); +} + +details.accordion summary .icon.dropdown_arrow { transition: transform 0.15s; } -details.accordion[open] summary .icon { +details.accordion[open] summary .icon.dropdown_arrow { transform: rotateZ(180deg); } @@ -1165,13 +1168,11 @@ details.accordion[open] summary { } details.accordion .inner { - background: var(--background); + background: var(--color-raised); padding: var(--pad-3) var(--pad-4); border-radius: var(--radius); border-top-left-radius: 0; border-top-right-radius: 0; - border: solid 1px var(--color-super-lowered); - border-top: none; } /* codemirror */ diff --git a/crates/app/src/public/html/littleweb/browser.lisp b/crates/app/src/public/html/littleweb/browser.lisp index 9379357..95f35d8 100644 --- a/crates/app/src/public/html/littleweb/browser.lisp +++ b/crates/app/src/public/html/littleweb/browser.lisp @@ -47,7 +47,7 @@ ("exclude" "dropdown") ("style" "gap: var(--pad-1) !important") (text "{{ components::avatar(username=user.username, size=\"24px\") }}") - (icon_class (text "chevron-down") (text "dropdown-arrow"))) + (icon_class (text "chevron-down") (text "dropdown_arrow"))) (text "{{ components::user_menu() }}")) (text "{%- endif %}")) diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index 980ee7f..0b0ba4b 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -74,7 +74,7 @@ ("exclude" "dropdown") ("style" "gap: var(--pad-1) !important") (text "{{ components::avatar(username=user.username, size=\"24px\") }}") - (icon_class (text "chevron-down") (text "dropdown-arrow"))) + (icon_class (text "chevron-down") (text "dropdown_arrow"))) (text "{{ components::user_menu() }}")) (text "{%- endif %} {% else %}") @@ -84,7 +84,7 @@ ("class" "title") ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") - (icon_class (text "chevron-down") (text "dropdown-arrow"))) + (icon_class (text "chevron-down") (text "dropdown_arrow"))) (div ("class" "inner") diff --git a/crates/app/src/public/html/profile/base.lisp b/crates/app/src/public/html/profile/base.lisp index 7962728..c482033 100644 --- a/crates/app/src/public/html/profile/base.lisp +++ b/crates/app/src/public/html/profile/base.lisp @@ -225,7 +225,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("class" "lowered red") - (icon_class (text "chevron-down") (text "dropdown-arrow")) + (icon_class (text "chevron-down") (text "dropdown_arrow")) (str (text "auth:action.block"))) (div ("class" "inner left") diff --git a/crates/app/src/public/html/profile/blocked.lisp b/crates/app/src/public/html/profile/blocked.lisp index 1a128fa..660be0d 100644 --- a/crates/app/src/public/html/profile/blocked.lisp +++ b/crates/app/src/public/html/profile/blocked.lisp @@ -30,7 +30,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("class" "lowered red") - (icon_class (text "chevron-down") (text "dropdown-arrow")) + (icon_class (text "chevron-down") (text "dropdown_arrow")) (str (text "auth:action.block"))) (div ("class" "inner left") diff --git a/crates/app/src/public/html/profile/private.lisp b/crates/app/src/public/html/profile/private.lisp index 83d533f..11740c9 100644 --- a/crates/app/src/public/html/profile/private.lisp +++ b/crates/app/src/public/html/profile/private.lisp @@ -57,7 +57,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("class" "lowered red") - (icon_class (text "chevron-down") (text "dropdown-arrow")) + (icon_class (text "chevron-down") (text "dropdown_arrow")) (str (text "auth:action.block"))) (div ("class" "inner left") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index e3d775b..18fcc99 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -35,6 +35,87 @@ (text "{{ macros::profile_settings_nav_options() }}")) ; ... + (div + ("class" "w-full flex flex-col gap-2 hidden") + ("data-tab" "presets") + (div + ("class" "card lowered flex flex-col gap-2") + (a + ("href" "#/account") + ("class" "button secondary") + (icon (text "arrow-left")) + (span + (str (text "general:action.back")))) + (div + ("class" "card-nest") + (div + ("class" "card flex items-center gap-2 small") + (icon (text "cooking-pot")) + (span + (str (text "settings:tab.presets")))) + (div + ("class" "card flex flex-col gap-2 secondary") + (p (text "Not sure where to start? Try some settings presets!")) + (details + ("class" "w-full accordion") + (summary + (icon (text "rss")) + (text "Microblogging")) + + (div + ("class" "inner flex flex-col gap-2") + (p ("class" "fade") (text "Focus on yourself and your communities.")) + (ul ("id" "preset_microblogging_ul")) + (button + ("onclick" "apply_preset(PRESET_MICROBLOGGING)") + (icon (text "settings")) + (str (text "general:action.apply"))))) + + (details + ("class" "w-full accordion") + (summary + (icon (text "message-circle-heart")) + (text "Q&A")) + + (div + ("class" "inner flex flex-col gap-2") + (p ("class" "fade") (text "Just like Neospring!")) + (ul ("id" "preset_questions_ul")) + (button + ("onclick" "apply_preset(PRESET_QUESTIONS)") + (icon (text "settings")) + (str (text "general:action.apply"))))) + + (details + ("class" "w-full accordion") + (summary + (icon (text "key")) + (text "Private")) + + (div + ("class" "inner flex flex-col gap-2") + (p ("class" "fade") (text "This preset allows you to keep your profile and posts hidden to people you aren't following.")) + (ul ("id" "preset_private_ul")) + (button + ("onclick" "apply_preset(PRESET_PRIVATE)") + (icon (text "settings")) + (str (text "general:action.apply"))))) + + (details + ("class" "w-full accordion") + (summary + (icon (text "eye-closed")) + (text "NSFW")) + + (div + ("class" "inner flex flex-col gap-2") + (p ("class" "fade") (text "NSFW content is allowed if it is hidden from main timelines. This preset will help you do that quickly.")) + (ul ("id" "preset_nsfw_ul")) + (button + ("onclick" "apply_preset(PRESET_NSFW)") + (icon (text "settings")) + (str (text "general:action.apply"))))))))) + (div ("class" "w-full flex flex-col gap-2") ("data-tab" "account") @@ -780,7 +861,23 @@ (text "Responses"))) (span ("class" "fade") - (text "This represents the timeline that is shown on your profile by default."))))) + (text "This represents the timeline that is shown on your profile by default.")))) + (div + ("class" "flex flex-col gap-2") + ("ui_ident" "show_presets") + (hr ("class" "margin")) + (div + ("class" "card-nest") + (div + ("class" "card small") + (b + (text "Not sure what to do?"))) + (div + ("class" "card no_p_margin") + (p + (text "Quickly set up your account with ") + (a ("href" "/settings#/presets") (text "settings presets")) + (text "!")))))) (button ("onclick" "save_settings()") ("id" "save_button") @@ -1392,6 +1489,63 @@ }); } + // presets + globalThis.apply_preset = async (preset) => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this? This will change all listed settings to the listed value.\", + ])) + ) { + return; + } + + for (const x of preset) { + window.SETTING_SET_FUNCTIONS[0](x[0], x[1]) + } + + save_settings(); + } + + globalThis.render_preset_lis = (preset, id) => { + for (const x of preset) { + console.log(id); + document.getElementById(id).innerHTML += `
  • ${x[0]}: ${x[1]}
  • `; + } + } + + globalThis.PRESET_MICROBLOGGING = [ + [\"default_timeline\", \"All\"], + [\"all_timeline_hide_answers\", true], + ]; + + globalThis.PRESET_QUESTIONS = [ + [\"default_timeline\", \"Following\"], + [\"auto_full_unlist\", true], + [\"enable_questions\", true], + [\"allow_anonymous_questions\", true], + [\"enable_drawings\", true], + [\"hide_extra_post_tabs\", true], + ]; + + globalThis.PRESET_PRIVATE = [ + [\"private_profile\", true], + [\"private_last_seen\", true], + [\"private_communities\", true], + [\"private_chats\", true], + [\"require_account\", true], + ]; + + globalThis.PRESET_NSFW = [ + [\"auto_unlist\", true], + [\"show_nsfw\", true], + ]; + + render_preset_lis(PRESET_MICROBLOGGING, \"preset_microblogging_ul\"); + render_preset_lis(PRESET_QUESTIONS, \"preset_questions_ul\"); + render_preset_lis(PRESET_PRIVATE, \"preset_private_ul\"); + render_preset_lis(PRESET_NSFW, \"preset_nsfw_ul\"); + + // ... const account_settings = document.getElementById(\"account_settings\"); const profile_settings = @@ -1411,6 +1565,7 @@ \"change_avatar\", \"change_banner\", \"default_profile_page\", + \"show_presets\", ]); ui.refresh_container(theme_settings, [ \"supporter_ad\", diff --git a/crates/app/src/public/html/timelines/home.lisp b/crates/app/src/public/html/timelines/home.lisp index 5a5658b..65b3a60 100644 --- a/crates/app/src/public/html/timelines/home.lisp +++ b/crates/app/src/public/html/timelines/home.lisp @@ -24,6 +24,18 @@ (a ("href" "/communities/search") (text "searching for a community to join!"))))) + (div + ("class" "card-nest") + (div + ("class" "card small") + (b + (text "Need help getting started?"))) + (div + ("class" "card no_p_margin") + (p + (text "Quickly set up your account with ") + (a ("href" "/settings#/presets") (text "settings presets")) + (text "!")))) (text "{% else %}") (div ("class" "card w-full flex flex-col gap-2") From 2c83ed3d9d2a794e32fc73ef7161866fad067a29 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 13 Jul 2025 18:42:08 -0400 Subject: [PATCH 51/93] add: "ask about this" from neospring --- crates/app/src/langs/en-US.toml | 1 + crates/app/src/public/html/components.lisp | 43 ++++++++++++++----- crates/app/src/public/html/misc/requests.lisp | 2 +- .../routes/api/v1/communities/questions.rs | 7 +++ crates/app/src/routes/api/v1/mod.rs | 2 + crates/app/src/routes/pages/communities.rs | 20 +++++++-- crates/core/src/database/posts.rs | 39 +++++++++++++---- crates/core/src/database/questions.rs | 36 +++++++++++++--- crates/core/src/model/communities.rs | 3 ++ 9 files changed, 122 insertions(+), 31 deletions(-) diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index abc24e1..788ca48 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -131,6 +131,7 @@ version = "1.0.0" "communities:label.edit_content" = "Edit content" "communities:label.repost" = "Repost" "communities:label.quote_post" = "Quote post" +"communities:label.ask_about_this" = "Ask about this" "communities:label.search_results" = "Search results" "communities:label.query" = "Query" "communities:label.join_new" = "Join new" diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index bd03879..0ce8821 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -118,7 +118,7 @@ (div ("class" "card-nest post_outer:{{ post.id }} post_outer") ("is_repost" "{{ is_repost }}") - (text "{% if question -%} {{ self::question(question=question[0], owner=question[1], profile=owner) }} {% else %}") + (text "{% if question -%} {{ self::question(question=question[0], owner=question[1], asking_about=question[2], profile=owner) }} {% else %}") (div ("class" "card small") (a @@ -321,7 +321,6 @@ ("class" "button camo small") ("target" "_blank") (text "{{ icon \"external-link\" }}")) - (text "{% if user -%}") (div ("class" "dropdown") (button @@ -335,6 +334,7 @@ (b ("class" "title") (text "{{ text \"general:label.share\" }}")) + (text "{% if user -%}") (button ("onclick" "trigger('me::repost', ['{{ post.id }}', '', '{{ config.town_square }}', true])") (text "{{ icon \"repeat-2\" }}") @@ -357,7 +357,14 @@ (span (text "BlueSky"))) (text "{%- endif %}") - (text "{% if user.id != post.owner -%}") + (a + ("class" "button") + ("href" "/@{{ owner.username }}?asking_about={{ post.id }}") + (icon (text "reply")) + (span + (str (text "communities:label.ask_about_this")))) + (text "{%- endif %}") + (text "{% if user and user.id != post.owner -%}") (b ("class" "title") (text "{{ text \"general:label.safety\" }}")) @@ -367,12 +374,12 @@ (text "{{ icon \"flag\" }}") (span (text "{{ text \"general:action.report\" }}"))) - (text "{%- endif %} {% if (user.id == post.owner) or is_helper or can_manage_post %}") + (text "{%- endif %} {% if user and (user.id == post.owner) or is_helper or can_manage_post %}") (b ("class" "title") (text "{{ text \"general:action.manage\" }}")) ; forge stuff - (text "{% if community and community.is_forge -%} {% if post.is_open -%}") + (text "{% if user and community and community.is_forge -%} {% if post.is_open -%}") (button ("class" "green") ("onclick" "trigger('me::update_open', ['{{ post.id }}', false])") @@ -388,7 +395,7 @@ (text "{{ text \"forge:action.reopen\" }}"))) (text "{%- endif %} {%- endif %}") ; owner stuff - (text "{% if user.id == post.owner -%}") + (text "{% if user and user.id == post.owner -%}") (a ("href" "/post/{{ post.id }}#/edit") (text "{{ icon \"pen\" }}") @@ -420,8 +427,7 @@ (text "{{ icon \"undo\" }}") (span (text "{{ text \"general:action.restore\" }}"))) - (text "{%- endif %} {%- endif %}"))) - (text "{%- endif %}")))) + (text "{%- endif %} {%- endif %}")))))) (text "{% if community and show_community and community.id != config.town_square or question %}")) (text "{%- endif %} {%- endmacro %} {% macro post_media(upload_ids) -%} {% if upload_ids|length > 0 -%}") @@ -630,7 +636,7 @@ --{{ css }}: {{ color|color }} !important; }")) -(text "{%- endif %} {%- endmacro %} {% macro question(question, owner, show_community=true, secondary=false, profile=false) -%}") +(text "{%- endif %} {%- endmacro %} {% macro question(question, owner, asking_about=false, show_community=true, secondary=false, profile=false) -%}") (div ("class" "card question {% if secondary -%}secondary{%- endif %} flex gap-2") (text "{% if owner.id == 0 or question.context.mask_owner -%}") @@ -700,6 +706,10 @@ (text "{{ question.content|markdown|safe }}")) ; question drawings (text "{{ self::post_media(upload_ids=question.drawings) }}") + ; asking about + (text "{% if asking_about -%}") + (text "{{ self::post(post=asking_about[1], owner=asking_about[0], secondary=not secondary, show_community=false) }}") + (text "{%- endif %}") ; anonymous user ip thing ; this is only shown if the post author is anonymous AND we are a helper (text "{% if is_helper and (owner.id == 0 or question.context.mask_owner) -%}") @@ -736,6 +746,7 @@ ("class" "no_p_margin") (text "{% if header -%} {{ header|markdown|safe }} {% else %} {{ text \"requests:label.ask_question\" }} {%- endif %}"))) (form + ("id" "create_question_form") ("class" "card flex flex-col gap-2") ("onsubmit" "create_question_from_form(event)") (div @@ -822,6 +833,15 @@ (script (text "globalThis.gerald = null; + // asking about + globalThis.asking_about = new URLSearchParams(window.location.search).get(\"asking_about\"); + + if (asking_about) { + document.getElementById(\"create_question_form\").innerHTML += + `
    Asking about: ${asking_about} (cancel)`; + } + + // ... async function create_question_from_form(e) { e.preventDefault(); await trigger(\"atto::debounce\", [\"questions::create\"]); @@ -843,7 +863,8 @@ receiver: \"{{ receiver }}\", community: \"{{ community }}\", is_global: \"{{ is_global }}\" == \"true\", - mask_owner: (e.target.mask_owner || { checked:false }).checked + mask_owner: (e.target.mask_owner || { checked:false }).checked, + asking_about, }), ); @@ -872,7 +893,7 @@ (text "{%- endmacro %} {% macro global_question(question, can_manage_questions=false, secondary=false, show_community=true) -%}") (div ("class" "card-nest") - (text "{{ self::question(question=question[0], owner=question[1], show_community=show_community) }}") + (text "{{ self::question(question=question[0], owner=question[1], asking_about=false, show_community=show_community) }}") (div ("class" "small card flex justify-between flex-wrap gap-2{% if secondary -%} secondary{%- endif %}") (div diff --git a/crates/app/src/public/html/misc/requests.lisp b/crates/app/src/public/html/misc/requests.lisp index 9ba68d2..b9700f0 100644 --- a/crates/app/src/public/html/misc/requests.lisp +++ b/crates/app/src/public/html/misc/requests.lisp @@ -92,7 +92,7 @@ (text "{%- endif %} {% endfor %} {% for question in questions %}") (div ("class" "card-nest") - (text "{{ components::question(question=question[0], owner=question[1], profile=user) }}") + (text "{{ components::question(question=question[0], owner=question[1], asking_about=question[2], profile=user) }}") (form ("class" "card flex flex-col gap-2") ("onsubmit" "answer_question_from_form(event, '{{ question[0].id }}')") diff --git a/crates/app/src/routes/api/v1/communities/questions.rs b/crates/app/src/routes/api/v1/communities/questions.rs index 1d1a7ba..e67b91b 100644 --- a/crates/app/src/routes/api/v1/communities/questions.rs +++ b/crates/app/src/routes/api/v1/communities/questions.rs @@ -96,6 +96,13 @@ pub async fn create_request( props.context.mask_owner = true; } + if !req.asking_about.is_empty() && !req.is_global { + props.context.asking_about = match req.asking_about.parse::() { + Ok(x) => Some(x), + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + } + } + match data .create_question(props, drawings.iter().map(|x| x.to_vec()).collect()) .await diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 38f915e..d4e19c1 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -865,6 +865,8 @@ pub struct CreateQuestion { pub community: String, #[serde(default)] pub mask_owner: bool, + #[serde(default)] + pub asking_about: String, } #[derive(Deserialize)] diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs index 30d2ce0..59dc982 100644 --- a/crates/app/src/routes/pages/communities.rs +++ b/crates/app/src/routes/pages/communities.rs @@ -1,3 +1,5 @@ +use std::collections::HashMap; + use super::{render_error, PaginatedQuery, RepostsQuery, SearchedQuery}; use crate::{ assets::initial_context, check_user_blocked_or_private, get_lang, get_user_from_token, State, @@ -798,7 +800,11 @@ pub async fn post_request( let (_, reposting) = data.0.get_post_reposting(&post, &ignore_users, &user).await; // check question - let question = match data.0.get_post_question(&post, &ignore_users).await { + let question = match data + .0 + .get_post_question(&post, &ignore_users, &mut HashMap::new()) + .await + { Ok(q) => q, Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), }; @@ -918,7 +924,11 @@ pub async fn reposts_request( let reposting = data.0.get_post_reposting(&post, &ignore_users, &user).await; // check question - let question = match data.0.get_post_question(&post, &ignore_users).await { + let question = match data + .0 + .get_post_question(&post, &ignore_users, &mut HashMap::new()) + .await + { Ok(q) => q, Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), }; @@ -1069,7 +1079,11 @@ pub async fn likes_request( .await; // check question - let question = match data.0.get_post_question(&post, &ignore_users).await { + let question = match data + .0 + .get_post_question(&post, &ignore_users, &mut HashMap::new()) + .await + { Ok(q) => q, Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), }; diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index f17bbea..701c053 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -22,10 +22,11 @@ pub type FullPost = ( User, Community, Option<(User, Post)>, - Option<(Question, User)>, + Option<(Question, User, Option<(User, Post)>)>, Option<(Poll, bool, bool)>, Option, ); +pub type FullQuestion = (Question, User, Option<(User, Post)>); macro_rules! private_post_replying { ($post:ident, $replying_posts:ident, $ua1:ident, $data:ident) => { @@ -224,8 +225,14 @@ impl DataManager { &self, post: &Post, ignore_users: &[usize], - ) -> Result> { + seen_questions: &mut HashMap, + ) -> Result> { if post.context.answering != 0 { + if let Some(q) = seen_questions.get(&post.context.answering) { + return Ok(Some(q.to_owned())); + } + + // ... let question = self.get_question_by_id(post.context.answering).await?; if ignore_users.contains(&question.owner) { @@ -238,7 +245,11 @@ impl DataManager { self.get_user_by_id_with_void(question.owner).await? }; - Ok(Some((question, user))) + let asking_about = self.get_question_asking_about(&question).await?; + let full_question = (question, user, asking_about); + + seen_questions.insert(post.context.answering, full_question.to_owned()); + Ok(Some(full_question)) } else { Ok(None) } @@ -322,7 +333,7 @@ impl DataManager { Post, User, Option<(User, Post)>, - Option<(Question, User)>, + Option<(Question, User, Option<(User, Post)>)>, Option<(Poll, bool, bool)>, Option, )>, @@ -332,6 +343,7 @@ impl DataManager { let mut users: HashMap = HashMap::new(); let mut seen_user_follow_statuses: HashMap<(usize, usize), bool> = HashMap::new(); let mut seen_stacks: HashMap = HashMap::new(); + let mut seen_questions: HashMap = HashMap::new(); let mut replying_posts: HashMap = HashMap::new(); for post in posts { @@ -373,7 +385,8 @@ impl DataManager { post.clone(), ua.clone(), reposting, - self.get_post_question(&post, ignore_users).await?, + self.get_post_question(&post, ignore_users, &mut seen_questions) + .await?, self.get_post_poll(&post, user).await?, stack, )); @@ -454,7 +467,8 @@ impl DataManager { post.clone(), ua, reposting, - self.get_post_question(&post, ignore_users).await?, + self.get_post_question(&post, ignore_users, &mut seen_questions) + .await?, self.get_post_poll(&post, user).await?, stack, )); @@ -477,6 +491,7 @@ impl DataManager { let mut seen_before: HashMap<(usize, usize), (User, Community)> = HashMap::new(); let mut seen_user_follow_statuses: HashMap<(usize, usize), bool> = HashMap::new(); let mut seen_stacks: HashMap = HashMap::new(); + let mut seen_questions: HashMap = HashMap::new(); let mut replying_posts: HashMap = HashMap::new(); let mut memberships: HashMap = HashMap::new(); @@ -544,7 +559,8 @@ impl DataManager { ua.clone(), community.to_owned(), reposting, - self.get_post_question(&post, ignore_users).await?, + self.get_post_question(&post, ignore_users, &mut seen_questions) + .await?, self.get_post_poll(&post, user).await?, stack, )); @@ -643,7 +659,8 @@ impl DataManager { ua, community, reposting, - self.get_post_question(&post, ignore_users).await?, + self.get_post_question(&post, ignore_users, &mut seen_questions) + .await?, self.get_post_poll(&post, user).await?, stack, )); @@ -716,8 +733,12 @@ impl DataManager { } // question - if let Some((_, ref mut x)) = post.4 { + if let Some((_, ref mut x, ref mut y)) = post.4 { x.clean(); + + if y.is_some() { + y.as_mut().unwrap().0.clean(); + } } // ... diff --git a/crates/core/src/database/questions.rs b/crates/core/src/database/questions.rs index 1cee527..900d68c 100644 --- a/crates/core/src/database/questions.rs +++ b/crates/core/src/database/questions.rs @@ -2,6 +2,7 @@ use std::collections::HashMap; use oiseau::cache::Cache; use tetratto_shared::unix_epoch_timestamp; use crate::model::addr::RemoteAddr; +use crate::model::communities::Post; use crate::model::communities_permissions::CommunityPermission; use crate::model::uploads::{MediaType, MediaUpload}; use crate::model::{ @@ -38,13 +39,26 @@ impl DataManager { auto_method!(get_question_by_id()@get_question_from_row -> "SELECT * FROM questions WHERE id = $1" --name="question" --returns=Question --cache-key-tmpl="atto.question:{}"); + /// Get the post a given question is asking about. + pub async fn get_question_asking_about( + &self, + question: &Question, + ) -> Result> { + Ok(if let Some(id) = question.context.asking_about { + let post = self.get_post_by_id(id).await?; + Some((self.get_user_by_id(post.owner).await?, post)) + } else { + None + }) + } + /// Fill the given vector of questions with their owner as well. pub async fn fill_questions( &self, questions: Vec, ignore_users: &[usize], - ) -> Result> { - let mut out: Vec<(Question, User)> = Vec::new(); + ) -> Result)>> { + let mut out: Vec<(Question, User, Option<(User, Post)>)> = Vec::new(); let mut seen_users: HashMap = HashMap::new(); for question in questions { @@ -53,7 +67,8 @@ impl DataManager { } if let Some(ua) = seen_users.get(&question.owner) { - out.push((question, ua.to_owned())); + let asking_about = self.get_question_asking_about(&question).await?; + out.push((question, ua.to_owned(), asking_about)); } else { let user = if question.owner == 0 { User::anonymous() @@ -62,7 +77,9 @@ impl DataManager { }; seen_users.insert(question.owner, user.clone()); - out.push((question, user)); + + let asking_about = self.get_question_asking_about(&question).await?; + out.push((question, user, asking_about)); } } @@ -72,12 +89,17 @@ impl DataManager { /// Filter to update questions to clean their owner for public APIs. pub fn questions_owner_filter( &self, - questions: &Vec<(Question, User)>, - ) -> Vec<(Question, User)> { - let mut out: Vec<(Question, User)> = Vec::new(); + questions: &Vec<(Question, User, Option<(User, Post)>)>, + ) -> Vec<(Question, User, Option<(User, Post)>)> { + let mut out: Vec<(Question, User, Option<(User, Post)>)> = Vec::new(); for mut question in questions.clone() { question.1.clean(); + + if question.2.is_some() { + question.2.as_mut().unwrap().0.clean(); + } + out.push(question); } diff --git a/crates/core/src/model/communities.rs b/crates/core/src/model/communities.rs index 8a4ab9a..14f640f 100644 --- a/crates/core/src/model/communities.rs +++ b/crates/core/src/model/communities.rs @@ -387,6 +387,9 @@ pub struct QuestionContext { /// If the owner is shown as anonymous in the UI. #[serde(default)] pub mask_owner: bool, + /// The POST this question is asking about. + #[serde(default)] + pub asking_about: Option, } #[derive(Clone, Debug, Serialize, Deserialize)] From 73d8e9ab498799ab35d49a4b2f9f9d15acefff2c Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 13 Jul 2025 18:43:36 -0400 Subject: [PATCH 52/93] fix: don't show "ask about this" if owner has questions disabled --- crates/app/src/public/html/components.lisp | 2 ++ 1 file changed, 2 insertions(+) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 0ce8821..2e09987 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -357,6 +357,7 @@ (span (text "BlueSky"))) (text "{%- endif %}") + (text "{% if owner.settings.enable_questions -%}") (a ("class" "button") ("href" "/@{{ owner.username }}?asking_about={{ post.id }}") @@ -364,6 +365,7 @@ (span (str (text "communities:label.ask_about_this")))) (text "{%- endif %}") + (text "{%- endif %}") (text "{% if user and user.id != post.owner -%}") (b ("class" "title") From 052ddf862f16039fa23f647825a8377d6e338ce2 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 13 Jul 2025 19:05:17 -0400 Subject: [PATCH 53/93] fix: check permissions before asking about a post --- crates/core/src/database/questions.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/crates/core/src/database/questions.rs b/crates/core/src/database/questions.rs index 900d68c..253b6c2 100644 --- a/crates/core/src/database/questions.rs +++ b/crates/core/src/database/questions.rs @@ -427,6 +427,22 @@ impl DataManager { } } + // check asking_about + if let Some(id) = data.context.asking_about { + let post = self.get_post_by_id(id).await?; + let owner = self.get_user_by_id(post.owner).await?; + + if post.stack != 0 { + return Err(Error::MiscError( + "Cannot ask about posts in a circle".to_string(), + )); + } else if owner.settings.private_profile { + return Err(Error::MiscError( + "Cannot ask about posts from a private user".to_string(), + )); + } + } + // create uploads if drawings.len() > 2 { return Err(Error::MiscError( From 292d30230402331005e5a035662539cd99c9368b Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 13 Jul 2025 19:58:59 -0400 Subject: [PATCH 54/93] fix: regular question asking --- crates/app/src/public/html/components.lisp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 2e09987..578345a 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -836,7 +836,7 @@ (script (text "globalThis.gerald = null; // asking about - globalThis.asking_about = new URLSearchParams(window.location.search).get(\"asking_about\"); + globalThis.asking_about = new URLSearchParams(window.location.search).get(\"asking_about\") || \"\"; if (asking_about) { document.getElementById(\"create_question_form\").innerHTML += From 3b5b0ce1a1e18c7745515f151a19f8edae08799e Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 13 Jul 2025 23:15:00 -0400 Subject: [PATCH 55/93] add: product uploads --- crates/app/src/public/html/components.lisp | 11 ++++++++++- crates/app/src/public/html/macros.lisp | 1 + crates/app/src/public/html/profile/settings.lisp | 2 +- .../app/src/routes/api/v1/auth/connections/stripe.rs | 2 +- .../core/src/database/drivers/sql/create_products.sql | 3 ++- crates/core/src/database/products.rs | 6 ++++-- crates/core/src/database/questions.rs | 6 +++++- crates/core/src/model/products.rs | 3 +++ 8 files changed, 27 insertions(+), 7 deletions(-) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 578345a..1e3aa17 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -227,7 +227,7 @@ ("hook" "long") (text "{{ post.title }}")) - (button ("class" "small lowered") (icon (text "ellipsis")))) + (button ("title" "View post content") ("class" "small lowered") (icon (text "ellipsis")))) (text "{% else %}") (text "{% if not post.context.content_warning -%}") (span @@ -327,6 +327,7 @@ ("class" "camo small") ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -922,6 +923,7 @@ ("class" "camo small") ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -1035,6 +1037,7 @@ ("class" "camo small") ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -1273,6 +1276,7 @@ ("class" "camo small square") ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -1494,6 +1498,7 @@ ("title" "More options") ("onclick" "document.getElementById('post_options_dialog').showModal()") ("type" "button") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (label @@ -2085,6 +2090,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("style" "width: 32px") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -2111,6 +2117,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("style" "width: 32px") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -2202,6 +2209,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("style" "width: 32px") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") @@ -2281,6 +2289,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("style" "width: 32px") + ("title" "More options") (text "{{ icon \"ellipsis\" }}")) (div ("class" "inner") diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index 0b0ba4b..969439b 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -73,6 +73,7 @@ ("onclick" "trigger('atto::hooks::dropdown', [event])") ("exclude" "dropdown") ("style" "gap: var(--pad-1) !important") + ("title" "Account options") (text "{{ components::avatar(username=user.username, size=\"24px\") }}") (icon_class (text "chevron-down") (text "dropdown_arrow"))) diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 18fcc99..da41608 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -1493,7 +1493,7 @@ globalThis.apply_preset = async (preset) => { if ( !(await trigger(\"atto::confirm\", [ - \"Are you sure you would like to do this? This will change all listed settings to the listed value.\", + \"Are you sure you would like to do this? This will change all listed settings to their listed values.\", ])) ) { return; 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 16db8d6..91560b0 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -303,7 +303,7 @@ pub async fn stripe_webhook( if let Err(e) = data .create_notification(Notification::new( "It seems your recent payment has failed :(".to_string(), - "No worries! Your subscription is still active and will be retried. Your supporter status will resume when you have a successful payment." + "No worries! Your subscription is still active and will be retried. Your supporter status will resume when you have a successful payment.\n\nIf you've cancelled your subscription, you can safely disregard this." .to_string(), user.id, )) diff --git a/crates/core/src/database/drivers/sql/create_products.sql b/crates/core/src/database/drivers/sql/create_products.sql index 54bec8d..4a972aa 100644 --- a/crates/core/src/database/drivers/sql/create_products.sql +++ b/crates/core/src/database/drivers/sql/create_products.sql @@ -7,5 +7,6 @@ CREATE TABLE IF NOT EXISTS products ( likes INT NOT NULL, dislikes INT NOT NULL, product_type TEXT NOT NULL, - price TEXT NOT NULL + price TEXT NOT NULL, + uploads TEXT NOT NULL ) diff --git a/crates/core/src/database/products.rs b/crates/core/src/database/products.rs index a9833f0..5127f78 100644 --- a/crates/core/src/database/products.rs +++ b/crates/core/src/database/products.rs @@ -19,7 +19,8 @@ impl DataManager { likes: get!(x->5(i32)) as isize, dislikes: get!(x->6(i32)) as isize, product_type: serde_json::from_str(&get!(x->7(String))).unwrap(), - price: serde_json::from_str(&get!(x->9(String))).unwrap(), + price: serde_json::from_str(&get!(x->8(String))).unwrap(), + uploads: serde_json::from_str(&get!(x->9(String))).unwrap(), } } @@ -84,7 +85,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)", + "INSERT INTO products VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", params![ &(data.id as i64), &(data.created as i64), @@ -95,6 +96,7 @@ impl DataManager { &0_i32, &serde_json::to_string(&data.product_type).unwrap(), &serde_json::to_string(&data.price).unwrap(), + &serde_json::to_string(&data.uploads).unwrap(), ] ); diff --git a/crates/core/src/database/questions.rs b/crates/core/src/database/questions.rs index 253b6c2..84f9eac 100644 --- a/crates/core/src/database/questions.rs +++ b/crates/core/src/database/questions.rs @@ -45,7 +45,11 @@ impl DataManager { question: &Question, ) -> Result> { Ok(if let Some(id) = question.context.asking_about { - let post = self.get_post_by_id(id).await?; + let post = match self.get_post_by_id(id).await { + Ok(x) => x, + Err(_) => return Ok(None), + }; + Some((self.get_user_by_id(post.owner).await?, post)) } else { None diff --git a/crates/core/src/model/products.rs b/crates/core/src/model/products.rs index 5e28b76..2b90ca5 100644 --- a/crates/core/src/model/products.rs +++ b/crates/core/src/model/products.rs @@ -14,6 +14,8 @@ pub struct Product { pub dislikes: isize, pub product_type: ProductType, pub price: ProductPrice, + /// Optional uploads to accompany the product title and description. Maximum of 4. + pub uploads: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -80,6 +82,7 @@ impl Product { dislikes: 0, product_type: r#type, price, + uploads: Vec::new(), } } } From e0e38b2b32414b97f61fff15745b826b947fe2d1 Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 14 Jul 2025 15:30:17 -0400 Subject: [PATCH 56/93] add: upload alt text --- crates/app/src/langs/en-US.toml | 1 + crates/app/src/public/html/components.lisp | 1 - .../app/src/public/html/profile/settings.lisp | 90 ++++++++++++----- crates/app/src/public/js/atto.js | 17 ++-- .../src/routes/api/v1/communities/posts.rs | 5 +- crates/app/src/routes/api/v1/mod.rs | 7 ++ crates/app/src/routes/api/v1/products.rs | 97 +++++++++++++++---- crates/app/src/routes/api/v1/uploads.rs | 42 +++++++- crates/app/src/routes/pages/marketplace.rs | 8 +- .../database/drivers/sql/create_uploads.sql | 3 +- crates/core/src/database/uploads.rs | 8 +- crates/core/src/model/uploads.rs | 2 + sql_changes/uploads_alt.sql | 2 + 13 files changed, 224 insertions(+), 59 deletions(-) create mode 100644 sql_changes/uploads_alt.sql diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 788ca48..de5a411 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -183,6 +183,7 @@ version = "1.0.0" "settings:label.ips" = "IPs" "settings:label.generate_invites" = "Generate invites" "settings:label.add_to_stack" = "Add to stack" +"settings:label.alt_text" = "Alt text" "settings:tab.security" = "Security" "settings:tab.blocks" = "Blocks" "settings:tab.billing" = "Billing" diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 1e3aa17..c81ef23 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -442,7 +442,6 @@ ("alt" "Image upload") ("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload }}'])")) (text "{% endfor %}")) - (text "{%- endif %} {%- endmacro %} {% macro notification(notification) -%}") (div ("class" "w-full card-nest") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index da41608..6be8134 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -574,32 +574,51 @@ (div ("class" "card flex flex-col gap-2 secondary") (text "{{ components::supporter_ad(body=\"Become a supporter to upload images directly to posts!\") }} {% for upload in uploads %}") - (div - ("class" "card flex flex-wrap gap-2 items-center justify-between") + (details + ("class" "accordion w-full") + (summary + ("class" "card flex flex-wrap gap-2 items-center justify-between") + (div + ("class" "flex gap-2 items-center") + (icon_class (text "chevron-down") (text "dropdown_arrow")) + (b + (span + ("class" "date") + (text "{{ upload.created }}")) + (text " ({{ upload.what }})"))) + (div + ("class" "flex gap-2") + (button + ("class" "raised small") + ("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])") + (text "{{ icon \"view\" }}") + (span + (text "{{ text \"general:action.view\" }}"))) + (button + ("class" "raised small red") + ("onclick" "remove_upload('{{ upload.id }}')") + (text "{{ icon \"x\" }}") + (span + (text "{{ text \"stacks:label.remove\" }}"))))) + (div - ("class" "flex gap-2 items-center") - ("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])") - ("style" "cursor: pointer") - (text "{{ icon \"file-image\" }}") - (b - (span - ("class" "date") - (text "{{ upload.created }}")) - (text "({{ upload.what }})"))) - (div - ("class" "flex gap-2") - (button - ("class" "lowered small") - ("onclick" "trigger('ui::lightbox_open', ['/api/v1/uploads/{{ upload.id }}'])") - (text "{{ icon \"view\" }}") - (span - (text "{{ text \"general:action.view\" }}"))) - (button - ("class" "lowered small red") - ("onclick" "remove_upload('{{ upload.id }}')") - (text "{{ icon \"x\" }}") - (span - (text "{{ text \"stacks:label.remove\" }}"))))) + ("class" "inner flex flex-col gap-2") + (form + ("class" "card lowered flex flex-col gap-2") + ("onsubmit" "update_upload_alt(event, '{{ upload.id }}')") + (div + ("class" "flex flex-col gap-1") + (label ("for" "alt_{{ upload.id }}") (b (str (text "settings:label.alt_text")))) + (textarea + ("id" "alt_{{ upload.id }}") + ("name" "alt") + ("class" "w-full") + ("placeholder" "Alternative text") + (text "{{ upload.alt|safe }}"))) + + (button + (icon (text "check")) + (str (text "general:action.save")))))) (text "{% endfor %} {{ components::pagination(page=page, items=uploads|length, key=\"#/account/uploads\") }}") (script (text "globalThis.remove_upload = async (id) => { @@ -621,6 +640,26 @@ res.message, ]); }); + }; + + globalThis.update_upload_alt = async (e, id) => { + e.preventDefault(); + fetch(`/api/v1/uploads/${id}/alt`, { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + alt: e.target.alt.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); };")))))) (text "{% if config.security.enable_invite_codes -%}") @@ -1508,7 +1547,6 @@ globalThis.render_preset_lis = (preset, id) => { for (const x of preset) { - console.log(id); document.getElementById(id).innerHTML += `
  • ${x[0]}: ${x[1]}
  • `; } } diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index f67cd2c..157d6d3 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -156,9 +156,7 @@ media_theme_pref(); .replaceAll(" year ago", "y"); } - element.innerText = - pretty === undefined ? then.toLocaleDateString() : pretty; - + element.innerText = !pretty ? then.toLocaleDateString() : pretty; element.style.display = "inline-block"; } }); @@ -198,9 +196,7 @@ media_theme_pref(); .replaceAll(" year ago", "y") .replaceAll("Yesterday", "1d") || ""; - element.innerText = - pretty === undefined ? then.toLocaleDateString() : pretty; - + element.innerText = !pretty ? then.toLocaleDateString() : pretty; element.style.display = "inline-block"; } }); @@ -1145,8 +1141,15 @@ ${option.input_element_type === "textarea" ? `${option.value}` : ""} ); // lightbox - self.define("lightbox_open", (_, src) => { + self.define("lightbox_open", async (_, src) => { document.getElementById("lightbox_img").src = src; + + const data = await (await fetch(`${src}/data`)).json(); + document + .getElementById("lightbox_img") + .setAttribute("alt", data.payload.alt); + document.getElementById("lightbox_img").title = data.payload.alt; + document.getElementById("lightbox_img_a").href = src; document.getElementById("lightbox").classList.remove("hidden"); }); diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index b4b3896..d65ce53 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -152,10 +152,11 @@ pub async fn create_request( } // ... - match data.create_post(props.clone()).await { + let uploads = props.uploads.clone(); + match data.create_post(props).await { Ok(id) => { // write to uploads - for (i, upload_id) in props.uploads.iter().enumerate() { + for (i, upload_id) in uploads.iter().enumerate() { let image = match images.get(i) { Some(img) => img, None => { diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index d4e19c1..517016b 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -637,6 +637,8 @@ pub fn routes() -> Router { // uploads .route("/uploads/{id}", get(uploads::get_request)) .route("/uploads/{id}", delete(uploads::delete_request)) + .route("/uploads/{id}/data", get(uploads::get_json_request)) + .route("/uploads/{id}/alt", post(uploads::update_alt_request)) // services .route("/services", get(services::list_request)) .route("/services", post(services::create_request)) @@ -1124,3 +1126,8 @@ pub struct UpdateProductDescription { pub struct UpdateProductPrice { pub price: ProductPrice, } + +#[derive(Deserialize)] +pub struct UpdateUploadAlt { + pub alt: String, +} diff --git a/crates/app/src/routes/api/v1/products.rs b/crates/app/src/routes/api/v1/products.rs index 5812127..6a48dd3 100644 --- a/crates/app/src/routes/api/v1/products.rs +++ b/crates/app/src/routes/api/v1/products.rs @@ -1,13 +1,20 @@ use crate::{ get_user_from_token, + image::{save_webp_buffer, JsonMultipart}, routes::api::v1::{ - CreateProduct, UpdateProductDescription, UpdateProductName, UpdateProductPrice, + communities::posts::MAXIMUM_FILE_SIZE, CreateProduct, UpdateProductDescription, + UpdateProductName, UpdateProductPrice, }, State, }; use axum::{extract::Path, response::IntoResponse, Extension, Json}; use axum_extra::extract::CookieJar; -use tetratto_core::model::{products::Product, oauth, ApiReturn, Error}; +use tetratto_core::model::{ + oauth, + products::Product, + uploads::{MediaType, MediaUpload}, + ApiReturn, Error, +}; pub async fn get_request( Path(id): Path, @@ -44,7 +51,7 @@ pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> pub async fn create_request( jar: CookieJar, Extension(data): Extension, - Json(req): Json, + JsonMultipart(uploads, req): JsonMultipart, ) -> impl IntoResponse { let data = &(data.read().await).0; let user = match get_user_from_token!(jar, data, oauth::AppScope::UserCreateProducts) { @@ -52,21 +59,75 @@ pub async fn create_request( None => return Json(Error::NotAllowed.into()), }; - match data - .create_product(Product::new( - user.id, - req.name, - req.description, - req.price, - req.product_type, - )) - .await - { - Ok(x) => Json(ApiReturn { - ok: true, - message: "Product created".to_string(), - payload: x.id.to_string(), - }), + if uploads.len() > 4 { + return Json( + Error::MiscError("Too many uploads. Please use a maximum of 4".to_string()).into(), + ); + } + + let mut product = Product::new( + user.id, + req.name, + req.description, + req.price, + req.product_type, + ); + + // check sizes + for img in &uploads { + if img.len() > MAXIMUM_FILE_SIZE { + return Json(Error::FileTooLarge.into()); + } + } + + // create uploads + for _ in 0..uploads.len() { + product.uploads.push( + match data + .create_upload(MediaUpload::new(MediaType::Webp, product.owner)) + .await + { + Ok(u) => u.id, + Err(e) => return Json(e.into()), + }, + ); + } + + let product_uploads = product.uploads.clone(); + match data.create_product(product).await { + Ok(x) => { + // store uploads + for (i, upload_id) in product_uploads.iter().enumerate() { + let image = match uploads.get(i) { + Some(img) => img, + None => { + if let Err(e) = data.delete_upload(*upload_id).await { + return Json(e.into()); + } + + continue; + } + }; + + let upload = match data.get_upload_by_id(*upload_id).await { + Ok(u) => u, + Err(e) => return Json(e.into()), + }; + + if let Err(e) = + save_webp_buffer(&upload.path(&data.0.0).to_string(), image.to_vec(), None) + { + return Json(Error::MiscError(e.to_string()).into()); + } + } + + // ... + Json(ApiReturn { + ok: true, + message: "Product created".to_string(), + payload: x.id.to_string(), + }) + } Err(e) => Json(e.into()), } } diff --git a/crates/app/src/routes/api/v1/uploads.rs b/crates/app/src/routes/api/v1/uploads.rs index 0e7d6ab..02673fe 100644 --- a/crates/app/src/routes/api/v1/uploads.rs +++ b/crates/app/src/routes/api/v1/uploads.rs @@ -2,7 +2,7 @@ use std::fs::exists; use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json}; use axum_extra::extract::CookieJar; use pathbufd::PathBufD; -use crate::{get_user_from_token, State}; +use crate::{get_user_from_token, routes::api::v1::UpdateUploadAlt, State}; use super::auth::images::read_image; use tetratto_core::model::{carp::CarpGraph, oauth, uploads::MediaType, ApiReturn, Error}; @@ -52,6 +52,24 @@ pub async fn get_request( Ok(([("Content-Type", upload.what.mime())], Body::from(bytes))) } +pub async fn get_json_request( + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + + let upload = match data.get_upload_by_id(id).await { + Ok(u) => u, + Err(e) => return Json(e.into()), + }; + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(upload), + }) +} + pub async fn delete_request( jar: CookieJar, Extension(data): Extension, @@ -72,3 +90,25 @@ pub async fn delete_request( Err(e) => Json(e.into()), } } + +pub async fn update_alt_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(props): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageUploads) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data.update_upload_alt(id, &user, &props.alt).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Upload updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/pages/marketplace.rs b/crates/app/src/routes/pages/marketplace.rs index 69a2b3d..f2b6b11 100644 --- a/crates/app/src/routes/pages/marketplace.rs +++ b/crates/app/src/routes/pages/marketplace.rs @@ -22,8 +22,14 @@ pub async fn seller_settings_request( } }; + let products = match data.0.get_products_by_user(user.id).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), + }; + let lang = get_lang!(jar, data.0); - let context = initial_context(&data.0.0.0, lang, &Some(user)).await; + let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + context.insert("list", &products); // return Ok(Html( diff --git a/crates/core/src/database/drivers/sql/create_uploads.sql b/crates/core/src/database/drivers/sql/create_uploads.sql index a563080..57d4037 100644 --- a/crates/core/src/database/drivers/sql/create_uploads.sql +++ b/crates/core/src/database/drivers/sql/create_uploads.sql @@ -2,5 +2,6 @@ CREATE TABLE IF NOT EXISTS uploads ( id BIGINT NOT NULL PRIMARY KEY, created BIGINT NOT NULL, owner BIGINT NOT NULL, - what TEXT NOT NULL + what TEXT NOT NULL, + alt TEXT NOT NULL ) diff --git a/crates/core/src/database/uploads.rs b/crates/core/src/database/uploads.rs index e3b2cb5..f669c53 100644 --- a/crates/core/src/database/uploads.rs +++ b/crates/core/src/database/uploads.rs @@ -16,10 +16,11 @@ impl DataManager { created: get!(x->1(i64)) as usize, owner: get!(x->2(i64)) as usize, what: serde_json::from_str(&get!(x->3(String))).unwrap(), + alt: get!(x->4(String)), } } - auto_method!(get_upload_by_id(usize as i64)@get_upload_from_row -> "SELECT * FROM uploads WHERE id = $1" --name="upload" --returns=MediaUpload --cache-key-tmpl="atto.uploads:{}"); + auto_method!(get_upload_by_id(usize as i64)@get_upload_from_row -> "SELECT * FROM uploads WHERE id = $1" --name="upload" --returns=MediaUpload --cache-key-tmpl="atto.upload:{}"); /// Get all uploads (paginated). /// @@ -113,12 +114,13 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO uploads VALUES ($1, $2, $3, $4)", + "INSERT INTO uploads VALUES ($1, $2, $3, $4, $5)", params![ &(data.id as i64), &(data.created as i64), &(data.owner as i64), &serde_json::to_string(&data.what).unwrap().as_str(), + &data.alt, ] ); @@ -187,4 +189,6 @@ impl DataManager { self.0.1.remove(format!("atto.upload:{}", id)).await; Ok(()) } + + auto_method!(update_upload_alt(&str)@get_upload_by_id:FinePermission::MANAGE_UPLOADS; -> "UPDATE uploads SET alt = $1 WHERE id = $2" --cache-key-tmpl="atto.upload:{}"); } diff --git a/crates/core/src/model/uploads.rs b/crates/core/src/model/uploads.rs index 35165c6..9ab2d97 100644 --- a/crates/core/src/model/uploads.rs +++ b/crates/core/src/model/uploads.rs @@ -44,6 +44,7 @@ pub struct MediaUpload { pub created: usize, pub owner: usize, pub what: MediaType, + pub alt: String, } impl MediaUpload { @@ -54,6 +55,7 @@ impl MediaUpload { created: unix_epoch_timestamp(), owner, what, + alt: String::new(), } } diff --git a/sql_changes/uploads_alt.sql b/sql_changes/uploads_alt.sql new file mode 100644 index 0000000..3d6298c --- /dev/null +++ b/sql_changes/uploads_alt.sql @@ -0,0 +1,2 @@ +ALTER TABLE uploads +ADD COLUMN alt TEXT NOT NULL DEFAULT ''; From 8dfd307919ca1fc1c79bfb4dec8d739e05b745e2 Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 14 Jul 2025 16:54:55 -0400 Subject: [PATCH 57/93] fix: stripe notification spam --- .../app/src/routes/api/v1/auth/connections/stripe.rs | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) 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 91560b0..b9964f6 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -274,6 +274,15 @@ pub async fn stripe_webhook( Err(e) => return Json(e.into()), }; + if !user.permissions.check(FinePermission::SUPPORTER) { + // the user isn't currently a supporter, there's no reason to send this notification + return Json(ApiReturn { + ok: true, + message: "Acceptable".to_string(), + payload: (), + }); + } + tracing::info!( "unsubscribe (pay fail) {} (stripe: {})", user.id, @@ -303,7 +312,7 @@ pub async fn stripe_webhook( if let Err(e) = data .create_notification(Notification::new( "It seems your recent payment has failed :(".to_string(), - "No worries! Your subscription is still active and will be retried. Your supporter status will resume when you have a successful payment.\n\nIf you've cancelled your subscription, you can safely disregard this." + "No worries! Your subscription is still active and will be retried. Your supporter status will resume when you have a successful payment." .to_string(), user.id, )) From 959a1259928ab3818a5ccea300978c687a18dd15 Mon Sep 17 00:00:00 2001 From: trisua Date: Mon, 14 Jul 2025 22:05:59 -0400 Subject: [PATCH 58/93] add: change default avatar --- .../app/src/public/images/default-avatar.svg | 7 ++++ crates/app/src/routes/api/v1/products.rs | 23 ++++++++--- crates/app/src/routes/pages/marketplace.rs | 10 ++++- crates/core/src/database/common.rs | 20 ++++++++++ crates/core/src/database/products.rs | 39 +++++++++++++++++-- crates/core/src/database/stacks.rs | 6 ++- 6 files changed, 92 insertions(+), 13 deletions(-) diff --git a/crates/app/src/public/images/default-avatar.svg b/crates/app/src/public/images/default-avatar.svg index 00fa7ab..2f92a92 100644 --- a/crates/app/src/public/images/default-avatar.svg +++ b/crates/app/src/public/images/default-avatar.svg @@ -6,4 +6,11 @@ xmlns="http://www.w3.org/2000/svg" > + + + diff --git a/crates/app/src/routes/api/v1/products.rs b/crates/app/src/routes/api/v1/products.rs index 6a48dd3..05d8e9c 100644 --- a/crates/app/src/routes/api/v1/products.rs +++ b/crates/app/src/routes/api/v1/products.rs @@ -1,13 +1,20 @@ use crate::{ get_user_from_token, image::{save_webp_buffer, JsonMultipart}, - routes::api::v1::{ - communities::posts::MAXIMUM_FILE_SIZE, CreateProduct, UpdateProductDescription, - UpdateProductName, UpdateProductPrice, + routes::{ + api::v1::{ + communities::posts::MAXIMUM_FILE_SIZE, CreateProduct, UpdateProductDescription, + UpdateProductName, UpdateProductPrice, + }, + pages::PaginatedQuery, }, State, }; -use axum::{extract::Path, response::IntoResponse, Extension, Json}; +use axum::{ + extract::{Path, Query}, + response::IntoResponse, + Extension, Json, +}; use axum_extra::extract::CookieJar; use tetratto_core::model::{ oauth, @@ -31,14 +38,18 @@ pub async fn get_request( } } -pub async fn list_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { +pub async fn list_request( + jar: CookieJar, + Extension(data): Extension, + Query(props): Query, +) -> impl IntoResponse { let data = &(data.read().await).0; let user = match get_user_from_token!(jar, data, oauth::AppScope::UserReadProducts) { Some(ua) => ua, None => return Json(Error::NotAllowed.into()), }; - match data.get_products_by_user(user.id).await { + match data.get_products_by_user(user.id, 12, props.page).await { Ok(x) => Json(ApiReturn { ok: true, message: "Success".to_string(), diff --git a/crates/app/src/routes/pages/marketplace.rs b/crates/app/src/routes/pages/marketplace.rs index f2b6b11..0de9be7 100644 --- a/crates/app/src/routes/pages/marketplace.rs +++ b/crates/app/src/routes/pages/marketplace.rs @@ -1,6 +1,9 @@ use super::render_error; -use crate::{assets::initial_context, get_lang, get_user_from_token, State}; +use crate::{ + assets::initial_context, get_lang, get_user_from_token, State, routes::pages::PaginatedQuery, +}; use axum::{ + extract::Query, response::{Html, IntoResponse}, Extension, }; @@ -11,6 +14,7 @@ use tetratto_core::model::Error; pub async fn seller_settings_request( jar: CookieJar, Extension(data): Extension, + Query(props): Query, ) -> impl IntoResponse { let data = data.read().await; let user = match get_user_from_token!(jar, data.0) { @@ -22,14 +26,16 @@ pub async fn seller_settings_request( } }; - let products = match data.0.get_products_by_user(user.id).await { + let products = match data.0.get_products_by_user(user.id, 12, props.page).await { Ok(x) => x, Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), }; let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + context.insert("list", &products); + context.insert("page", &props.page); // return Ok(Html( diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index e7cd0ef..e61b565 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -75,6 +75,26 @@ impl DataManager { Ok(res.unwrap()) } + + pub async fn get_table_row_count_where(&self, table: &str, r#where: &str) -> Result { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_row!( + &conn, + &format!("SELECT COUNT(*)::int FROM {} {}", table, r#where), + params![], + |x| Ok(x.get::(0)) + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(res.unwrap()) + } } #[macro_export] diff --git a/crates/core/src/database/products.rs b/crates/core/src/database/products.rs index 5127f78..0eab9aa 100644 --- a/crates/core/src/database/products.rs +++ b/crates/core/src/database/products.rs @@ -30,7 +30,38 @@ impl DataManager { /// /// # Arguments /// * `id` - the ID of the user to fetch products for - pub async fn get_products_by_user(&self, id: usize) -> Result> { + /// * `batch` + /// * `page` + pub async fn get_products_by_user( + &self, + id: usize, + batch: usize, + page: usize, + ) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM products WHERE owner = $1 ORDER BY created DESC LIMIT {} OFFSET {}", + &[&(id as i64), &(batch as i64), &((page * batch) as i64)], + |x| { Self::get_product_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("product".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get all products by user. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch products for + pub async fn get_products_by_user_all(&self, id: usize) -> Result> { let conn = match self.0.connect().await { Ok(c) => c, Err(e) => return Err(Error::DatabaseConnection(e.to_string())), @@ -68,9 +99,11 @@ impl DataManager { let owner = self.get_user_by_id(data.owner).await?; if !owner.permissions.check(FinePermission::SUPPORTER) { - let products = self.get_products_by_user(data.owner).await?; + let products = self + .get_table_row_count_where("products", &format!("owner = {}", owner.id)) + .await? as usize; - if products.len() >= Self::MAXIMUM_FREE_PRODUCTS { + if products >= Self::MAXIMUM_FREE_PRODUCTS { return Err(Error::MiscError( "You already have the maximum number of products you can have".to_string(), )); diff --git a/crates/core/src/database/stacks.rs b/crates/core/src/database/stacks.rs index 6a64b53..cea2be9 100644 --- a/crates/core/src/database/stacks.rs +++ b/crates/core/src/database/stacks.rs @@ -165,9 +165,11 @@ impl DataManager { let owner = self.get_user_by_id(data.owner).await?; if !owner.permissions.check(FinePermission::SUPPORTER) { - let stacks = self.get_stacks_by_user(data.owner).await?; + let stacks = self + .get_table_row_count_where("stacks", &format!("owner = {}", owner.id)) + .await? as usize; - if stacks.len() >= Self::MAXIMUM_FREE_STACKS { + if stacks >= Self::MAXIMUM_FREE_STACKS { return Err(Error::MiscError( "You already have the maximum number of stacks you can have".to_string(), )); From 70ecc6f96e14380ac1de95c6f46864ee87bfa444 Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 15 Jul 2025 00:08:49 -0400 Subject: [PATCH 59/93] add: manage followers page --- crates/app/src/public/html/components.lisp | 4 +- crates/app/src/public/html/misc/requests.lisp | 5 +- .../app/src/public/html/profile/private.lisp | 3 +- .../app/src/public/html/profile/settings.lisp | 64 ++++++++++++++++++- crates/app/src/routes/api/v1/auth/social.rs | 25 ++++++++ crates/app/src/routes/api/v1/mod.rs | 4 ++ crates/app/src/routes/pages/profile.rs | 19 +++++- justfile | 1 + 8 files changed, 119 insertions(+), 6 deletions(-) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index c81ef23..1f62828 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -1798,8 +1798,8 @@ (span ("class" "notification chip") (text "{{ total }} votes")) (text "{% if not poll[2] -%}") (span - ("class" "notification chip") - (text "Expires in ") + ("class" "notification chip flex items-center gap-1") + (text "Expires in") (span ("class" "poll_date") ("data-created" "{{ poll[0].created }}") diff --git a/crates/app/src/public/html/misc/requests.lisp b/crates/app/src/public/html/misc/requests.lisp index b9700f0..8f4bdb6 100644 --- a/crates/app/src/public/html/misc/requests.lisp +++ b/crates/app/src/public/html/misc/requests.lisp @@ -62,12 +62,15 @@ ("class" "card-nest") (div ("class" "card small flex items-center gap-2") - (text "{{ icon \"user-plus\" }}") + (a + ("href" "/api/v1/auth/user/find/{{ request.id }}") + (text "{{ components::avatar(username=request.id, selector_type=\"id\") }}")) (span (text "{{ text \"requests:label.user_follow_request\" }}"))) (div ("class" "card flex flex-col gap-2") (span + ("class" "flex items-center gap-2") (text "{{ text \"requests:label.user_follow_request_message\" }}")) (div ("class" "card flex flex-wrap w-full secondary gap-2") diff --git a/crates/app/src/public/html/profile/private.lisp b/crates/app/src/public/html/profile/private.lisp index 11740c9..8bd94e9 100644 --- a/crates/app/src/public/html/profile/private.lisp +++ b/crates/app/src/public/html/profile/private.lisp @@ -35,6 +35,7 @@ (text "{{ icon \"user-plus\" }}") (span (text "{{ text \"auth:action.request_to_follow\" }}"))) + (text "{% if follow_requested -%}") (button ("onclick" "cancel_follow_user(event)") ("class" "lowered red{% if not follow_requested -%} hidden{%- endif %}") @@ -42,7 +43,7 @@ (text "{{ icon \"user-minus\" }}") (span (text "{{ text \"auth:action.cancel_follow_request\" }}"))) - (text "{% else %}") + (text "{%- endif %} {% else %}") (button ("onclick" "toggle_follow_user(event)") ("class" "lowered red") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 6be8134..046f425 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -137,6 +137,12 @@ (text "{{ icon \"rss\" }}") (span (text "{{ text \"auth:label.following\" }}"))) + (a + ("data-tab-button" "account/followers") + ("href" "#/account/followers") + (text "{{ icon \"rss\" }}") + (span + (text "{{ text \"auth:label.followers\" }}"))) (a ("data-tab-button" "account/blocks") ("href" "#/account/blocks") @@ -457,7 +463,7 @@ (text "{{ icon \"external-link\" }}") (span (text "{{ text \"requests:action.view_profile\" }}"))))) - (text "{% endfor %}")))) + (text "{% endfor %} {{ components::pagination(page=page, items=following|length, key=\"#/account/following\") }}")))) (script (text "globalThis.toggle_follow_user = async (uid) => { await trigger(\"atto::debounce\", [\"users::follow\"]); @@ -473,6 +479,62 @@ ]); }); };"))) + (div + ("class" "w-full flex flex-col gap-2 hidden") + ("data-tab" "account/followers") + (div + ("class" "card lowered flex flex-col gap-2") + (a + ("href" "#/account") + ("class" "button secondary") + (text "{{ icon \"arrow-left\" }}") + (span + (text "{{ text \"general:action.back\" }}"))) + (div + ("class" "card-nest") + (div + ("class" "card flex items-center gap-2 small") + (text "{{ icon \"rss\" }}") + (span + (text "{{ text \"auth:label.followers\" }}"))) + (div + ("class" "card flex flex-col gap-2") + (text "{% for userfollow in followers %} {% set user = userfollow[1] %}") + (div + ("class" "card secondary flex flex-wrap gap-2 items-center justify-between") + (div + ("class" "flex gap-2") + (text "{{ components::avatar(username=user.username) }} {{ components::full_username(user=user) }}")) + (div + ("class" "flex gap-2") + (button + ("class" "lowered red small") + ("onclick" "force_unfollow_me('{{ user.id }}')") + (text "{{ icon \"user-minus\" }}") + (span + (str (text "stacks:label.remove")))) + (a + ("href" "/@{{ user.username }}") + ("class" "button lowered small") + (text "{{ icon \"external-link\" }}") + (span + (text "{{ text \"requests:action.view_profile\" }}"))))) + (text "{% endfor %} {{ components::pagination(page=page, items=following|length, key=\"#/account/followers\") }}")))) + (script + (text "globalThis.force_unfollow_me = async (uid) => { + await trigger(\"atto::debounce\", [\"users::follow\"]); + + fetch(`/api/v1/auth/user/${uid}/force_unfollow_me`, { + method: \"POST\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + };"))) (div ("class" "w-full flex flex-col gap-2 hidden") ("data-tab" "account/blocks") diff --git a/crates/app/src/routes/api/v1/auth/social.rs b/crates/app/src/routes/api/v1/auth/social.rs index 88a78b5..17ca6cf 100644 --- a/crates/app/src/routes/api/v1/auth/social.rs +++ b/crates/app/src/routes/api/v1/auth/social.rs @@ -154,6 +154,31 @@ pub async fn accept_follow_request( } } +pub async fn force_unfollow_me_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageFollowing) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if let Ok(userfollow) = data.get_userfollow_by_receiver_initiator(user.id, id).await { + match data.delete_userfollow(userfollow.id, &user, false).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "User is no longer following you".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } + } else { + return Json(Error::GeneralNotFound("user follow".to_string()).into()); + } +} + /// Toggle blocking on the given user. pub async fn block_request( jar: CookieJar, diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 517016b..0fbfc40 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -293,6 +293,10 @@ pub fn routes() -> Router { "/auth/user/{id}/follow/accept", post(auth::social::accept_follow_request), ) + .route( + "/auth/user/{id}/force_unfollow_me", + post(auth::social::force_unfollow_me_request), + ) .route("/auth/user/{id}/block", post(auth::social::block_request)) .route( "/auth/user/{id}/block_ip", diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index 4d12556..11966a6 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -63,11 +63,27 @@ pub async fn settings_request( } }; + let followers = match data + .0 + .fill_userfollows_with_initiator( + data.0 + .get_userfollows_by_receiver(profile.id, 12, req.page) + .await + .unwrap_or(Vec::new()), + &None, + false, + ) + .await + { + Ok(r) => r, + Err(e) => return Err(Html(render_error(e, &jar, &data, &None).await)), + }; + let following = match data .0 .fill_userfollows_with_receiver( data.0 - .get_userfollows_by_initiator_all(profile.id) + .get_userfollows_by_initiator(profile.id, 12, req.page) .await .unwrap_or(Vec::new()), &None, @@ -138,6 +154,7 @@ pub async fn settings_request( context.insert("page", &req.page); context.insert("uploads", &uploads); context.insert("stacks", &stacks); + context.insert("followers", &followers); context.insert("following", &following); context.insert("blocks", &blocks); context.insert("stackblocks", &stackblocks); diff --git a/justfile b/justfile index a83d0c4..56aa26b 100644 --- a/justfile +++ b/justfile @@ -10,5 +10,6 @@ doc: cargo doc --document-private-items --no-deps test: + sudo pkill -e tetratto cd example && LITTLEWEB=true PORT=4119 cargo run & cd example && cargo run From 0256f38e5da6fe3107621db4222d55d67a47b491 Mon Sep 17 00:00:00 2001 From: trisua Date: Tue, 15 Jul 2025 15:59:05 -0400 Subject: [PATCH 60/93] fix: don't toggle follow when following back --- crates/app/src/public/html/post/post.lisp | 1 - crates/app/src/public/html/profile/base.lisp | 2 +- .../app/src/public/html/profile/private.lisp | 2 +- crates/app/src/public/js/me.js | 8 ++- crates/app/src/routes/api/v1/auth/social.rs | 67 ++++++++++++++++++- crates/app/src/routes/api/v1/mod.rs | 4 ++ justfile | 1 - 7 files changed, 78 insertions(+), 7 deletions(-) diff --git a/crates/app/src/public/html/post/post.lisp b/crates/app/src/public/html/post/post.lisp index b43fc82..81a16a9 100644 --- a/crates/app/src/public/html/post/post.lisp +++ b/crates/app/src/public/html/post/post.lisp @@ -71,7 +71,6 @@ ("name" "content") ("id" "content") ("placeholder" "content") - ("required" "") ("minlength" "2") ("maxlength" "4096"))) (div diff --git a/crates/app/src/public/html/profile/base.lisp b/crates/app/src/public/html/profile/base.lisp index c482033..51f8489 100644 --- a/crates/app/src/public/html/profile/base.lisp +++ b/crates/app/src/public/html/profile/base.lisp @@ -290,7 +290,7 @@ ]); fetch( - \"/api/v1/auth/user/{{ profile.id }}/follow\", + \"/api/v1/auth/user/{{ profile.id }}/follow/toggle\", { method: \"POST\", }, diff --git a/crates/app/src/public/html/profile/private.lisp b/crates/app/src/public/html/profile/private.lisp index 8bd94e9..4654298 100644 --- a/crates/app/src/public/html/profile/private.lisp +++ b/crates/app/src/public/html/profile/private.lisp @@ -81,7 +81,7 @@ (script (text "globalThis.toggle_follow_user = async (e) => { await trigger(\"atto::debounce\", [\"users::follow\"]); - fetch(\"/api/v1/auth/user/{{ profile.id }}/follow\", { + fetch(\"/api/v1/auth/user/{{ profile.id }}/follow/toggle\", { method: \"POST\", }) .then((res) => res.json()) diff --git a/crates/app/src/public/js/me.js b/crates/app/src/public/js/me.js index e7fa2d6..99fda4e 100644 --- a/crates/app/src/public/js/me.js +++ b/crates/app/src/public/js/me.js @@ -193,9 +193,13 @@ like.classList.add("green"); like.querySelector("svg").classList.add("filled"); - dislike.classList.remove("red"); + if (dislike) { + dislike.classList.remove("red"); + } } else { - dislike.classList.add("red"); + if (dislike) { + dislike.classList.add("red"); + } like.classList.remove("green"); like.querySelector("svg").classList.remove("filled"); diff --git a/crates/app/src/routes/api/v1/auth/social.rs b/crates/app/src/routes/api/v1/auth/social.rs index 17ca6cf..86a601d 100644 --- a/crates/app/src/routes/api/v1/auth/social.rs +++ b/crates/app/src/routes/api/v1/auth/social.rs @@ -17,7 +17,7 @@ use tetratto_core::model::{ }; /// Toggle following on the given user. -pub async fn follow_request( +pub async fn toggle_follow_request( jar: CookieJar, Path(id): Path, Extension(data): Extension, @@ -154,6 +154,71 @@ pub async fn accept_follow_request( } } +pub async fn follow_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageFollowing) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if data + .get_userfollow_by_initiator_receiver(user.id, id) + .await + .is_ok() + { + return Json(Error::MiscError("Already following user".to_string()).into()); + } else { + match data + .create_userfollow(UserFollow::new(user.id, id), &user, false) + .await + { + Ok(r) => { + if r == FollowResult::Followed { + if let Err(e) = data + .create_notification(Notification::new( + "Somebody has followed you!".to_string(), + format!( + "You have been followed by [@{}](/api/v1/auth/user/find/{}).", + user.username, user.id + ), + id, + )) + .await + { + return Json(e.into()); + }; + + // award achievement + if let Err(e) = data + .add_achievement(&mut user, AchievementName::FollowUser.into(), true) + .await + { + return Json(e.into()); + } + + // ... + Json(ApiReturn { + ok: true, + message: "User followed".to_string(), + payload: (), + }) + } else { + Json(ApiReturn { + ok: true, + message: "Asked to follow user".to_string(), + payload: (), + }) + } + } + Err(e) => Json(e.into()), + } + } +} + pub async fn force_unfollow_me_request( jar: CookieJar, Path(id): Path, diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 0fbfc40..d59fe26 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -285,6 +285,10 @@ pub fn routes() -> Router { .route("/auth/user/{id}/avatar", get(auth::images::avatar_request)) .route("/auth/user/{id}/banner", get(auth::images::banner_request)) .route("/auth/user/{id}/follow", post(auth::social::follow_request)) + .route( + "/auth/user/{id}/follow/toggle", + post(auth::social::toggle_follow_request), + ) .route( "/auth/user/{id}/follow/cancel", post(auth::social::cancel_follow_request), diff --git a/justfile b/justfile index 56aa26b..a83d0c4 100644 --- a/justfile +++ b/justfile @@ -10,6 +10,5 @@ doc: cargo doc --document-private-items --no-deps test: - sudo pkill -e tetratto cd example && LITTLEWEB=true PORT=4119 cargo run & cd example && cargo run From b25bda29b8da3bacc9556d464bcfc3061be53d68 Mon Sep 17 00:00:00 2001 From: trisua Date: Wed, 16 Jul 2025 18:36:56 -0400 Subject: [PATCH 61/93] fix: can_manage_posts permission --- crates/app/src/routes/pages/communities.rs | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs index 59dc982..901ec75 100644 --- a/crates/app/src/routes/pages/communities.rs +++ b/crates/app/src/routes/pages/communities.rs @@ -124,12 +124,20 @@ macro_rules! community_context_bools { ) } else { false + } || if let Some(ref ua) = $user { + ua.permissions.check(tetratto_core::model::permissions::FinePermission::MANAGE_POSTS) + } else { + false }; let can_manage_community = if let Some(ref membership) = membership { membership.role.check(tetratto_core::model::communities_permissions::CommunityPermission::MANAGE_COMMUNITY) } else { false + } || if let Some(ref ua) = $user { + ua.permissions.check(tetratto_core::model::permissions::FinePermission::MANAGE_COMMUNITIES) + } else { + false }; let can_manage_roles = if let Some(ref membership) = membership { From d1c3643574d846ba2d63994cdef4d3642730c8b8 Mon Sep 17 00:00:00 2001 From: trisua Date: Wed, 16 Jul 2025 20:18:39 -0400 Subject: [PATCH 62/93] add: user ban_reason --- crates/app/src/langs/en-US.toml | 1 + crates/app/src/macros.rs | 5 ++- crates/app/src/public/html/components.lisp | 6 +++- crates/app/src/public/html/mod/profile.lisp | 29 +++++++++++++++- crates/app/src/public/html/root.lisp | 9 +++-- crates/app/src/routes/api/v1/auth/profile.rs | 34 +++++++++++++++++-- crates/app/src/routes/api/v1/mod.rs | 9 +++++ crates/core/src/database/auth.rs | 5 ++- .../src/database/drivers/sql/create_users.sql | 5 ++- crates/core/src/model/auth.rs | 4 +++ sql_changes/users_ban_reason.sql | 2 ++ 11 files changed, 100 insertions(+), 9 deletions(-) create mode 100644 sql_changes/users_ban_reason.sql diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index de5a411..226b35c 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -198,6 +198,7 @@ version = "1.0.0" "mod_panel:label.associations" = "Associations" "mod_panel:label.invited_by" = "Invited by" "mod_panel:label.send_debug_payload" = "Send debug payload" +"mod_panel:label.ban_reason" = "Ban reason" "mod_panel:action.send" = "Send" "requests:label.requests" = "Requests" diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 2f5433d..b9faeb6 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -87,7 +87,10 @@ macro_rules! get_user_from_token { { Ok(ua) => { if ua.permissions.check_banned() { - Some(tetratto_core::model::auth::User::banned()) + let mut banned_user = tetratto_core::model::auth::User::banned(); + banned_user.ban_reason = ua.ban_reason; + + Some(banned_user) } else { Some(ua) } diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 1f62828..53ef6d3 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -102,7 +102,11 @@ ("class" "flush") ("style" "font-weight: 600") ("target" "_top") - (text "{{ self::username(user=user) }}")) + (text "{% if user.permissions|has_banned -%}") + (del ("class" "fade") (text "{{ self::username(user=user) }}")) + (text "{% else %}") + (text "{{ self::username(user=user) }}") + (text "{%- endif %}")) (text "{{ self::online_indicator(user=user) }} {% if user.is_verified -%}") (span ("title" "Verified") diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index 408b391..1d1410a 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -84,7 +84,7 @@ const ui = await ns(\"ui\"); const element = document.getElementById(\"mod_options\"); - async function profile_request(do_confirm, path, body) { + globalThis.profile_request = async (do_confirm, path, body) => { if (do_confirm) { if ( !(await trigger(\"atto::confirm\", [ @@ -273,6 +273,33 @@ ("class" "card lowered flex flex-wrap gap-2") (text "{{ components::user_plate(user=invite[0], show_menu=false) }}"))) (text "{%- endif %}") + (div + ("class" "card-nest w-full") + (div + ("class" "card small flex items-center justify-between gap-2") + (div + ("class" "flex items-center gap-2") + (icon (text "scale")) + (span + (str (text "mod_panel:label.ban_reason"))))) + (form + ("class" "card flex flex-col gap-2") + ("onsubmit" "event.preventDefault(); profile_request(false, 'ban_reason', { reason: event.target.reason.value || '' })") + (div + ("class" "flex flex-col gap-1") + (label + ("for" "title") + (str (text "mod_panel:label.ban_reason"))) + (textarea + ("type" "text") + ("name" "reason") + ("id" "reason") + ("placeholder" "ban reason") + ("minlength" "2") + (text "{{ profile.ban_reason|remove_script_tags|safe }}"))) + (button + ("class" "primary") + (str (text "general:action.save"))))) (div ("class" "card-nest w-full") (div diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp index 6730dd8..3dd15a0 100644 --- a/crates/app/src/public/html/root.lisp +++ b/crates/app/src/public/html/root.lisp @@ -70,8 +70,13 @@ (str (text "general:label.account_banned"))) (div - ("class" "card") - (str (text "general:label.account_banned_body")))))) + ("class" "card flex flex-col gap-2 no_p_margin") + (str (text "general:label.account_banned_body")) + (hr) + (span ("class" "fade") (text "The following reason was provided by a moderator:")) + (div + ("class" "card lowered w-full") + (text "{{ user.ban_reason|markdown|safe }}")))))) ; if we aren't banned, just show the page body (text "{% elif user and user.awaiting_purchase %}") diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index bb874fe..5119e0d 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -4,8 +4,9 @@ use crate::{ model::{ApiReturn, Error}, routes::api::v1::{ AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken, - UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserInviteCode, - UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, UpdateUserUsername, + UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserBanReason, + UpdateUserInviteCode, UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, + UpdateUserUsername, }, State, }; @@ -424,6 +425,35 @@ pub async fn update_user_secondary_role_request( } } +/// Update the ban reason of the given user. +/// +/// Does not support third-party grants. +pub async fn update_user_ban_reason_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if !user.permissions.check(FinePermission::MANAGE_USERS) { + return Json(Error::NotAllowed.into()); + } + + match data.update_user_ban_reason(id, &req.reason).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "User updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + /// Update the current user's last seen value. pub async fn seen_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { let data = &(data.read().await).0; diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index d59fe26..588a08e 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -322,6 +322,10 @@ pub fn routes() -> Router { "/auth/user/{id}/role/2", post(auth::profile::update_user_secondary_role_request), ) + .route( + "/auth/user/{id}/ban_reason", + post(auth::profile::update_user_ban_reason_request), + ) .route( "/auth/user/{id}", delete(auth::profile::delete_user_request), @@ -840,6 +844,11 @@ pub struct UpdateSecondaryUserRole { pub role: SecondaryPermission, } +#[derive(Deserialize)] +pub struct UpdateUserBanReason { + pub reason: String, +} + #[derive(Deserialize)] pub struct UpdateUserInviteCode { pub invite_code: String, diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index b8651ca..65c5307 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -119,6 +119,7 @@ impl DataManager { was_purchased: get!(x->25(i32)) as i8 == 1, browser_session: get!(x->26(String)), seller_data: serde_json::from_str(&get!(x->27(String)).to_string()).unwrap(), + ban_reason: get!(x->28(String)), } } @@ -275,7 +276,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28)", + "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29)", params![ &(data.id as i64), &(data.created as i64), @@ -305,6 +306,7 @@ impl DataManager { &if data.was_purchased { 1_i32 } else { 0_i32 }, &data.browser_session, &serde_json::to_string(&data.seller_data).unwrap(), + &data.ban_reason ] ); @@ -1001,6 +1003,7 @@ impl DataManager { auto_method!(update_user_invite_code(i64)@get_user_by_id -> "UPDATE users SET invite_code = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); auto_method!(update_user_browser_session(&str)@get_user_by_id -> "UPDATE users SET browser_session = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); auto_method!(update_user_seller_data(StripeSellerData)@get_user_by_id -> "UPDATE users SET seller_data = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); + auto_method!(update_user_ban_reason(&str)@get_user_by_id -> "UPDATE users SET ban_reason = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); auto_method!(get_user_by_stripe_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE stripe_id = $1" --name="user" --returns=User); auto_method!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql index 0e24753..57b2078 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -20,9 +20,12 @@ CREATE TABLE IF NOT EXISTS users ( stripe_id TEXT NOT NULL, grants TEXT NOT NULL, associated TEXT NOT NULL, + invite_code TEXT NOT NULL, secondary_permissions INT NOT NULL, achievements TEXT NOT NULL, awaiting_purchase INT NOT NULL, was_purchased INT NOT NULL, - browser_session TEXT NOT NULL + browser_session TEXT NOT NULL, + seller_data TEXT NOT NULL, + ban_reason TEXT NOT NULL ) diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index 4fb1882..a9dadf1 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -83,6 +83,9 @@ pub struct User { /// Stripe connected account information (for Tetratto marketplace). #[serde(default)] pub seller_data: StripeSellerData, + /// The reason the user was banned. + #[serde(default)] + pub ban_reason: String, } pub type UserConnections = @@ -383,6 +386,7 @@ impl User { was_purchased: false, browser_session: String::new(), seller_data: StripeSellerData::default(), + ban_reason: String::new(), } } diff --git a/sql_changes/users_ban_reason.sql b/sql_changes/users_ban_reason.sql new file mode 100644 index 0000000..15f7b6f --- /dev/null +++ b/sql_changes/users_ban_reason.sql @@ -0,0 +1,2 @@ +ALTER TABLE users +ADD COLUMN ban_reason TEXT NOT NULL DEFAULT ''; From f802a1c8abb0a150e8e4b2ed90cf33a6b92e5f00 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 17 Jul 2025 00:44:05 -0400 Subject: [PATCH 63/93] chore: bump deps --- Cargo.lock | 115 ++++++++++++++++++++++++++++----------- crates/app/Cargo.toml | 8 +-- crates/app/src/assets.rs | 4 +- crates/core/Cargo.toml | 4 +- crates/l10n/Cargo.toml | 2 +- crates/shared/Cargo.toml | 2 +- 6 files changed, 94 insertions(+), 41 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index c90535f..8ea8eb1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,9 +34,9 @@ checksum = "4aa90d7ce82d4be67b64039a3d588d38dbcc6736577de4a847025ce5b0c468d1" [[package]] name = "ammonia" -version = "4.1.0" +version = "4.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ada2ee439075a3e70b6992fce18ac4e407cd05aea9ca3f75d2c0b0c20bbb364" +checksum = "d6b346764dd0814805de8abf899fe03065bcee69bb1a4771c785817e39f3978f" dependencies = [ "cssparser", "html5ever", @@ -337,12 +337,6 @@ dependencies = [ "tokio-postgres", ] -[[package]] -name = "bberry" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ee0ee2ee1f1a6094d77ba1bf5402f8a8d66e77f6353aff728e37249b2e77458" - [[package]] name = "bit_field" version = "0.10.2" @@ -1124,12 +1118,11 @@ dependencies = [ [[package]] name = "html5ever" -version = "0.31.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953cbbe631aae7fc0a112702ad5d3aaf09da38beaf45ea84610d6e1c358f569c" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" dependencies = [ "log", - "mac", "markup5ever", "match_token", ] @@ -1576,6 +1569,17 @@ dependencies = [ "syn 2.0.101", ] +[[package]] +name = "io-uring" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b86e202f00093dcba4275d4636b93ef9dd75d025ae560d2521b45ea28ab49013" +dependencies = [ + "bitflags 2.9.1", + "cfg-if", + "libc", +] + [[package]] name = "ipnet" version = "2.11.0" @@ -1750,9 +1754,9 @@ dependencies = [ [[package]] name = "markup5ever" -version = "0.16.1" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a8096766c229e8c88a3900c9b44b7e06aa7f7343cc229158c3e58ef8f9973a" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" dependencies = [ "log", "tendril", @@ -1761,9 +1765,9 @@ dependencies = [ [[package]] name = "match_token" -version = "0.1.0" +version = "0.35.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" dependencies = [ "proc-macro2", "quote", @@ -1871,6 +1875,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "nanoneo" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1495d19c5bed5372c613d7b4a38e8093b357f4405ce38ba1de2d6586e5c892" + [[package]] name = "native-tls" version = "0.2.14" @@ -2639,9 +2649,9 @@ checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" [[package]] name = "reqwest" -version = "0.12.20" +version = "0.12.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eabf4c97d9130e2bf606614eb937e86edac8292eaa6f422f995d7e8de1eb1813" +checksum = "cbc931937e6ca3a06e3b6c0aa7841849b160a90351d6ab467a8b9b9959767531" dependencies = [ "base64 0.22.1", "bytes", @@ -2925,6 +2935,15 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_spanned" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40734c41988f7306bb04f0ecf60ec0f3f1caa34290e4e8ea471dcd3346483b83" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3191,7 +3210,7 @@ dependencies = [ "cfg-expr", "heck", "pkg-config", - "toml", + "toml 0.8.23", "version-compare", ] @@ -3255,13 +3274,13 @@ dependencies = [ "async-stripe", "axum", "axum-extra", - "bberry", "cf-turnstile", "contrasted", "emojis", "futures-util", "image", "mime_guess", + "nanoneo", "pathbufd", "regex", "reqwest", @@ -3297,7 +3316,7 @@ dependencies = [ "serde_json", "tetratto-l10n", "tetratto-shared", - "toml", + "toml 0.9.2", "totp-rs", ] @@ -3307,7 +3326,7 @@ version = "11.0.0" dependencies = [ "pathbufd", "serde", - "toml", + "toml 0.9.2", ] [[package]] @@ -3444,16 +3463,18 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.45.1" +version = "1.46.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75ef51a33ef1da925cea3e4eb122833cb377c61439ca401b770f54902b806779" +checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17" dependencies = [ "backtrace", "bytes", + "io-uring", "libc", "mio", "parking_lot", "pin-project-lite", + "slab", "socket2", "tokio-macros", "windows-sys 0.52.0", @@ -3548,11 +3569,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" dependencies = [ "serde", - "serde_spanned", - "toml_datetime", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "toml_edit", ] +[[package]] +name = "toml" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed0aee96c12fa71097902e0bb061a5e1ebd766a6636bb605ba401c45c1650eac" +dependencies = [ + "indexmap", + "serde", + "serde_spanned 1.0.0", + "toml_datetime 0.7.0", + "toml_parser", + "toml_writer", + "winnow", +] + [[package]] name = "toml_datetime" version = "0.6.11" @@ -3562,6 +3598,15 @@ dependencies = [ "serde", ] +[[package]] +name = "toml_datetime" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bade1c3e902f58d73d3f294cd7f20391c1cb2fbcb643b73566bc773971df91e3" +dependencies = [ + "serde", +] + [[package]] name = "toml_edit" version = "0.22.27" @@ -3570,17 +3615,25 @@ checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ "indexmap", "serde", - "serde_spanned", - "toml_datetime", - "toml_write", + "serde_spanned 0.6.9", + "toml_datetime 0.6.11", "winnow", ] [[package]] -name = "toml_write" -version = "0.1.2" +name = "toml_parser" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" +checksum = "97200572db069e74c512a14117b296ba0a80a30123fbbb5aa1f4a348f639ca30" +dependencies = [ + "winnow", +] + +[[package]] +name = "toml_writer" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fcc842091f2def52017664b53082ecbbeb5c7731092bad69d2c63050401dfd64" [[package]] name = "totp-rs" diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index a54b7a8..f9eb7d4 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -16,15 +16,15 @@ tower-http = { version = "0.6.6", features = [ "set-header", ] } axum = { version = "0.8.4", features = ["macros", "ws"] } -tokio = { version = "1.45.1", features = ["macros", "rt-multi-thread"] } +tokio = { version = "1.46.1", features = ["macros", "rt-multi-thread"] } axum-extra = { version = "0.10.1", features = ["cookie", "multipart"] } -ammonia = "4.1.0" +ammonia = "4.1.1" tetratto-shared = { path = "../shared" } tetratto-core = { path = "../core" } tetratto-l10n = { path = "../l10n" } image = "0.25.6" -reqwest = { version = "0.12.20", features = ["json", "stream"] } +reqwest = { version = "0.12.22", features = ["json", "stream"] } regex = "1.11.1" serde_json = "1.0.140" mime_guess = "2.0.5" @@ -41,4 +41,4 @@ async-stripe = { version = "0.41.0", features = [ ] } emojis = "0.7.0" webp = "0.3.0" -bberry = "0.2.0" +nanoneo = "0.2.0" diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index ad0f49b..aba7de9 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -1,4 +1,4 @@ -use bberry::{ +use nanoneo::{ core::element::{Element, Render}, text, read_param, }; @@ -240,7 +240,7 @@ pub(crate) async fn replace_in_html( input.to_string() } else { let start = SystemTime::now(); - let parsed = bberry::parse(input); + let parsed = nanoneo::parse(input); println!("parsed lisp in {}μs", start.elapsed().unwrap().as_micros()); if let Some(plugins) = plugins { diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index ffbd2c2..72d6481 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -6,12 +6,12 @@ edition = "2024" [dependencies] pathbufd = "0.1.4" serde = { version = "1.0.219", features = ["derive"] } -toml = "0.8.23" +toml = "0.9.2" tetratto-shared = { path = "../shared" } tetratto-l10n = { path = "../l10n" } serde_json = "1.0.140" totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"] } -reqwest = { version = "0.12.20", features = ["json"] } +reqwest = { version = "0.12.22", features = ["json"] } bitflags = "2.9.1" async-recursion = "1.1.1" md-5 = "0.10.6" diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index 9544981..d7661c2 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -9,4 +9,4 @@ license.workspace = true [dependencies] pathbufd = "0.1.4" serde = { version = "1.0.219", features = ["derive"] } -toml = "0.8.23" +toml = "0.9.2" diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 633984b..f21f611 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -7,7 +7,7 @@ repository.workspace = true license.workspace = true [dependencies] -ammonia = "4.1.0" +ammonia = "4.1.1" chrono = "0.4.41" markdown = "1.0.0" hex_fmt = "0.3.0" From 5c520f4308ae00daecaea5151f6673be7dfdef61 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 17 Jul 2025 01:30:27 -0400 Subject: [PATCH 64/93] add: app_data table --- crates/core/src/database/app_data.rs | 163 ++++++++++++++++++ crates/core/src/database/common.rs | 1 + crates/core/src/database/drivers/common.rs | 1 + .../database/drivers/sql/create_app_data.sql | 7 + crates/core/src/database/mod.rs | 1 + crates/core/src/model/apps.rs | 76 ++++++++ 6 files changed, 249 insertions(+) create mode 100644 crates/core/src/database/app_data.rs create mode 100644 crates/core/src/database/drivers/sql/create_app_data.sql diff --git a/crates/core/src/database/app_data.rs b/crates/core/src/database/app_data.rs new file mode 100644 index 0000000..b614e85 --- /dev/null +++ b/crates/core/src/database/app_data.rs @@ -0,0 +1,163 @@ +use oiseau::cache::Cache; +use crate::model::{apps::AppData, auth::User, permissions::FinePermission, Error, Result}; +use crate::{auto_method, DataManager}; + +use oiseau::PostgresRow; + +use oiseau::{execute, get, query_rows, params}; + +impl DataManager { + /// Get a [`AppData`] from an SQL row. + pub(crate) fn get_app_data_from_row(x: &PostgresRow) -> AppData { + AppData { + id: get!(x->0(i64)) as usize, + owner: get!(x->1(i64)) as usize, + app: get!(x->2(i64)) as usize, + key: get!(x->3(String)), + value: get!(x->4(String)), + } + } + + auto_method!(get_app_data_by_id(usize as i64)@get_app_data_from_row -> "SELECT * FROM app_data WHERE id = $1" --name="app_data" --returns=AppData --cache-key-tmpl="atto.app_data:{}"); + + /// Get all app_data by app. + /// + /// # Arguments + /// * `id` - the ID of the app to fetch app_data for + pub async fn get_app_data_by_app(&self, id: usize) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM app_data WHERE app = $1 ORDER BY created DESC", + &[&(id as i64)], + |x| { Self::get_app_data_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("app_data".to_string())); + } + + Ok(res.unwrap()) + } + + /// Get all app_data by owner. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch app_data for + pub async fn get_app_data_by_owner(&self, id: usize) -> Result> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = query_rows!( + &conn, + "SELECT * FROM app_data WHERE owner = $1 ORDER BY created DESC", + &[&(id as i64)], + |x| { Self::get_app_data_from_row(x) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("app_data".to_string())); + } + + Ok(res.unwrap()) + } + + const MAXIMUM_FREE_APP_DATA: usize = 5; + const MAXIMUM_DATA_SIZE: usize = 205_000; + + /// Create a new app_data in the database. + /// + /// # Arguments + /// * `data` - a mock [`AppData`] object to insert + pub async fn create_app_data(&self, data: AppData) -> Result { + let app = self.get_app_by_id(data.app).await?; + + // check values + if data.key.len() < 2 { + return Err(Error::DataTooShort("key".to_string())); + } else if data.key.len() > 32 { + return Err(Error::DataTooLong("key".to_string())); + } + + if data.value.len() < 2 { + return Err(Error::DataTooShort("key".to_string())); + } else if data.value.len() > Self::MAXIMUM_DATA_SIZE { + return Err(Error::DataTooLong("key".to_string())); + } + + // check number of app_data + let owner = self.get_user_by_id(app.owner).await?; + + if !owner.permissions.check(FinePermission::SUPPORTER) { + let app_data = self + .get_table_row_count_where("app_data", &format!("app = {}", data.app)) + .await? as usize; + + if app_data >= Self::MAXIMUM_FREE_APP_DATA { + return Err(Error::MiscError( + "You already have the maximum number of app_data you can have".to_string(), + )); + } + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO app_data VALUES ($1, $2, $3, $4, $5)", + params![ + &(data.id as i64), + &(data.owner as i64), + &(data.app as i64), + &data.key, + &data.value + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn delete_app_data(&self, id: usize, user: &User) -> Result<()> { + let app_data = self.get_app_data_by_id(id).await?; + let app = self.get_app_by_id(app_data.app).await?; + + // check user permission + if ((user.id != app.owner) | (user.id != app_data.owner)) + && !user.permissions.check(FinePermission::MANAGE_APPS) + { + 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 app_data WHERE id = $1", &[&(id as i64)]); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.0.1.remove(format!("atto.app_data:{}", id)).await; + Ok(()) + } + + auto_method!(update_app_data_key(&str)@get_app_data_by_id:FinePermission::MANAGE_APPS; -> "UPDATE app_data SET k = $1 WHERE id = $2" --cache-key-tmpl="atto.app_data:{}"); + auto_method!(update_app_data_value(&str)@get_app_data_by_id:FinePermission::MANAGE_APPS; -> "UPDATE app_data SET v = $1 WHERE id = $2" --cache-key-tmpl="atto.app_data:{}"); +} diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index e61b565..075d0f7 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -43,6 +43,7 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_DOMAINS).unwrap(); execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap(); execute!(&conn, common::CREATE_TABLE_PRODUCTS).unwrap(); + execute!(&conn, common::CREATE_TABLE_APP_DATA).unwrap(); self.0 .1 diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index 7bee30a..2535f43 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -30,3 +30,4 @@ pub const CREATE_TABLE_INVITE_CODES: &str = include_str!("./sql/create_invite_co pub const CREATE_TABLE_DOMAINS: &str = include_str!("./sql/create_domains.sql"); pub const CREATE_TABLE_SERVICES: &str = include_str!("./sql/create_services.sql"); pub const CREATE_TABLE_PRODUCTS: &str = include_str!("./sql/create_products.sql"); +pub const CREATE_TABLE_APP_DATA: &str = include_str!("./sql/create_app_data.sql"); diff --git a/crates/core/src/database/drivers/sql/create_app_data.sql b/crates/core/src/database/drivers/sql/create_app_data.sql new file mode 100644 index 0000000..28a8379 --- /dev/null +++ b/crates/core/src/database/drivers/sql/create_app_data.sql @@ -0,0 +1,7 @@ +CREATE TABLE IF NOT EXISTS app_data ( + id BIGINT NOT NULL PRIMARY KEY, + owner BIGINT NOT NULL, + app BIGINT NOT NULL, + k TEXT NOT NULL, + v TEXT NOT NULL +) diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index 730c54a..a4cdb3d 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -1,3 +1,4 @@ +mod app_data; mod apps; mod audit_log; mod auth; diff --git a/crates/core/src/model/apps.rs b/crates/core/src/model/apps.rs index 713df48..8f90899 100644 --- a/crates/core/src/model/apps.rs +++ b/crates/core/src/model/apps.rs @@ -1,3 +1,5 @@ +use std::fmt::Display; + use serde::{Deserialize, Serialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; use crate::model::oauth::AppScope; @@ -100,3 +102,77 @@ impl ThirdPartyApp { } } } + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct AppData { + pub id: usize, + pub owner: usize, + pub app: usize, + pub key: String, + pub value: String, +} + +impl AppData { + /// Create a new [`AppData`]. + pub fn new(owner: usize, app: usize, key: String, value: String) -> Self { + Self { + id: Snowflake::new().to_string().parse::().unwrap(), + owner, + app, + key, + value, + } + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum AppDataSelectQuery { + Like(String, String), +} + +impl Display for AppDataSelectQuery { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&match self { + Self::Like(k, v) => format!("%\"{k}\":\"{v}\"%"), + }) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub enum AppDataSelectMode { + /// Select a single row. + One, + /// Select multiple rows at once. + /// + /// `(order by top level key, limit, offset)` + Many(String, usize, usize), +} + +impl Display for AppDataSelectMode { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&match self { + Self::One => "LIMIT 1".to_string(), + Self::Many(order_by_top_level_key, limit, offset) => { + format!( + "ORDER BY v::jsonb->>'{order_by_top_level_key}' LIMIT {limit} OFFSET {offset}" + ) + } + }) + } +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct AppDataQuery { + pub app: usize, + pub query: AppDataSelectQuery, + pub mode: AppDataSelectMode, +} + +impl Display for AppDataQuery { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&format!( + "SELECT * FROM app_data WHERE app = {} AND v LIKE $1 {}", + self.app, self.mode + )) + } +} From f423daf2fc35f5fed20a79e7b04b9ac7f9fae354 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 17 Jul 2025 13:34:10 -0400 Subject: [PATCH 65/93] add: app_data api --- crates/app/src/langs/en-US.toml | 3 + crates/app/src/macros.rs | 17 +++ crates/app/src/public/css/style.css | 18 ++- .../public/html/communities/create_post.lisp | 1 - .../app/src/public/html/communities/list.lisp | 1 - .../src/public/html/communities/question.lisp | 1 - .../src/public/html/communities/search.lisp | 1 - .../src/public/html/communities/settings.lisp | 5 - crates/app/src/public/html/components.lisp | 2 - crates/app/src/public/html/developer/app.lisp | 74 ++++++++-- .../app/src/public/html/developer/home.lisp | 1 - crates/app/src/public/html/forge/home.lisp | 1 - crates/app/src/public/html/journals/app.lisp | 1 - .../src/public/html/littleweb/domains.lisp | 1 - .../src/public/html/littleweb/services.lisp | 1 - .../src/public/html/misc/achievements.lisp | 2 +- crates/app/src/public/html/misc/requests.lisp | 1 - .../app/src/public/html/mod/file_report.lisp | 1 - crates/app/src/public/html/mod/profile.lisp | 2 +- crates/app/src/public/html/mod/warnings.lisp | 1 - crates/app/src/public/html/post/post.lisp | 2 - .../app/src/public/html/profile/settings.lisp | 6 - crates/app/src/public/html/stacks/list.lisp | 1 - crates/app/src/public/html/stacks/manage.lisp | 1 - crates/app/src/routes/api/v1/app_data.rs | 136 ++++++++++++++++++ crates/app/src/routes/api/v1/apps.rs | 31 ++++ crates/app/src/routes/api/v1/mod.rs | 32 ++++- crates/app/src/routes/pages/developer.rs | 6 +- crates/core/src/database/app_data.rs | 65 +++++---- crates/core/src/database/apps.rs | 26 +++- crates/core/src/database/channels.rs | 1 - crates/core/src/database/mod.rs | 2 +- crates/core/src/model/apps.rs | 38 ++++- crates/core/src/model/mod.rs | 2 + crates/core/src/model/permissions.rs | 1 + crates/shared/src/hash.rs | 12 ++ sql_changes/apps_api_key.sql | 2 + sql_changes/apps_data_used.sql | 2 + 38 files changed, 410 insertions(+), 91 deletions(-) create mode 100644 crates/app/src/routes/api/v1/app_data.rs create mode 100644 sql_changes/apps_api_key.sql create mode 100644 sql_changes/apps_data_used.sql diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 226b35c..243b70f 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -253,6 +253,9 @@ version = "1.0.0" "developer:label.manage_scopes" = "Manage scopes" "developer:label.scopes" = "Scopes" "developer:label.guides_and_help" = "Guides & help" +"developer:label.secret_key" = "Secret key" +"developer:label.roll_key" = "Roll key" +"developer:label.data_usage" = "Data usage" "developer:action.delete" = "Delete app" "developer:action.authorize" = "Authorize" diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index b9faeb6..0edafbb 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -419,3 +419,20 @@ macro_rules! ignore_users_gen { .concat() }; } + +#[macro_export] +macro_rules! get_app_from_key { + ($db:ident, $jar:ident) => { + if let Some(token) = $jar.get("Atto-Secret-Key") { + match $db + .get_app_by_api_key(&token.to_string().replace("Atto-Secret-Key=", "")) + .await + { + Ok(x) => Some(x), + Err(_) => None, + } + } else { + None + } + }; +} diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index f8a4a8a..ab6a09d 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -404,7 +404,7 @@ select:focus { .poll_bar { background-color: var(--color-primary); border-radius: var(--radius); - height: 25px; + height: 24px; } .poll_option { @@ -413,6 +413,22 @@ select:focus { overflow-wrap: anywhere; } +.progress_bar { + background: var(--color-super-lowered); + border-radius: var(--circle); + position: relative; + overflow: hidden; + height: 14px; +} + +.progress_bar .poll_bar { + border-radius: var(--circle); + height: 14px; + border-top-right-radius: 0; + border-bottom-right-radius: 0; + position: absolute; +} + input[type="checkbox"] { --color: #c9b1bc; appearance: none; diff --git a/crates/app/src/public/html/communities/create_post.lisp b/crates/app/src/public/html/communities/create_post.lisp index 0b7cf19..1c8ce2c 100644 --- a/crates/app/src/public/html/communities/create_post.lisp +++ b/crates/app/src/public/html/communities/create_post.lisp @@ -159,7 +159,6 @@ (text "{{ icon \"notepad-text-dashed\" }}")) (text "{%- endif %} {%- endif %}") (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")))))) (text "{% if not quoting -%}") (script diff --git a/crates/app/src/public/html/communities/list.lisp b/crates/app/src/public/html/communities/list.lisp index 186d4f9..cf1cb48 100644 --- a/crates/app/src/public/html/communities/list.lisp +++ b/crates/app/src/public/html/communities/list.lisp @@ -29,7 +29,6 @@ ("minlength" "2") ("maxlength" "32"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")))) (text "{% if list|length >= 4 -%} {{ components::supporter_ad(body=\"Become a supporter to create up to 10 communities!\") }} {%- endif %} {%- endif %}") (div diff --git a/crates/app/src/public/html/communities/question.lisp b/crates/app/src/public/html/communities/question.lisp index 975e055..4468d25 100644 --- a/crates/app/src/public/html/communities/question.lisp +++ b/crates/app/src/public/html/communities/question.lisp @@ -39,7 +39,6 @@ ("class" "flex gap-2") (text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {% endif %}") (button - ("class" "primary") (text "{{ text \"requests:label.answer\" }}"))))) (text "{%- endif %}") (div diff --git a/crates/app/src/public/html/communities/search.lisp b/crates/app/src/public/html/communities/search.lisp index 642d214..a985e91 100644 --- a/crates/app/src/public/html/communities/search.lisp +++ b/crates/app/src/public/html/communities/search.lisp @@ -28,7 +28,6 @@ ("maxlength" "32") ("value" "{{ text }}"))) (button - ("class" "primary") (text "{{ text \"dialog:action.continue\" }}")))) (div ("class" "card-nest") diff --git a/crates/app/src/public/html/communities/settings.lisp b/crates/app/src/public/html/communities/settings.lisp index 4213cb9..fa5ddcf 100644 --- a/crates/app/src/public/html/communities/settings.lisp +++ b/crates/app/src/public/html/communities/settings.lisp @@ -135,7 +135,6 @@ ("required" "") ("minlength" "2"))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}")))))) @@ -190,7 +189,6 @@ ("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif") ("class" "w-content")) (button - ("class" "primary") (text "{{ icon \"check\" }}")))) (div ("class" "card-nest") @@ -213,7 +211,6 @@ ("accept" "image/png,image/jpeg,image/avif,image/webp") ("class" "w-content")) (button - ("class" "primary") (text "{{ icon \"check\" }}"))) (span ("class" "fade") @@ -245,7 +242,6 @@ ("required" "") ("minlength" "18"))) (button - ("class" "primary") (text "{{ text \"communities:action.select\" }}"))))) (div ("class" "card flex flex-col gap-2 w-full") @@ -296,7 +292,6 @@ ("minlength" "2") ("maxlength" "32"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")))) (text "{% for channel in channels %}") (div diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 53ef6d3..a83ad44 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -779,7 +779,6 @@ (div ("class" "flex gap-2") (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")) (text "{% if drawing_enabled -%}") @@ -1879,7 +1878,6 @@ ("id" "join_or_leave") (text "{% if not is_owner -%} {% if not is_joined -%} {% if not is_pending %}") (button - ("class" "primary") ("onclick" "join_community()") (text "{{ icon \"circle-plus\" }}") (span diff --git a/crates/app/src/public/html/developer/app.lisp b/crates/app/src/public/html/developer/app.lisp index 2850ef5..d01e9de 100644 --- a/crates/app/src/public/html/developer/app.lisp +++ b/crates/app/src/public/html/developer/app.lisp @@ -10,11 +10,27 @@ (div ("id" "manage_fields") ("class" "card lowered flex flex-col gap-2") + (div + ("class" "card-nest") + (div + ("class" "card small flex items-center gap-2") + (icon (text "database")) + (b (str (text "developer:label.data_usage")))) + (div + ("class" "card flex flex-col gap-2") + (p ("class" "fade") (text "App data keys are not included in this metric, only stored values count towards your limit.")) + (text "{% set percentage = (data_limit / app.data_used) * 100 %}") + (div ("class" "progress_bar") (div ("class" "poll_bar") ("style" "width: {{ percentage }}%"))) + (div + ("class" "w-full flex justify-between items-center") + (span (text "{{ app.data_used|filesizeformat }}")) + (span (text "{{ data_limit|filesizeformat }}"))))) (text "{% if is_helper -%}") (div ("class" "card-nest") (div - ("class" "card small") + ("class" "card small flex items-center gap-2") + (icon (text "infinity")) (b (str (text "developer:label.change_quota_status")))) (div ("class" "card") @@ -32,7 +48,8 @@ (div ("class" "card-nest") (div - ("class" "card small") + ("class" "card small flex items-center gap-2") + (icon (text "pencil")) (b (str (text "developer:label.change_title")))) (form ("class" "card flex flex-col gap-2") @@ -50,14 +67,14 @@ ("required" "") ("minlength" "2"))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}"))))) (div ("class" "card-nest") (div - ("class" "card small") + ("class" "card small flex items-center gap-2") + (icon (text "house")) (b (str (text "developer:label.change_homepage")))) (form ("class" "card flex flex-col gap-2") @@ -75,14 +92,14 @@ ("required" "") ("minlength" "2"))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}"))))) (div ("class" "card-nest") (div - ("class" "card small") + ("class" "card small flex items-center gap-2") + (icon (text "goal")) (b (str (text "developer:label.change_redirect")))) (form ("class" "card flex flex-col gap-2") @@ -100,14 +117,14 @@ ("required" "") ("minlength" "2"))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}"))))) (div ("class" "card-nest") (div - ("class" "card small") + ("class" "card small flex items-center gap-2") + (icon (text "telescope")) (b (str (text "developer:label.manage_scopes")))) (form ("class" "card flex flex-col gap-2") @@ -140,10 +157,22 @@ (icon (text "external-link")) (text "Docs")))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span - (text "{{ text \"general:action.save\" }}")))))) + (text "{{ text \"general:action.save\" }}"))))) + (div + ("class" "card-nest") + (div + ("class" "card small flex items-center gap-2") + (icon (text "rotate-ccw-key")) + (b (str (text "developer:label.secret_key")))) + (div + ("class" "card flex flex-col gap-2") + (p ("class" "fade") (text "Your app's API key can only be seen once, so don't lose it. Rolling the key will invalidate the old one.")) + (pre (code ("id" "new_key"))) + (button + ("onclick" "roll_key()") + (str (text "developer:label.roll_key")))))) (div ("class" "card flex flex-col gap-2") (ul @@ -323,6 +352,31 @@ }); }; + globalThis.roll_key = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/apps/{{ app.id }}/roll\", { + method: \"POST\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + document.getElementById(\"new_key\").innerText = res.payload; + } + }); + }; + globalThis.delete_app = async () => { if ( !(await trigger(\"atto::confirm\", [ diff --git a/crates/app/src/public/html/developer/home.lisp b/crates/app/src/public/html/developer/home.lisp index aefd55d..fb00c7e 100644 --- a/crates/app/src/public/html/developer/home.lisp +++ b/crates/app/src/public/html/developer/home.lisp @@ -57,7 +57,6 @@ ("minlength" "2") ("maxlength" "32"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")))) ; app listing diff --git a/crates/app/src/public/html/forge/home.lisp b/crates/app/src/public/html/forge/home.lisp index a83c545..c295066 100644 --- a/crates/app/src/public/html/forge/home.lisp +++ b/crates/app/src/public/html/forge/home.lisp @@ -30,7 +30,6 @@ ("minlength" "2") ("maxlength" "32"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")))) (text "{% else %}") (text "{{ components::supporter_ad(body=\"Become a supporter to create forges!\") }}") diff --git a/crates/app/src/public/html/journals/app.lisp b/crates/app/src/public/html/journals/app.lisp index c6ed985..71fbd4d 100644 --- a/crates/app/src/public/html/journals/app.lisp +++ b/crates/app/src/public/html/journals/app.lisp @@ -253,7 +253,6 @@ ("required" "") ("minlength" "2"))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}"))))))) diff --git a/crates/app/src/public/html/littleweb/domains.lisp b/crates/app/src/public/html/littleweb/domains.lisp index e3a6c10..c79ab3e 100644 --- a/crates/app/src/public/html/littleweb/domains.lisp +++ b/crates/app/src/public/html/littleweb/domains.lisp @@ -59,7 +59,6 @@ (option ("value" "{{ tld }}") (text ".{{ tld|lower }}")) (text "{%- endfor %}"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")) (details diff --git a/crates/app/src/public/html/littleweb/services.lisp b/crates/app/src/public/html/littleweb/services.lisp index 3399685..261b006 100644 --- a/crates/app/src/public/html/littleweb/services.lisp +++ b/crates/app/src/public/html/littleweb/services.lisp @@ -45,7 +45,6 @@ ("minlength" "2") ("maxlength" "32"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")))) (text "{%- endif %}") (div diff --git a/crates/app/src/public/html/misc/achievements.lisp b/crates/app/src/public/html/misc/achievements.lisp index 429c924..93f895e 100644 --- a/crates/app/src/public/html/misc/achievements.lisp +++ b/crates/app/src/public/html/misc/achievements.lisp @@ -17,7 +17,7 @@ (p (text "You'll find out what each achievement is when you get it, so look around!")) (hr) (span (b (text "Your progress: ")) (text "{{ percentage|round(method=\"floor\", precision=2) }}%")) - (div ("class" "poll_bar") ("style" "width: {{ percentage }}%")))) + (div ("class" "progress_bar") (div ("class" "poll_bar") ("style" "width: {{ percentage }}%"))))) (div ("class" "card-nest") diff --git a/crates/app/src/public/html/misc/requests.lisp b/crates/app/src/public/html/misc/requests.lisp index 8f4bdb6..f49b6f4 100644 --- a/crates/app/src/public/html/misc/requests.lisp +++ b/crates/app/src/public/html/misc/requests.lisp @@ -132,7 +132,6 @@ (text "{{ text \"auth:action.ip_block\" }}"))) (button - ("class" "primary") (text "{{ text \"requests:label.answer\" }}"))))) (text "{% endfor %}"))) diff --git a/crates/app/src/public/html/mod/file_report.lisp b/crates/app/src/public/html/mod/file_report.lisp index 39891a7..39f7669 100644 --- a/crates/app/src/public/html/mod/file_report.lisp +++ b/crates/app/src/public/html/mod/file_report.lisp @@ -28,7 +28,6 @@ ("required" "") ("minlength" "16"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}"))))) (script diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index 1d1410a..6f07c93 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -298,7 +298,6 @@ ("minlength" "2") (text "{{ profile.ban_reason|remove_script_tags|safe }}"))) (button - ("class" "primary") (str (text "general:action.save"))))) (div ("class" "card-nest w-full") @@ -396,6 +395,7 @@ MANAGE_DOMAINS: 1 << 2, MANAGE_SERVICES: 1 << 3, MANAGE_PRODUCTS: 1 << 4, + DEVELOPER_PASS: 1 << 5, }, \"secondary_role\", \"add_permission_to_secondary_role\", diff --git a/crates/app/src/public/html/mod/warnings.lisp b/crates/app/src/public/html/mod/warnings.lisp index 35c384e..203fa3e 100644 --- a/crates/app/src/public/html/mod/warnings.lisp +++ b/crates/app/src/public/html/mod/warnings.lisp @@ -37,7 +37,6 @@ ("minlength" "2") ("maxlength" "4096"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")))) (div ("class" "card-nest") diff --git a/crates/app/src/public/html/post/post.lisp b/crates/app/src/public/html/post/post.lisp index 81a16a9..8013461 100644 --- a/crates/app/src/public/html/post/post.lisp +++ b/crates/app/src/public/html/post/post.lisp @@ -80,7 +80,6 @@ ("class" "flex gap-2") (text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {% endif %}") (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}"))))) (text "{%- endif %}") (div @@ -279,7 +278,6 @@ ("class" "flex gap-2") (text "{{ components::emoji_picker(element_id=\"new_content\", render_dialog=false) }}") (button - ("class" "primary") (text "{{ text \"general:action.save\" }}"))))) (script (text "async function edit_post_from_form(e) { diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 046f425..c4169a6 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -276,7 +276,6 @@ ("required" "") ("minlength" "2"))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}")))))) @@ -305,7 +304,6 @@ ("minlength" "6") ("autocomplete" "off"))) (button - ("class" "primary") (text "{{ icon \"trash\" }}") (span (text "{{ text \"general:action.delete\" }}"))))) @@ -419,7 +417,6 @@ ("minlength" "6") ("autocomplete" "off"))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}"))))))))) @@ -908,7 +905,6 @@ ("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif") ("class" "w-content")) (button - ("class" "primary") (text "{{ icon \"check\" }}"))) (span ("class" "fade") @@ -936,7 +932,6 @@ ("accept" "image/png,image/jpeg,image/avif,image/webp,image/gif") ("class" "w-content")) (button - ("class" "primary") (text "{{ icon \"check\" }}"))) (span ("class" "fade") @@ -1054,7 +1049,6 @@ ("class" "card w-full flex flex-wrap gap-2") ("ui_ident" "import_export") (button - ("class" "primary") ("onclick" "import_theme_settings()") (text "{{ icon \"upload\" }}") (span diff --git a/crates/app/src/public/html/stacks/list.lisp b/crates/app/src/public/html/stacks/list.lisp index 50246ef..6381881 100644 --- a/crates/app/src/public/html/stacks/list.lisp +++ b/crates/app/src/public/html/stacks/list.lisp @@ -29,7 +29,6 @@ ("minlength" "2") ("maxlength" "32"))) (button - ("class" "primary") (text "{{ text \"communities:action.create\" }}")))) (text "{%- endif %}") (div diff --git a/crates/app/src/public/html/stacks/manage.lisp b/crates/app/src/public/html/stacks/manage.lisp index 450c027..ecd892c 100644 --- a/crates/app/src/public/html/stacks/manage.lisp +++ b/crates/app/src/public/html/stacks/manage.lisp @@ -114,7 +114,6 @@ ("required" "") ("minlength" "2"))) (button - ("class" "primary") (text "{{ icon \"check\" }}") (span (text "{{ text \"general:action.save\" }}")))))) diff --git a/crates/app/src/routes/api/v1/app_data.rs b/crates/app/src/routes/api/v1/app_data.rs new file mode 100644 index 0000000..c074c8f --- /dev/null +++ b/crates/app/src/routes/api/v1/app_data.rs @@ -0,0 +1,136 @@ +use crate::{ + get_app_from_key, + routes::api::v1::{InsertAppData, QueryAppData, UpdateAppDataValue}, + State, +}; +use axum::{Extension, Json, extract::Path, response::IntoResponse}; +use axum_extra::extract::CookieJar; +use tetratto_core::model::{ + apps::{AppData, AppDataQuery}, + ApiReturn, Error, +}; + +pub async fn query_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let app = match get_app_from_key!(data, jar) { + Some(x) => x, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .query_app_data(AppDataQuery { + app: app.id, + query: req.query, + mode: req.mode, + }) + .await + { + Ok(x) => Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(x), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn create_request( + jar: CookieJar, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let app = match get_app_from_key!(data, jar) { + Some(x) => x, + None => return Json(Error::NotAllowed.into()), + }; + + let owner = match data.get_user_by_id(app.owner).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + // check size + let new_size = app.data_used + req.value.len(); + if new_size > AppData::user_limit(&owner) { + return Json(Error::AppHitStorageLimit.into()); + } + + // ... + match data + .create_app_data(AppData::new(app.id, req.key, req.value)) + .await + { + Ok(s) => Json(ApiReturn { + ok: true, + message: "App created".to_string(), + payload: s.id.to_string(), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn update_value_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let app = match get_app_from_key!(data, jar) { + Some(x) => x, + None => return Json(Error::NotAllowed.into()), + }; + + let owner = match data.get_user_by_id(app.owner).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + let app_data = match data.get_app_data_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + // check size + let size_without = app.data_used - app_data.value.len(); + let new_size = size_without + req.value.len(); + + if new_size > AppData::user_limit(&owner) { + return Json(Error::AppHitStorageLimit.into()); + } + + // ... + match data.update_app_data_value(id, &req.value).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Data updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn delete_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + if get_app_from_key!(data, jar).is_none() { + return Json(Error::NotAllowed.into()); + } + + match data.delete_app_data(id).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Data deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/apps.rs b/crates/app/src/routes/api/v1/apps.rs index c4e2809..2b1a314 100644 --- a/crates/app/src/routes/api/v1/apps.rs +++ b/crates/app/src/routes/api/v1/apps.rs @@ -239,3 +239,34 @@ pub async fn grant_request( Err(e) => Json(e.into()), } } + +pub async fn roll_api_key_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let app = match data.get_app_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if user.id != app.owner { + return Json(Error::NotAllowed.into()); + } + + let new_key = tetratto_shared::hash::random_id_salted_len(32); + match data.update_app_api_key(id, &new_key).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "App updated".to_string(), + payload: Some(new_key), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 588a08e..8b276dd 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -1,3 +1,4 @@ +pub mod app_data; pub mod apps; pub mod auth; pub mod channels; @@ -19,9 +20,9 @@ use axum::{ routing::{any, delete, get, post, put}, Router, }; -use serde::Deserialize; +use serde::{Deserialize}; use tetratto_core::model::{ - apps::AppQuota, + apps::{AppDataSelectMode, AppDataSelectQuery, AppQuota}, auth::AchievementName, communities::{ CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess, @@ -32,7 +33,7 @@ use tetratto_core::model::{ littleweb::{DomainData, DomainTld, ServiceFsEntry}, oauth::AppScope, permissions::{FinePermission, SecondaryPermission}, - products::{ProductType, ProductPrice}, + products::{ProductPrice, ProductType}, reactions::AssetType, stacks::{StackMode, StackPrivacy, StackSort}, }; @@ -419,6 +420,7 @@ pub fn routes() -> Router { ) // apps .route("/apps", post(apps::create_request)) + .route("/apps/{id}", delete(apps::delete_request)) .route("/apps/{id}/title", post(apps::update_title_request)) .route("/apps/{id}/homepage", post(apps::update_homepage_request)) .route("/apps/{id}/redirect", post(apps::update_redirect_request)) @@ -427,8 +429,13 @@ pub fn routes() -> Router { post(apps::update_quota_status_request), ) .route("/apps/{id}/scopes", post(apps::update_scopes_request)) - .route("/apps/{id}", delete(apps::delete_request)) .route("/apps/{id}/grant", post(apps::grant_request)) + .route("/apps/{id}/roll", post(apps::roll_api_key_request)) + // app data + .route("/app_data", post(app_data::create_request)) + .route("/app_data/query", post(app_data::query_request)) + .route("/app_data/{id}", delete(app_data::delete_request)) + .route("/app_data/{id}/value", post(app_data::update_value_request)) // warnings .route("/warnings/{id}", get(auth::user_warnings::get_request)) .route("/warnings/{id}", post(auth::user_warnings::create_request)) @@ -1148,3 +1155,20 @@ pub struct UpdateProductPrice { pub struct UpdateUploadAlt { pub alt: String, } + +#[derive(Deserialize)] +pub struct UpdateAppDataValue { + pub value: String, +} + +#[derive(Deserialize)] +pub struct InsertAppData { + pub key: String, + pub value: String, +} + +#[derive(Deserialize)] +pub struct QueryAppData { + pub query: AppDataSelectQuery, + pub mode: AppDataSelectMode, +} diff --git a/crates/app/src/routes/pages/developer.rs b/crates/app/src/routes/pages/developer.rs index 76d94fe..de4c4e1 100644 --- a/crates/app/src/routes/pages/developer.rs +++ b/crates/app/src/routes/pages/developer.rs @@ -6,7 +6,7 @@ use axum::{ Extension, }; use axum_extra::extract::CookieJar; -use tetratto_core::model::{permissions::FinePermission, Error}; +use tetratto_core::model::{apps::AppData, permissions::FinePermission, Error}; /// `/developer` pub async fn home_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse { @@ -62,9 +62,13 @@ pub async fn app_request( )); } + let data_limit = AppData::user_limit(&user); + let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; + context.insert("app", &app); + context.insert("data_limit", &data_limit); // return Ok(Html(data.1.render("developer/app.html", &context).unwrap())) diff --git a/crates/core/src/database/app_data.rs b/crates/core/src/database/app_data.rs index b614e85..6ea4f63 100644 --- a/crates/core/src/database/app_data.rs +++ b/crates/core/src/database/app_data.rs @@ -1,17 +1,17 @@ use oiseau::cache::Cache; -use crate::model::{apps::AppData, auth::User, permissions::FinePermission, Error, Result}; +use crate::model::apps::{AppDataQuery, AppDataQueryResult, AppDataSelectMode, AppDataSelectQuery}; +use crate::model::{apps::AppData, permissions::FinePermission, Error, Result}; use crate::{auto_method, DataManager}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_rows, params}; +pub const FREE_DATA_LIMIT: usize = 512_000; +pub const PASS_DATA_LIMIT: usize = 5_242_880; impl DataManager { /// Get a [`AppData`] from an SQL row. pub(crate) fn get_app_data_from_row(x: &PostgresRow) -> AppData { AppData { id: get!(x->0(i64)) as usize, - owner: get!(x->1(i64)) as usize, app: get!(x->2(i64)) as usize, key: get!(x->3(String)), value: get!(x->4(String)), @@ -48,24 +48,39 @@ impl DataManager { /// /// # Arguments /// * `id` - the ID of the user to fetch app_data for - pub async fn get_app_data_by_owner(&self, id: usize) -> Result> { + pub async fn query_app_data(&self, query: AppDataQuery) -> Result { let conn = match self.0.connect().await { Ok(c) => c, Err(e) => return Err(Error::DatabaseConnection(e.to_string())), }; - let res = query_rows!( - &conn, - "SELECT * FROM app_data WHERE owner = $1 ORDER BY created DESC", - &[&(id as i64)], - |x| { Self::get_app_data_from_row(x) } + let query_str = query.to_string().replace( + "%q%", + &match query.query { + AppDataSelectQuery::Like(_, _) => format!("v LIKE $1"), + }, ); - if res.is_err() { - return Err(Error::GeneralNotFound("app_data".to_string())); - } + let res = match query.mode { + AppDataSelectMode::One => AppDataQueryResult::One( + match query_row!(&conn, &query_str, params![&query.query.to_string()], |x| { + Ok(Self::get_app_data_from_row(x)) + }) { + Ok(x) => x, + Err(_) => return Err(Error::GeneralNotFound("app_data".to_string())), + }, + ), + AppDataSelectMode::Many(_, _, _) => AppDataQueryResult::Many( + match query_rows!(&conn, &query_str, params![&query.query.to_string()], |x| { + Self::get_app_data_from_row(x) + }) { + Ok(x) => x, + Err(_) => return Err(Error::GeneralNotFound("app_data".to_string())), + }, + ), + }; - Ok(res.unwrap()) + Ok(res) } const MAXIMUM_FREE_APP_DATA: usize = 5; @@ -114,10 +129,9 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO app_data VALUES ($1, $2, $3, $4, $5)", + "INSERT INTO app_data VALUES ($1, $2, $3, $4)", params![ &(data.id as i64), - &(data.owner as i64), &(data.app as i64), &data.key, &data.value @@ -131,18 +145,7 @@ impl DataManager { Ok(data) } - pub async fn delete_app_data(&self, id: usize, user: &User) -> Result<()> { - let app_data = self.get_app_data_by_id(id).await?; - let app = self.get_app_by_id(app_data.app).await?; - - // check user permission - if ((user.id != app.owner) | (user.id != app_data.owner)) - && !user.permissions.check(FinePermission::MANAGE_APPS) - { - return Err(Error::NotAllowed); - } - - // ... + pub async fn delete_app_data(&self, id: usize) -> Result<()> { let conn = match self.0.connect().await { Ok(c) => c, Err(e) => return Err(Error::DatabaseConnection(e.to_string())), @@ -158,6 +161,6 @@ impl DataManager { Ok(()) } - auto_method!(update_app_data_key(&str)@get_app_data_by_id:FinePermission::MANAGE_APPS; -> "UPDATE app_data SET k = $1 WHERE id = $2" --cache-key-tmpl="atto.app_data:{}"); - auto_method!(update_app_data_value(&str)@get_app_data_by_id:FinePermission::MANAGE_APPS; -> "UPDATE app_data SET v = $1 WHERE id = $2" --cache-key-tmpl="atto.app_data:{}"); + auto_method!(update_app_data_key(&str) -> "UPDATE app_data SET k = $1 WHERE id = $2" --cache-key-tmpl="atto.app_data:{}"); + auto_method!(update_app_data_value(&str) -> "UPDATE app_data SET v = $1 WHERE id = $2" --cache-key-tmpl="atto.app_data:{}"); } diff --git a/crates/core/src/database/apps.rs b/crates/core/src/database/apps.rs index f24b427..c6a4f42 100644 --- a/crates/core/src/database/apps.rs +++ b/crates/core/src/database/apps.rs @@ -7,10 +7,7 @@ use crate::model::{ Error, Result, }; use crate::{auto_method, DataManager}; - -use oiseau::PostgresRow; - -use oiseau::{execute, get, query_rows, params}; +use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { /// Get a [`ThirdPartyApp`] from an SQL row. @@ -26,10 +23,13 @@ impl DataManager { banned: get!(x->7(i32)) as i8 == 1, grants: get!(x->8(i32)) as usize, scopes: serde_json::from_str(&get!(x->9(String))).unwrap(), + api_key: get!(x->10(String)), + data_used: get!(x->11(i32)) as usize, } } auto_method!(get_app_by_id(usize as i64)@get_app_from_row -> "SELECT * FROM apps WHERE id = $1" --name="app" --returns=ThirdPartyApp --cache-key-tmpl="atto.app:{}"); + auto_method!(get_app_by_api_key(&str)@get_app_from_row -> "SELECT * FROM apps WHERE api_key = $1" --name="app" --returns=ThirdPartyApp --cache-key-tmpl="atto.app:{}"); /// Get all apps by user. /// @@ -90,7 +90,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO apps VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)", + "INSERT INTO apps VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)", params![ &(data.id as i64), &(data.created as i64), @@ -102,6 +102,8 @@ impl DataManager { &{ if data.banned { 1 } else { 0 } }, &(data.grants as i32), &serde_json::to_string(&data.scopes).unwrap(), + &data.api_key, + &(data.data_used as i32) ] ); @@ -133,6 +135,19 @@ impl DataManager { } self.0.1.remove(format!("atto.app:{}", id)).await; + + // remove data + let res = execute!( + &conn, + "DELETE FROM app_data WHERE app = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // ... Ok(()) } @@ -141,6 +156,7 @@ impl DataManager { auto_method!(update_app_redirect(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET redirect = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); auto_method!(update_app_quota_status(AppQuota) -> "UPDATE apps SET quota_status = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}"); auto_method!(update_app_scopes(Vec)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET scopes = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}"); + auto_method!(update_app_api_key(&str) -> "UPDATE apps SET api_key = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); auto_method!(incr_app_grants() -> "UPDATE apps SET grants = grants + 1 WHERE id = $1" --cache-key-tmpl="atto.app:{}" --incr); auto_method!(decr_app_grants()@get_app_by_id -> "UPDATE apps SET grants = grants - 1 WHERE id = $1" --cache-key-tmpl="atto.app:{}" --decr=grants); diff --git a/crates/core/src/database/channels.rs b/crates/core/src/database/channels.rs index ee42d4b..c1e9938 100644 --- a/crates/core/src/database/channels.rs +++ b/crates/core/src/database/channels.rs @@ -5,7 +5,6 @@ use crate::model::{ communities_permissions::CommunityPermission, channels::Channel, }; use crate::{auto_method, DataManager}; - use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; impl DataManager { diff --git a/crates/core/src/database/mod.rs b/crates/core/src/database/mod.rs index a4cdb3d..80b77a1 100644 --- a/crates/core/src/database/mod.rs +++ b/crates/core/src/database/mod.rs @@ -1,4 +1,4 @@ -mod app_data; +pub mod app_data; mod apps; mod audit_log; mod auth; diff --git a/crates/core/src/model/apps.rs b/crates/core/src/model/apps.rs index 8f90899..b48b0ed 100644 --- a/crates/core/src/model/apps.rs +++ b/crates/core/src/model/apps.rs @@ -2,7 +2,10 @@ use std::fmt::Display; use serde::{Deserialize, Serialize}; use tetratto_shared::{snow::Snowflake, unix_epoch_timestamp}; -use crate::model::oauth::AppScope; +use crate::{ + database::app_data::{FREE_DATA_LIMIT, PASS_DATA_LIMIT}, + model::{auth::User, oauth::AppScope, permissions::SecondaryPermission}, +}; #[derive(Serialize, Deserialize, Debug, Clone, PartialEq, Eq)] pub enum AppQuota { @@ -83,6 +86,10 @@ pub struct ThirdPartyApp { /// /// Your app should handle informing users when scopes change. pub scopes: Vec, + /// The app's secret API key (for app_data access). + pub api_key: String, + /// The number of bytes the app's app_data rows are using. + pub data_used: usize, } impl ThirdPartyApp { @@ -99,6 +106,8 @@ impl ThirdPartyApp { banned: false, grants: 0, scopes: Vec::new(), + api_key: String::new(), + data_used: 0, } } } @@ -106,7 +115,6 @@ impl ThirdPartyApp { #[derive(Serialize, Deserialize, Debug, Clone)] pub struct AppData { pub id: usize, - pub owner: usize, pub app: usize, pub key: String, pub value: String, @@ -114,15 +122,26 @@ pub struct AppData { impl AppData { /// Create a new [`AppData`]. - pub fn new(owner: usize, app: usize, key: String, value: String) -> Self { + pub fn new(app: usize, key: String, value: String) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), - owner, app, key, value, } } + + /// Get the data limit of a given user. + pub fn user_limit(user: &User) -> usize { + if user + .secondary_permissions + .check(SecondaryPermission::DEVELOPER_PASS) + { + PASS_DATA_LIMIT + } else { + FREE_DATA_LIMIT + } + } } #[derive(Serialize, Deserialize, Debug, Clone)] @@ -154,7 +173,8 @@ impl Display for AppDataSelectMode { Self::One => "LIMIT 1".to_string(), Self::Many(order_by_top_level_key, limit, offset) => { format!( - "ORDER BY v::jsonb->>'{order_by_top_level_key}' LIMIT {limit} OFFSET {offset}" + "ORDER BY v::jsonb->>'{order_by_top_level_key}' LIMIT {} OFFSET {offset}", + if *limit > 1024 { 1024 } else { *limit } ) } }) @@ -171,8 +191,14 @@ pub struct AppDataQuery { impl Display for AppDataQuery { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&format!( - "SELECT * FROM app_data WHERE app = {} AND v LIKE $1 {}", + "SELECT * FROM app_data WHERE app = {} AND %q% {}", self.app, self.mode )) } } + +#[derive(Serialize, Deserialize)] +pub enum AppDataQueryResult { + One(AppData), + Many(Vec), +} diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs index b86ebfa..7d7f19e 100644 --- a/crates/core/src/model/mod.rs +++ b/crates/core/src/model/mod.rs @@ -51,6 +51,7 @@ pub enum Error { QuestionsDisabled, RequiresSupporter, DrawingsDisabled, + AppHitStorageLimit, Unknown, } @@ -75,6 +76,7 @@ impl Display for Error { Self::QuestionsDisabled => "You are not allowed to ask questions there".to_string(), Self::RequiresSupporter => "Only site supporters can do this".to_string(), Self::DrawingsDisabled => "You are not allowed to submit drawings there".to_string(), + Self::AppHitStorageLimit => "This app has already hit its storage limit, or will do so if this data is processed.".to_string(), _ => format!("An unknown error as occurred: ({:?})", self), }) } diff --git a/crates/core/src/model/permissions.rs b/crates/core/src/model/permissions.rs index bbaca18..796b9f1 100644 --- a/crates/core/src/model/permissions.rs +++ b/crates/core/src/model/permissions.rs @@ -177,6 +177,7 @@ bitflags! { const MANAGE_DOMAINS = 1 << 2; const MANAGE_SERVICES = 1 << 3; const MANAGE_PRODUCTS = 1 << 4; + const DEVELOPER_PASS = 1 << 5; const _ = !0; } diff --git a/crates/shared/src/hash.rs b/crates/shared/src/hash.rs index f346861..a267bc4 100644 --- a/crates/shared/src/hash.rs +++ b/crates/shared/src/hash.rs @@ -33,6 +33,18 @@ pub fn salt() -> String { .collect() } +pub fn salt_len(len: usize) -> String { + rng() + .sample_iter(&Alphanumeric) + .take(len) + .map(char::from) + .collect() +} + pub fn random_id() -> String { hash(uuid()) } + +pub fn random_id_salted_len(len: usize) -> String { + hash(uuid() + &salt_len(len)) +} diff --git a/sql_changes/apps_api_key.sql b/sql_changes/apps_api_key.sql new file mode 100644 index 0000000..a5c35ad --- /dev/null +++ b/sql_changes/apps_api_key.sql @@ -0,0 +1,2 @@ +ALTER TABLE apps +ADD COLUMN api_key TEXT NOT NULL DEFAULT ''; diff --git a/sql_changes/apps_data_used.sql b/sql_changes/apps_data_used.sql new file mode 100644 index 0000000..77202a0 --- /dev/null +++ b/sql_changes/apps_data_used.sql @@ -0,0 +1,2 @@ +ALTER TABLE apps +ADD COLUMN data_used INT NOT NULL DEFAULT 0; From 440ca81c25b4c2e9fd9c8efcc2045ae8ab05b290 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 17 Jul 2025 13:46:20 -0400 Subject: [PATCH 66/93] fix: properly update app usage --- crates/app/src/routes/api/v1/app_data.rs | 26 ++++++++++++++++++++++-- crates/core/src/database/apps.rs | 1 + 2 files changed, 25 insertions(+), 2 deletions(-) diff --git a/crates/app/src/routes/api/v1/app_data.rs b/crates/app/src/routes/api/v1/app_data.rs index c074c8f..b4c0d03 100644 --- a/crates/app/src/routes/api/v1/app_data.rs +++ b/crates/app/src/routes/api/v1/app_data.rs @@ -61,6 +61,10 @@ pub async fn create_request( } // ... + if let Err(e) = data.update_app_data_used(app.id, new_size as i32).await { + return Json(e.into()); + } + match data .create_app_data(AppData::new(app.id, req.key, req.value)) .await @@ -105,6 +109,10 @@ pub async fn update_value_request( } // ... + if let Err(e) = data.update_app_data_used(app.id, new_size as i32).await { + return Json(e.into()); + } + match data.update_app_data_value(id, &req.value).await { Ok(_) => Json(ApiReturn { ok: true, @@ -121,8 +129,22 @@ pub async fn delete_request( Path(id): Path, ) -> impl IntoResponse { let data = &(data.read().await).0; - if get_app_from_key!(data, jar).is_none() { - return Json(Error::NotAllowed.into()); + let app = match get_app_from_key!(data, jar) { + Some(x) => x, + None => return Json(Error::NotAllowed.into()), + }; + + let app_data = match data.get_app_data_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + // ... + if let Err(e) = data + .update_app_data_used(app.id, (app.data_used - app_data.value.len()) as i32) + .await + { + return Json(e.into()); } match data.delete_app_data(id).await { diff --git a/crates/core/src/database/apps.rs b/crates/core/src/database/apps.rs index c6a4f42..4915907 100644 --- a/crates/core/src/database/apps.rs +++ b/crates/core/src/database/apps.rs @@ -157,6 +157,7 @@ impl DataManager { auto_method!(update_app_quota_status(AppQuota) -> "UPDATE apps SET quota_status = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}"); auto_method!(update_app_scopes(Vec)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET scopes = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}"); auto_method!(update_app_api_key(&str) -> "UPDATE apps SET api_key = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); + auto_method!(update_app_data_used(i32) -> "UPDATE apps SET data_used = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); auto_method!(incr_app_grants() -> "UPDATE apps SET grants = grants + 1 WHERE id = $1" --cache-key-tmpl="atto.app:{}" --incr); auto_method!(decr_app_grants()@get_app_by_id -> "UPDATE apps SET grants = grants - 1 WHERE id = $1" --cache-key-tmpl="atto.app:{}" --decr=grants); From 9f61d9ce6abb9c3dcf41578e9f3322c79d126ce2 Mon Sep 17 00:00:00 2001 From: trisua Date: Thu, 17 Jul 2025 13:51:56 -0400 Subject: [PATCH 67/93] fix: post creation form --- crates/app/src/public/html/communities/create_post.lisp | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/app/src/public/html/communities/create_post.lisp b/crates/app/src/public/html/communities/create_post.lisp index 1c8ce2c..0b7cf19 100644 --- a/crates/app/src/public/html/communities/create_post.lisp +++ b/crates/app/src/public/html/communities/create_post.lisp @@ -159,6 +159,7 @@ (text "{{ icon \"notepad-text-dashed\" }}")) (text "{%- endif %} {%- endif %}") (button + ("class" "primary") (text "{{ text \"communities:action.create\" }}")))))) (text "{% if not quoting -%}") (script From 22aea48cc596a4b5e9ca78f131e660d033694204 Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 18 Jul 2025 00:14:52 -0400 Subject: [PATCH 68/93] add: better app data queries --- crates/app/src/macros.rs | 9 +- crates/app/src/public/html/developer/app.lisp | 2 +- .../app/src/public/html/developer/home.lisp | 5 +- .../app/src/public/html/developer/link.lisp | 7 ++ crates/app/src/routes/api/v1/app_data.rs | 86 +++++++++++++++---- crates/app/src/routes/api/v1/mod.rs | 4 +- crates/core/src/database/app_data.rs | 59 ++++++++++--- crates/core/src/database/apps.rs | 29 ++++--- .../database/drivers/sql/create_app_data.sql | 1 - .../src/database/drivers/sql/create_apps.sql | 3 +- crates/core/src/model/apps.rs | 28 ++++-- sql_changes/apps_data_used.sql | 2 +- 12 files changed, 175 insertions(+), 60 deletions(-) diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 0edafbb..fd141ea 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -422,12 +422,9 @@ macro_rules! ignore_users_gen { #[macro_export] macro_rules! get_app_from_key { - ($db:ident, $jar:ident) => { - if let Some(token) = $jar.get("Atto-Secret-Key") { - match $db - .get_app_by_api_key(&token.to_string().replace("Atto-Secret-Key=", "")) - .await - { + ($db:ident, $headers:ident) => { + if let Some(token) = $headers.get("Atto-Secret-Key") { + match $db.get_app_by_api_key(token.to_str().unwrap()).await { Ok(x) => Some(x), Err(_) => None, } diff --git a/crates/app/src/public/html/developer/app.lisp b/crates/app/src/public/html/developer/app.lisp index d01e9de..6795001 100644 --- a/crates/app/src/public/html/developer/app.lisp +++ b/crates/app/src/public/html/developer/app.lisp @@ -19,7 +19,7 @@ (div ("class" "card flex flex-col gap-2") (p ("class" "fade") (text "App data keys are not included in this metric, only stored values count towards your limit.")) - (text "{% set percentage = (data_limit / app.data_used) * 100 %}") + (text "{% set percentage = (app.data_used / data_limit) * 100 %}") (div ("class" "progress_bar") (div ("class" "poll_bar") ("style" "width: {{ percentage }}%"))) (div ("class" "w-full flex justify-between items-center") diff --git a/crates/app/src/public/html/developer/home.lisp b/crates/app/src/public/html/developer/home.lisp index fb00c7e..d96be6f 100644 --- a/crates/app/src/public/html/developer/home.lisp +++ b/crates/app/src/public/html/developer/home.lisp @@ -47,13 +47,12 @@ ("class" "flex flex-col gap-1") (label ("for" "title") - (text "{{ text \"developer:label.redirect\" }}")) + (text "{{ text \"developer:label.redirect\" }} (optional)")) (input ("type" "url") ("name" "redirect") ("id" "redirect") ("placeholder" "redirect URL") - ("required" "") ("minlength" "2") ("maxlength" "32"))) (button @@ -125,7 +124,7 @@ body: JSON.stringify({ title: e.target.title.value, homepage: e.target.homepage.value, - redirect: e.target.redirect.value, + redirect: e.target.redirect.value || \"\", }), }) .then((res) => res.json()) diff --git a/crates/app/src/public/html/developer/link.lisp b/crates/app/src/public/html/developer/link.lisp index 5d46c87..2c94309 100644 --- a/crates/app/src/public/html/developer/link.lisp +++ b/crates/app/src/public/html/developer/link.lisp @@ -39,6 +39,13 @@ (str (text "dialog:action.cancel"))))))) (script (text "setTimeout(() => { + // {% if app.redirect|length == 0 %} + alert(\"App has an invalid redirect. Please contact the owner for help.\"); + window.close(); + return; + // {% endif %} + + // ... globalThis.authorize = async (event) => { if ( !(await trigger(\"atto::confirm\", [ diff --git a/crates/app/src/routes/api/v1/app_data.rs b/crates/app/src/routes/api/v1/app_data.rs index b4c0d03..c2983f1 100644 --- a/crates/app/src/routes/api/v1/app_data.rs +++ b/crates/app/src/routes/api/v1/app_data.rs @@ -3,20 +3,19 @@ use crate::{ routes::api::v1::{InsertAppData, QueryAppData, UpdateAppDataValue}, State, }; -use axum::{Extension, Json, extract::Path, response::IntoResponse}; -use axum_extra::extract::CookieJar; +use axum::{extract::Path, http::HeaderMap, response::IntoResponse, Extension, Json}; use tetratto_core::model::{ - apps::{AppData, AppDataQuery}, + apps::{AppData, AppDataQuery, AppDataQueryResult}, ApiReturn, Error, }; pub async fn query_request( - jar: CookieJar, + headers: HeaderMap, Extension(data): Extension, Json(req): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; - let app = match get_app_from_key!(data, jar) { + let app = match get_app_from_key!(data, headers) { Some(x) => x, None => return Json(Error::NotAllowed.into()), }; @@ -39,12 +38,12 @@ pub async fn query_request( } pub async fn create_request( - jar: CookieJar, + headers: HeaderMap, Extension(data): Extension, Json(req): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; - let app = match get_app_from_key!(data, jar) { + let app = match get_app_from_key!(data, headers) { Some(x) => x, None => return Json(Error::NotAllowed.into()), }; @@ -61,7 +60,7 @@ pub async fn create_request( } // ... - if let Err(e) = data.update_app_data_used(app.id, new_size as i32).await { + if let Err(e) = data.add_app_data_used(app.id, req.value.len() as i32).await { return Json(e.into()); } @@ -71,7 +70,7 @@ pub async fn create_request( { Ok(s) => Json(ApiReturn { ok: true, - message: "App created".to_string(), + message: "Data inserted".to_string(), payload: s.id.to_string(), }), Err(e) => Json(e.into()), @@ -79,13 +78,13 @@ pub async fn create_request( } pub async fn update_value_request( - jar: CookieJar, + headers: HeaderMap, Extension(data): Extension, Path(id): Path, Json(req): Json, ) -> impl IntoResponse { let data = &(data.read().await).0; - let app = match get_app_from_key!(data, jar) { + let app = match get_app_from_key!(data, headers) { Some(x) => x, None => return Json(Error::NotAllowed.into()), }; @@ -109,7 +108,7 @@ pub async fn update_value_request( } // ... - if let Err(e) = data.update_app_data_used(app.id, new_size as i32).await { + if let Err(e) = data.add_app_data_used(app.id, req.value.len() as i32).await { return Json(e.into()); } @@ -124,12 +123,12 @@ pub async fn update_value_request( } pub async fn delete_request( - jar: CookieJar, + headers: HeaderMap, Extension(data): Extension, Path(id): Path, ) -> impl IntoResponse { let data = &(data.read().await).0; - let app = match get_app_from_key!(data, jar) { + let app = match get_app_from_key!(data, headers) { Some(x) => x, None => return Json(Error::NotAllowed.into()), }; @@ -141,7 +140,7 @@ pub async fn delete_request( // ... if let Err(e) = data - .update_app_data_used(app.id, (app.data_used - app_data.value.len()) as i32) + .add_app_data_used(app.id, -(app_data.value.len() as i32)) .await { return Json(e.into()); @@ -156,3 +155,60 @@ pub async fn delete_request( Err(e) => Json(e.into()), } } + +pub async fn delete_query_request( + headers: HeaderMap, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let app = match get_app_from_key!(data, headers) { + Some(x) => x, + None => return Json(Error::NotAllowed.into()), + }; + + // ... + let rows = match data + .query_app_data(AppDataQuery { + app: app.id, + query: req.query.clone(), + mode: req.mode.clone(), + }) + .await + { + Ok(x) => match x { + AppDataQueryResult::One(x) => vec![x], + AppDataQueryResult::Many(x) => x, + }, + Err(e) => return Json(e.into()), + }; + + let mut subtract_amount: usize = 0; + for row in &rows { + subtract_amount += row.value.len(); + } + drop(rows); + + if let Err(e) = data + .add_app_data_used(app.id, -(subtract_amount as i32)) + .await + { + return Json(e.into()); + } + + match data + .query_delete_app_data(AppDataQuery { + app: app.id, + query: req.query, + mode: req.mode, + }) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Data deleted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 8b276dd..9e48f8d 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -433,9 +433,10 @@ pub fn routes() -> Router { .route("/apps/{id}/roll", post(apps::roll_api_key_request)) // app data .route("/app_data", post(app_data::create_request)) - .route("/app_data/query", post(app_data::query_request)) .route("/app_data/{id}", delete(app_data::delete_request)) .route("/app_data/{id}/value", post(app_data::update_value_request)) + .route("/app_data/query", post(app_data::query_request)) + .route("/app_data/query", delete(app_data::delete_query_request)) // warnings .route("/warnings/{id}", get(auth::user_warnings::get_request)) .route("/warnings/{id}", post(auth::user_warnings::create_request)) @@ -987,6 +988,7 @@ pub struct UpdatePostIsOpen { pub struct CreateApp { pub title: String, pub homepage: String, + #[serde(default)] pub redirect: String, } diff --git a/crates/core/src/database/app_data.rs b/crates/core/src/database/app_data.rs index 6ea4f63..7ddece5 100644 --- a/crates/core/src/database/app_data.rs +++ b/crates/core/src/database/app_data.rs @@ -12,9 +12,9 @@ impl DataManager { pub(crate) fn get_app_data_from_row(x: &PostgresRow) -> AppData { AppData { id: get!(x->0(i64)) as usize, - app: get!(x->2(i64)) as usize, - key: get!(x->3(String)), - value: get!(x->4(String)), + app: get!(x->1(i64)) as usize, + key: get!(x->2(String)), + value: get!(x->3(String)), } } @@ -44,10 +44,7 @@ impl DataManager { Ok(res.unwrap()) } - /// Get all app_data by owner. - /// - /// # Arguments - /// * `id` - the ID of the user to fetch app_data for + /// Get all app_data by the given query. pub async fn query_app_data(&self, query: AppDataQuery) -> Result { let conn = match self.0.connect().await { Ok(c) => c, @@ -57,12 +54,13 @@ impl DataManager { let query_str = query.to_string().replace( "%q%", &match query.query { - AppDataSelectQuery::Like(_, _) => format!("v LIKE $1"), + AppDataSelectQuery::KeyIs(_) => format!("k = $1"), + AppDataSelectQuery::LikeJson(_, _) => format!("v LIKE $1"), }, ); let res = match query.mode { - AppDataSelectMode::One => AppDataQueryResult::One( + AppDataSelectMode::One(_) => AppDataQueryResult::One( match query_row!(&conn, &query_str, params![&query.query.to_string()], |x| { Ok(Self::get_app_data_from_row(x)) }) { @@ -70,7 +68,15 @@ impl DataManager { Err(_) => return Err(Error::GeneralNotFound("app_data".to_string())), }, ), - AppDataSelectMode::Many(_, _, _) => AppDataQueryResult::Many( + AppDataSelectMode::Many(_, _) => AppDataQueryResult::Many( + match query_rows!(&conn, &query_str, params![&query.query.to_string()], |x| { + Self::get_app_data_from_row(x) + }) { + Ok(x) => x, + Err(_) => return Err(Error::GeneralNotFound("app_data".to_string())), + }, + ), + AppDataSelectMode::ManyJson(_, _, _) => AppDataQueryResult::Many( match query_rows!(&conn, &query_str, params![&query.query.to_string()], |x| { Self::get_app_data_from_row(x) }) { @@ -83,6 +89,35 @@ impl DataManager { Ok(res) } + /// Delete all app_data matched by the given query. + pub async fn query_delete_app_data(&self, query: AppDataQuery) -> Result<()> { + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let query_str = query + .to_string() + .replace( + "%q%", + &match query.query { + AppDataSelectQuery::KeyIs(_) => format!("k = $1"), + AppDataSelectQuery::LikeJson(_, _) => format!("v LIKE $1"), + }, + ) + .replace("SELECT * FROM", "SELECT id FROM"); + + if let Err(e) = execute!( + &conn, + &format!("DELETE FROM app_data WHERE id IN ({query_str})"), + params![&query.query.to_string()] + ) { + return Err(Error::MiscError(e.to_string())); + } + + Ok(()) + } + const MAXIMUM_FREE_APP_DATA: usize = 5; const MAXIMUM_DATA_SIZE: usize = 205_000; @@ -101,9 +136,9 @@ impl DataManager { } if data.value.len() < 2 { - return Err(Error::DataTooShort("key".to_string())); + return Err(Error::DataTooShort("value".to_string())); } else if data.value.len() > Self::MAXIMUM_DATA_SIZE { - return Err(Error::DataTooLong("key".to_string())); + return Err(Error::DataTooLong("value".to_string())); } // check number of app_data diff --git a/crates/core/src/database/apps.rs b/crates/core/src/database/apps.rs index 4915907..1fa5f31 100644 --- a/crates/core/src/database/apps.rs +++ b/crates/core/src/database/apps.rs @@ -29,7 +29,7 @@ impl DataManager { } auto_method!(get_app_by_id(usize as i64)@get_app_from_row -> "SELECT * FROM apps WHERE id = $1" --name="app" --returns=ThirdPartyApp --cache-key-tmpl="atto.app:{}"); - auto_method!(get_app_by_api_key(&str)@get_app_from_row -> "SELECT * FROM apps WHERE api_key = $1" --name="app" --returns=ThirdPartyApp --cache-key-tmpl="atto.app:{}"); + auto_method!(get_app_by_api_key(&str)@get_app_from_row -> "SELECT * FROM apps WHERE api_key = $1" --name="app" --returns=ThirdPartyApp --cache-key-tmpl="atto.app_k:{}"); /// Get all apps by user. /// @@ -134,7 +134,7 @@ impl DataManager { return Err(Error::DatabaseError(e.to_string())); } - self.0.1.remove(format!("atto.app:{}", id)).await; + self.cache_clear_app(&app).await; // remove data let res = execute!( @@ -151,14 +151,21 @@ impl DataManager { Ok(()) } - auto_method!(update_app_title(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET title = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); - auto_method!(update_app_homepage(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET homepage = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); - auto_method!(update_app_redirect(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET redirect = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); - auto_method!(update_app_quota_status(AppQuota) -> "UPDATE apps SET quota_status = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}"); - auto_method!(update_app_scopes(Vec)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET scopes = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.app:{}"); - auto_method!(update_app_api_key(&str) -> "UPDATE apps SET api_key = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); - auto_method!(update_app_data_used(i32) -> "UPDATE apps SET data_used = $1 WHERE id = $2" --cache-key-tmpl="atto.app:{}"); + pub async fn cache_clear_app(&self, app: &ThirdPartyApp) { + self.0.1.remove(format!("atto.app:{}", app.id)).await; + self.0.1.remove(format!("atto.app_k:{}", app.api_key)).await; + } - auto_method!(incr_app_grants() -> "UPDATE apps SET grants = grants + 1 WHERE id = $1" --cache-key-tmpl="atto.app:{}" --incr); - auto_method!(decr_app_grants()@get_app_by_id -> "UPDATE apps SET grants = grants - 1 WHERE id = $1" --cache-key-tmpl="atto.app:{}" --decr=grants); + auto_method!(update_app_title(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET title = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_app); + auto_method!(update_app_homepage(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET homepage = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_app); + auto_method!(update_app_redirect(&str)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET redirect = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_app); + auto_method!(update_app_quota_status(AppQuota)@get_app_by_id -> "UPDATE apps SET quota_status = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_app); + auto_method!(update_app_scopes(Vec)@get_app_by_id:FinePermission::MANAGE_APPS; -> "UPDATE apps SET scopes = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_app); + auto_method!(update_app_api_key(&str)@get_app_by_id -> "UPDATE apps SET api_key = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_app); + + auto_method!(update_app_data_used(i32)@get_app_by_id -> "UPDATE apps SET data_used = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_app); + auto_method!(add_app_data_used(i32)@get_app_by_id -> "UPDATE apps SET data_used = data_used + $1 WHERE id = $2" --cache-key-tmpl=cache_clear_app); + + auto_method!(incr_app_grants()@get_app_by_id -> "UPDATE apps SET grants = grants + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_app --incr); + auto_method!(decr_app_grants()@get_app_by_id -> "UPDATE apps SET grants = grants - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_app --decr=grants); } diff --git a/crates/core/src/database/drivers/sql/create_app_data.sql b/crates/core/src/database/drivers/sql/create_app_data.sql index 28a8379..64cdd3f 100644 --- a/crates/core/src/database/drivers/sql/create_app_data.sql +++ b/crates/core/src/database/drivers/sql/create_app_data.sql @@ -1,6 +1,5 @@ CREATE TABLE IF NOT EXISTS app_data ( id BIGINT NOT NULL PRIMARY KEY, - owner BIGINT NOT NULL, app BIGINT NOT NULL, k TEXT NOT NULL, v TEXT NOT NULL diff --git a/crates/core/src/database/drivers/sql/create_apps.sql b/crates/core/src/database/drivers/sql/create_apps.sql index 575ce5c..d01ed41 100644 --- a/crates/core/src/database/drivers/sql/create_apps.sql +++ b/crates/core/src/database/drivers/sql/create_apps.sql @@ -8,5 +8,6 @@ CREATE TABLE IF NOT EXISTS apps ( quota_status TEXT NOT NULL, banned INT NOT NULL, grants INT NOT NULL, - scopes TEXT NOT NULL + scopes TEXT NOT NULL, + data_used INT NOT NULL CHECK (data_used >= 0) ) diff --git a/crates/core/src/model/apps.rs b/crates/core/src/model/apps.rs index b48b0ed..bca5c81 100644 --- a/crates/core/src/model/apps.rs +++ b/crates/core/src/model/apps.rs @@ -146,34 +146,46 @@ impl AppData { #[derive(Serialize, Deserialize, Debug, Clone)] pub enum AppDataSelectQuery { - Like(String, String), + KeyIs(String), + LikeJson(String, String), } impl Display for AppDataSelectQuery { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&match self { - Self::Like(k, v) => format!("%\"{k}\":\"{v}\"%"), + Self::KeyIs(k) => k.to_owned(), + Self::LikeJson(k, v) => format!("%\"{k}\":\"{v}\"%"), }) } } #[derive(Serialize, Deserialize, Debug, Clone)] pub enum AppDataSelectMode { - /// Select a single row. - One, + /// Select a single row (with offset). + One(usize), + /// Select multiple rows at once. + /// + /// `(limit, offset)` + Many(usize, usize), /// Select multiple rows at once. /// /// `(order by top level key, limit, offset)` - Many(String, usize, usize), + ManyJson(String, usize, usize), } impl Display for AppDataSelectMode { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&match self { - Self::One => "LIMIT 1".to_string(), - Self::Many(order_by_top_level_key, limit, offset) => { + Self::One(offset) => format!("LIMIT 1 OFFSET {offset}"), + Self::Many(limit, offset) => { format!( - "ORDER BY v::jsonb->>'{order_by_top_level_key}' LIMIT {} OFFSET {offset}", + "LIMIT {} OFFSET {offset}", + if *limit > 1024 { 1024 } else { *limit } + ) + } + Self::ManyJson(order_by_top_level_key, limit, offset) => { + format!( + "ORDER BY v::jsonb->>'{order_by_top_level_key}' DESC LIMIT {} OFFSET {offset}", if *limit > 1024 { 1024 } else { *limit } ) } diff --git a/sql_changes/apps_data_used.sql b/sql_changes/apps_data_used.sql index 77202a0..a86da50 100644 --- a/sql_changes/apps_data_used.sql +++ b/sql_changes/apps_data_used.sql @@ -1,2 +1,2 @@ ALTER TABLE apps -ADD COLUMN data_used INT NOT NULL DEFAULT 0; +ADD COLUMN data_used INT NOT NULL DEFAULT 0 CHECK (data_used >= 0); From e393221b4fbe00a8bf83a843c6a6ef0f7c8f2ae3 Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 18 Jul 2025 12:22:50 -0400 Subject: [PATCH 69/93] fix: check muted phrases while creating questions --- crates/core/src/database/questions.rs | 27 ++++++++++----------------- 1 file changed, 10 insertions(+), 17 deletions(-) diff --git a/crates/core/src/database/questions.rs b/crates/core/src/database/questions.rs index 84f9eac..7722250 100644 --- a/crates/core/src/database/questions.rs +++ b/crates/core/src/database/questions.rs @@ -387,23 +387,8 @@ impl DataManager { // inherit nsfw status data.context.is_nsfw = community.context.is_nsfw; } else { - let receiver = self.get_user_by_id(data.receiver).await?; - - if !receiver.settings.enable_questions { - return Err(Error::QuestionsDisabled); - } - - // check for ip block - if self - .get_ipblock_by_initiator_receiver( - receiver.id, - &RemoteAddr::from(data.ip.as_str()), - ) - .await - .is_ok() - { - return Err(Error::NotAllowed); - } + // this should be unreachable + return Err(Error::Unknown); } } else { // single @@ -421,6 +406,14 @@ impl DataManager { return Err(Error::DrawingsDisabled); } + // check muted phrases + for phrase in receiver.settings.muted { + if data.content.contains(&phrase) { + // act like the question was created so theyre less likely to try and send it again or bypass + return Ok(0); + } + } + // check for ip block if self .get_ipblock_by_initiator_receiver(receiver.id, &RemoteAddr::from(data.ip.as_str())) From 636ecce9f45b0336e5f9fd8727560541267f2412 Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 18 Jul 2025 13:22:25 -0400 Subject: [PATCH 70/93] add: apps js sdk --- crates/app/src/assets.rs | 1 + crates/app/src/langs/en-US.toml | 2 + crates/app/src/public/html/components.lisp | 10 -- crates/app/src/public/html/macros.lisp | 43 ++++++-- crates/app/src/public/js/app_sdk.js | 108 +++++++++++++++++++++ crates/app/src/public/js/atto.js | 52 +++++----- crates/app/src/routes/api/v1/app_data.rs | 17 ++++ crates/app/src/routes/api/v1/mod.rs | 1 + crates/app/src/routes/assets.rs | 1 + crates/app/src/routes/mod.rs | 1 + example/app_sdk_test.js | 28 ++++++ 11 files changed, 223 insertions(+), 41 deletions(-) create mode 100644 crates/app/src/public/js/app_sdk.js create mode 100644 example/app_sdk_test.js diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index aba7de9..50b256b 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -41,6 +41,7 @@ pub const ME_JS: &str = include_str!("./public/js/me.js"); pub const STREAMS_JS: &str = include_str!("./public/js/streams.js"); pub const CARP_JS: &str = include_str!("./public/js/carp.js"); pub const PROTO_LINKS_JS: &str = include_str!("./public/js/proto_links.js"); +pub const APP_SDK_JS: &str = include_str!("./public/js/app_sdk.js"); // html pub const BODY: &str = include_str!("./public/html/body.lisp"); diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 243b70f..c77bfd5 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -234,6 +234,7 @@ version = "1.0.0" "stacks:label.block_all" = "Block all" "stacks:label.unblock_all" = "Unblock all" +"forge:label.forges" = "Forges" "forge:label.my_forges" = "My forges" "forge:label.create_new" = "Create new forge" "forge:tab.info" = "Info" @@ -242,6 +243,7 @@ version = "1.0.0" "forge:action.close" = "Close" "developer:label.for_developers" = "for Developers" +"developer:label.apps" = "Apps" "developer:label.my_apps" = "My apps" "developer:label.create_new" = "Create new app" "developer:label.homepage" = "Homepage" diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index a83ad44..516fe2a 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -1142,16 +1142,6 @@ (text "{{ icon \"circle-user-round\" }}") (span (text "{{ text \"auth:link.my_profile\" }}"))) - (a - ("href" "/journals/0/0") - (icon (text "notebook")) - (str (text "general:link.journals"))) - (text "{% if config.lw_host -%}") - (button - ("onclick" "document.getElementById('littleweb').showModal()") - (icon (text "globe")) - (str (text "general:link.little_web"))) - (text "{%- endif %}") (text "{% if not user.settings.disable_achievements -%}") (a ("href" "/achievements") diff --git a/crates/app/src/public/html/macros.lisp b/crates/app/src/public/html/macros.lisp index 969439b..fff3188 100644 --- a/crates/app/src/public/html/macros.lisp +++ b/crates/app/src/public/html/macros.lisp @@ -39,12 +39,6 @@ ("title" "Create post") (icon (text "square-pen"))) - (a - ("href" "/chats/0/0") - ("class" "button {% if selected == 'chats' -%}active{%- endif %}") - ("title" "Chats") - (icon (text "message-circle"))) - (a ("href" "/requests") ("class" "button {% if selected == 'requests' -%}active{%- endif %}") @@ -65,6 +59,43 @@ ("id" "notifications_span") (text "{{ user.notification_count }}"))) + (text "{% if user -%}") + (div + ("class" "dropdown") + (button + ("class" "flex-row {% if selected == 'chats' or selected == 'journals' -%}active{%- endif %}") + ("onclick" "trigger('atto::hooks::dropdown', [event])") + ("exclude" "dropdown") + ("title" "More services") + (icon (text "grip"))) + + (div + ("class" "inner") + (a + ("href" "/chats/0/0") + ("title" "Chats") + (icon (text "message-circle")) + (str (text "communities:label.chats"))) + (a + ("href" "/journals/0/0") + (icon (text "notebook")) + (str (text "general:link.journals"))) + (a + ("href" "/forges") + (icon (text "anvil")) + (str (text "forge:label.forges"))) + (a + ("href" "/developer") + (icon (text "code")) + (str (text "developer:label.apps"))) + (text "{% if config.lw_host -%}") + (button + ("onclick" "document.getElementById('littleweb').showModal()") + (icon (text "globe")) + (str (text "general:link.little_web"))) + (text "{%- endif %}"))) + (text "{%- endif %}") + (text "{% if not hide_user_menu -%}") (div ("class" "dropdown") diff --git a/crates/app/src/public/js/app_sdk.js b/crates/app/src/public/js/app_sdk.js new file mode 100644 index 0000000..10de985 --- /dev/null +++ b/crates/app/src/public/js/app_sdk.js @@ -0,0 +1,108 @@ +import { + JSONParse as json_parse, + JSONStringify as json_stringify, +} from "https://unpkg.com/json-with-bigint@3.4.4/json-with-bigint.js"; + +export default function tetratto(tetratto_host, api_key) { + function api_promise(res) { + return new Promise((resolve, reject) => { + if (res.ok) { + resolve(res.payload); + } else { + reject(res.message); + } + }); + } + + async function app() { + return api_promise( + json_parse( + await ( + await fetch(`${tetratto_host}/api/v1/app_data/app`, { + method: "GET", + headers: { + "Atto-Secret-Key": api_key, + }, + }) + ).text(), + ), + ); + } + + async function query(body) { + return api_promise( + json_parse( + await ( + await fetch(`${tetratto_host}/api/v1/app_data/query`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Atto-Secret-Key": api_key, + }, + body: json_stringify(body), + }) + ).text(), + ), + ); + } + + async function insert(key, value) { + return api_promise( + json_parse( + await ( + await fetch(`${tetratto_host}/api/v1/app_data`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Atto-Secret-Key": api_key, + }, + body: json_stringify({ + key, + value, + }), + }) + ).text(), + ), + ); + } + + async function remove(id) { + return api_promise( + json_parse( + await ( + await fetch(`${tetratto_host}/api/v1/app_data/${id}`, { + method: "DELETE", + headers: { + "Atto-Secret-Key": api_key, + }, + }) + ).text(), + ), + ); + } + + async function remove_query(body) { + return api_promise( + json_parse( + await ( + await fetch(`${tetratto_host}/api/v1/app_data/query`, { + method: "DELETE", + headers: { + "Content-Type": "application/json", + "Atto-Secret-Key": api_key, + }, + body: json_stringify(body), + }) + ).text(), + ), + ); + } + + return { + app, + query, + insert, + remove, + remove_query, + }; +} diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index 157d6d3..d8ffb86 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -415,33 +415,35 @@ media_theme_pref(); }); self.define("hooks::long_text.init", (_) => { - for (const element of Array.from( - document.querySelectorAll("[hook=long]") || [], - )) { - const is_long = element.innerText.length >= 64 * 8; + setTimeout(() => { + for (const element of Array.from( + document.querySelectorAll("[hook=long]") || [], + )) { + const is_long = element.innerText.length >= 64 * 8; - if (!is_long) { - continue; + if (!is_long) { + continue; + } + + element.classList.add("hook:long.hidden_text"); + + if (element.getAttribute("hook-arg") === "lowered") { + element.classList.add("hook:long.hidden_text+lowered"); + } + + const html = element.innerHTML; + const short = html.slice(0, 64 * 8); + element.innerHTML = `${short}...`; + + // event + const listener = () => { + self["hooks::long"](element, html); + element.removeEventListener("click", listener); + }; + + element.addEventListener("click", listener); } - - element.classList.add("hook:long.hidden_text"); - - if (element.getAttribute("hook-arg") === "lowered") { - element.classList.add("hook:long.hidden_text+lowered"); - } - - const html = element.innerHTML; - const short = html.slice(0, 64 * 8); - element.innerHTML = `${short}...`; - - // event - const listener = () => { - self["hooks::long"](element, html); - element.removeEventListener("click", listener); - }; - - element.addEventListener("click", listener); - } + }, 150); }); self.define("hooks::alt", (_) => { diff --git a/crates/app/src/routes/api/v1/app_data.rs b/crates/app/src/routes/api/v1/app_data.rs index c2983f1..f9da1c8 100644 --- a/crates/app/src/routes/api/v1/app_data.rs +++ b/crates/app/src/routes/api/v1/app_data.rs @@ -9,6 +9,23 @@ use tetratto_core::model::{ ApiReturn, Error, }; +pub async fn get_app_request( + headers: HeaderMap, + Extension(data): Extension, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let app = match get_app_from_key!(data, headers) { + Some(x) => x, + None => return Json(Error::NotAllowed.into()), + }; + + Json(ApiReturn { + ok: true, + message: "Success".to_string(), + payload: Some(app), + }) +} + pub async fn query_request( headers: HeaderMap, Extension(data): Extension, diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 9e48f8d..3b6e61e 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -433,6 +433,7 @@ pub fn routes() -> Router { .route("/apps/{id}/roll", post(apps::roll_api_key_request)) // app data .route("/app_data", post(app_data::create_request)) + .route("/app_data/app", get(app_data::get_app_request)) .route("/app_data/{id}", delete(app_data::delete_request)) .route("/app_data/{id}/value", post(app_data::update_value_request)) .route("/app_data/query", post(app_data::query_request)) diff --git a/crates/app/src/routes/assets.rs b/crates/app/src/routes/assets.rs index d7843bd..f18ede0 100644 --- a/crates/app/src/routes/assets.rs +++ b/crates/app/src/routes/assets.rs @@ -20,3 +20,4 @@ serve_asset!(me_js_request: ME_JS("text/javascript")); serve_asset!(streams_js_request: STREAMS_JS("text/javascript")); serve_asset!(carp_js_request: CARP_JS("text/javascript")); serve_asset!(proto_links_request: PROTO_LINKS_JS("text/javascript")); +serve_asset!(app_sdk_request: APP_SDK_JS("text/javascript")); diff --git a/crates/app/src/routes/mod.rs b/crates/app/src/routes/mod.rs index e0fa067..cde54f5 100644 --- a/crates/app/src/routes/mod.rs +++ b/crates/app/src/routes/mod.rs @@ -21,6 +21,7 @@ pub fn routes(config: &Config) -> Router { .route("/js/streams.js", get(assets::streams_js_request)) .route("/js/carp.js", get(assets::carp_js_request)) .route("/js/proto_links.js", get(assets::proto_links_request)) + .route("/js/app_sdk.js", get(assets::app_sdk_request)) .nest_service( "/public", get_service(tower_http::services::ServeDir::new(&config.dirs.assets)), diff --git a/example/app_sdk_test.js b/example/app_sdk_test.js new file mode 100644 index 0000000..000eb8d --- /dev/null +++ b/example/app_sdk_test.js @@ -0,0 +1,28 @@ +// @ts-nocheck +// APP_API_KEY=... deno run --allow-net --allow-import --allow-env -r app_sdk_test.js +const deno = Deno; +const sdk = (await import("http://localhost:4118/js/app_sdk.js")).default( + "http://localhost:4118", + deno.env.get("APP_API_KEY"), +); + +// check data used +console.log("data used:", (await sdk.app()).data_used); + +// record insert +await sdk.insert("deno_test", "Hello, Deno!"); +console.log("record created"); +console.log("data used:", (await sdk.app()).data_used); + +// testing record query then delete +const record = ( + await sdk.query({ + query: { KeyIs: "deno_test" }, + mode: { One: 0 }, + }) +).One; + +console.log(record); +await sdk.remove(record.id); +console.log("record deleted"); +console.log("data used:", (await sdk.app()).data_used); From 02f3d089260a55d98dddbd66368d30c83378105b Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 18 Jul 2025 14:52:00 -0400 Subject: [PATCH 71/93] add: developer pass --- crates/app/src/assets.rs | 7 +- crates/app/src/main.rs | 15 +- crates/app/src/public/html/auth/register.lisp | 2 +- crates/app/src/public/html/components.lisp | 60 ++++- crates/app/src/public/html/forge/home.lisp | 4 +- crates/app/src/public/html/profile/base.lisp | 12 +- .../app/src/public/html/profile/settings.lisp | 58 +++-- .../routes/api/v1/auth/connections/stripe.rs | 225 ++++++++++++++---- crates/core/src/config.rs | 12 +- crates/core/src/database/app_data.rs | 18 +- crates/core/src/database/apps.rs | 13 +- crates/core/src/database/common.rs | 2 +- crates/core/src/database/communities.rs | 7 +- crates/core/src/model/apps.rs | 21 +- 14 files changed, 355 insertions(+), 101 deletions(-) diff --git a/crates/app/src/assets.rs b/crates/app/src/assets.rs index 50b256b..1504ba5 100644 --- a/crates/app/src/assets.rs +++ b/crates/app/src/assets.rs @@ -15,7 +15,7 @@ use tetratto_core::{ config::Config, model::{ auth::{DefaultTimelineChoice, User}, - permissions::FinePermission, + permissions::{FinePermission, SecondaryPermission}, }, }; use tetratto_l10n::LangFile; @@ -516,6 +516,11 @@ pub(crate) async fn initial_context( "is_supporter", &ua.permissions.check(FinePermission::SUPPORTER), ); + ctx.insert( + "has_developer_pass", + &ua.secondary_permissions + .check(SecondaryPermission::DEVELOPER_PASS), + ); ctx.insert("home", &ua.settings.default_timeline.relative_url()); } else { ctx.insert("is_helper", &false); diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index f7f7c06..bf74220 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -9,7 +9,10 @@ mod sanitize; use assets::{init_dirs, write_assets}; use stripe::Client as StripeClient; -use tetratto_core::model::{permissions::FinePermission, uploads::CustomEmoji}; +use tetratto_core::model::{ + permissions::{FinePermission, SecondaryPermission}, + uploads::CustomEmoji, +}; pub use tetratto_core::*; use axum::{ @@ -55,6 +58,15 @@ fn check_supporter(value: &Value, _: &HashMap) -> tera::Result) -> tera::Result { + Ok( + SecondaryPermission::from_bits(value.as_u64().unwrap() as u32) + .unwrap() + .check(SecondaryPermission::DEVELOPER_PASS) + .into(), + ) +} + fn check_staff_badge(value: &Value, _: &HashMap) -> tera::Result { Ok(FinePermission::from_bits(value.as_u64().unwrap() as u32) .unwrap() @@ -109,6 +121,7 @@ async fn main() { tera.register_filter("markdown", render_markdown); tera.register_filter("color", color_escape); tera.register_filter("has_supporter", check_supporter); + tera.register_filter("has_dev_pass", check_dev_pass); tera.register_filter("has_staff_badge", check_staff_badge); tera.register_filter("has_banned", check_banned); tera.register_filter("remove_script_tags", remove_script_tags); diff --git a/crates/app/src/public/html/auth/register.lisp b/crates/app/src/public/html/auth/register.lisp index aa94c3d..05b3d71 100644 --- a/crates/app/src/public/html/auth/register.lisp +++ b/crates/app/src/public/html/auth/register.lisp @@ -118,7 +118,7 @@ ("class" "hidden lowered card w-full no_p_margin") ("ui_ident" "purchase_help") (b (text "What does \"Purchase account\" mean?")) - (p (text "Your account will be created, but you cannot use it until you activate it for {{ config.stripe.supporter_price_text }}.")) + (p (text "Your account will be created, but you cannot use it until you activate it for {{ config.stripe.price_texts.supporter }}.")) (p (text "Alternatively, you can provide an invite code to create your account for free."))) (text "{%- endif %}") (button diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 516fe2a..982c099 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -1452,7 +1452,9 @@ }); })();")) -(text "{%- endmacro %} {% macro supporter_ad(body=\"\") -%} {% if config.stripe and not is_supporter %}") +(text "{%- endmacro %}") + +(text "{% macro supporter_ad(body=\"\") -%} {% if config.stripe and not is_supporter %}") (div ("class" "card w-full supporter_ad") ("ui_ident" "supporter_ad") @@ -1472,8 +1474,9 @@ (text "{{ icon \"heart\" }}") (span (text "{{ text \"general:action.become_supporter\" }}"))))) +(text "{%- endif %} {%- endmacro %}") -(text "{%- endif %} {%- endmacro %} {% macro create_post_options() -%}") +(text "{% macro create_post_options() -%}") (div ("class" "flex gap-2 flex-wrap") (text "{{ components::emoji_picker(element_id=\"content\", render_dialog=true) }} {% if not quoting -%} {% if is_supporter -%} {{ components::file_picker(files_list_id=\"files_list\") }} {%- endif %} {%- endif %}") @@ -2358,10 +2361,6 @@ (text "Save infinite post drafts")) (li (text "Ability to search through all posts")) - (li - (text "Ability to create forges")) - (li - (text "Create more than 1 app")) (li (text "Create up to 10 stack blocks")) (li @@ -2388,15 +2387,13 @@ ("href" "{{ config.stripe.payment_links.supporter }}?client_reference_id={{ user.id }}") ("class" "button") ("target" "_blank") - (text "Become a supporter ({{ config.stripe.supporter_price_text }})")) + (text "Become a supporter ({{ config.stripe.price_texts.supporter }})")) (span ("class" "fade") (text "Please use your") (b (text " real email ")) - (text "when - completing payment. It is required to manage - your billing settings.")) + (text "when completing payment. It is required to manage your billing settings.")) (text "{% if config.security.enable_invite_codes -%}") (span @@ -2405,3 +2402,46 @@ (b (text "1: ")) (text "After your account is at least 1 month old")) (text "{%- endif %}") (text "{%- endmacro %}") + +(text "{% macro get_developer_pass_button() -%}") +(p + (text "You currently do not hold a developer pass. With a developer pass, you'll get:")) +(ul + ("style" "margin-bottom: var(--pad-4)") + (li + (text "Increased app storage limit (500 KB->5 MB)")) + (li + (text "Ability to create forges")) + (li + (text "Ability to create more than 1 app")) + (li + (text "Developer pass profile badge"))) +(a + ("href" "{{ config.stripe.payment_links.dev_pass }}?client_reference_id={{ user.id }}") + ("class" "button") + ("target" "_blank") + (text "Continue ({{ config.stripe.price_texts.dev_pass }})")) +(span + ("class" "fade") + (text "Please use your") + (b + (text " real email ")) + (text "when completing payment. It is required to manage your billing settings. If you're already a supporter, please use the same email you used there.")) +(text "{%- endmacro %}") + +(text "{% macro developer_pass_ad(body) -%} {% if config.stripe and not has_developer_pass %}") +(div + ("class" "card w-full supporter_ad") + ("ui_ident" "supporter_ad") + ("onclick" "window.location.href = '/settings#/account/billing'") + (div + ("class" "card w-full flex flex-wrap items-center gap-2 justify-between") + (b + (text "{{ body }}")) + (a + ("href" "/settings#/account/billing") + ("class" "button small") + (icon (text "arrow-right")) + (span + (str (text "dialog:action.continue")))))) +(text "{%- endif %} {%- endmacro %}") diff --git a/crates/app/src/public/html/forge/home.lisp b/crates/app/src/public/html/forge/home.lisp index c295066..3208a63 100644 --- a/crates/app/src/public/html/forge/home.lisp +++ b/crates/app/src/public/html/forge/home.lisp @@ -6,7 +6,7 @@ (main ("class" "flex flex-col gap-2") ; create new - (text "{% if user.permissions|has_supporter -%}") + (text "{% if user.secondary_permissions|has_dev_pass -%}") (div ("class" "card-nest") (div @@ -32,7 +32,7 @@ (button (text "{{ text \"communities:action.create\" }}")))) (text "{% else %}") - (text "{{ components::supporter_ad(body=\"Become a supporter to create forges!\") }}") + (text "{{ components::developer_pass_ad(body=\"Get a developer pass to create forges!\") }}") (text "{%- endif %}") ; forge listing diff --git a/crates/app/src/public/html/profile/base.lisp b/crates/app/src/public/html/profile/base.lisp index 51f8489..7e4d6fb 100644 --- a/crates/app/src/public/html/profile/base.lisp +++ b/crates/app/src/public/html/profile/base.lisp @@ -72,19 +72,25 @@ ("style" "color: var(--color-primary);") ("class" "flex items-center") (text "{{ icon \"badge-check\" }}")) - (text "{%- endif %} {% if profile.permissions|has_supporter -%}") + (text "{%- endif %} {% if profile.permissions|has_supporter -%}") (span ("title" "Supporter") ("style" "color: var(--color-primary);") ("class" "flex items-center") (text "{{ icon \"star\" }}")) - (text "{%- endif %} {% if profile.permissions|has_staff_badge -%}") + (text "{%- endif %} {% if profile.secondary_permissions|has_dev_pass -%}") + (span + ("title" "Developer pass") + ("style" "color: var(--color-primary);") + ("class" "flex items-center") + (text "{{ icon \"id-card-lanyard\" }}")) + (text "{%- endif %} {% if profile.permissions|has_staff_badge -%}") (span ("title" "Staff") ("style" "color: var(--color-primary);") ("class" "flex items-center") (text "{{ icon \"shield-user\" }}")) - (text "{%- endif %} {% if profile.permissions|has_banned -%}") + (text "{%- endif %} {% if profile.permissions|has_banned -%}") (span ("title" "Banned") ("style" "color: var(--color-primary);") diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index c4169a6..b8e251c 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -823,6 +823,29 @@ (div ("class" "card flex flex-col gap-2 secondary") (text "{% if config.stripe -%}") + (text "{% if has_developer_pass or is_supporter -%}") + (div + ("class" "card-nest") + ("ui_ident" "supporter_card") + (div + ("class" "card small flex items-center gap-2") + (icon (text "credit-card")) + (b + (text "Manage billing"))) + (div + ("class" "card flex flex-col gap-2") + (p + (text "You currently have a subscription! You can manage your billing information below. ") + (b + (text "Please use your email address you supplied when paying to log into the billing portal.")) + (text " You can manage all of your active subscriptions through this page.")) + (a + ("href" "{{ config.stripe.billing_portal_url }}") + ("class" "button lowered") + ("target" "_blank") + (text "Manage billing")))) + (text "{%- endif %}") + (div ("class" "card-nest") ("ui_ident" "supporter_card") @@ -832,28 +855,33 @@ (b (text "Supporter status"))) (div - ("class" "card flex flex-col gap-2") + ("class" "card flex flex-col gap-2 no_p_margin") (text "{% if is_supporter -%}") (p (text "You ") - (b - (text "are ")) - (text "a supporter! Thank you for all - that you do. You can manage your billing - information below.") - (b - (text "Please use your email address you supplied - when paying to login to the billing - portal."))) - (a - ("href" "{{ config.stripe.billing_portal_url }}") - ("class" "button lowered") - ("target" "_blank") - (text "Manage billing")) + (b (text "are ")) + (text "a supporter! Thank you for all that you do.")) (text "{% else %}") (text "{{ components::become_supporter_button() }}") (text "{%- endif %}"))) + (div + ("class" "card-nest") + ("ui_ident" "supporter_card") + (div + ("class" "card small flex items-center gap-2") + (icon (text "id-card-lanyard")) + (b + (text "Developer pass status"))) + (div + ("class" "card flex flex-col gap-2 no_p_margin") + (text "{% if has_developer_pass -%}") + (p + (text "You currently have a developer pass!")) + (text "{% else %}") + (text "{{ components::get_developer_pass_button() }}") + (text "{%- endif %}"))) + (text "{% if user.was_purchased and user.invite_code == 0 -%}") (form ("class" "card w-full lowered flex flex-col gap-2") 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 b9964f6..1d5be0d 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -3,9 +3,9 @@ use std::{str::FromStr, time::Duration}; use axum::{http::HeaderMap, response::IntoResponse, Extension, Json}; use axum_extra::extract::CookieJar; use tetratto_core::model::{ - auth::{User, Notification}, + auth::{Notification, User}, moderation::AuditLogEntry, - permissions::FinePermission, + permissions::{FinePermission, SecondaryPermission}, ApiReturn, Error, }; use stripe::{EventObject, EventType}; @@ -205,6 +205,43 @@ pub async fn stripe_webhook( { return Json(e.into()); } + } else if product_id == stripe_cnf.product_ids.dev_pass { + // dev pass + tracing::info!("found subscription user in {retries} tries"); + + if user + .secondary_permissions + .check(SecondaryPermission::DEVELOPER_PASS) + { + return Json(ApiReturn { + ok: true, + message: "Already applied".to_string(), + payload: (), + }); + } + + tracing::info!("invoice {} (stripe: {})", user.id, customer_id); + let new_user_permissions = + user.secondary_permissions | SecondaryPermission::DEVELOPER_PASS; + + if let Err(e) = data + .update_user_secondary_role(user.id, new_user_permissions, user.clone(), true) + .await + { + return Json(e.into()); + } + + if let Err(e) = data + .create_notification(Notification::new( + "Welcome new developer!".to_string(), + "Thank you for your support! Your account has been updated with your new role." + .to_string(), + user.id, + )) + .await + { + return Json(e.into()); + } } else { tracing::error!( "received an invalid stripe product id, please check config.stripe.product_ids" @@ -220,34 +257,72 @@ pub async fn stripe_webhook( }; let customer_id = subscription.customer.id(); + let product_id = subscription + .items + .data + .get(0) + .as_ref() + .expect("cancelled nothing?") + .plan + .as_ref() + .expect("no subscription plan?") + .product + .as_ref() + .expect("plan with no product?") + .id() + .to_string(); let user = match data.get_user_by_stripe_id(customer_id.as_str()).await { Ok(ua) => ua, Err(e) => return Json(e.into()), }; - tracing::info!("unsubscribe {} (stripe: {})", user.id, customer_id); - let new_user_permissions = user.permissions - FinePermission::SUPPORTER; + // handle each subscription item + if product_id == stripe_cnf.product_ids.supporter { + // supporter + tracing::info!("unsubscribe {} (stripe: {})", user.id, customer_id); + let new_user_permissions = user.permissions - FinePermission::SUPPORTER; - if let Err(e) = data - .update_user_role(user.id, new_user_permissions, user.clone(), true) - .await - { - return Json(e.into()); - } - - if data.0.0.security.enable_invite_codes && user.was_purchased && user.invite_code == 0 - { - // user doesn't come from an invite code, and is a purchased account - // this means their account must be locked if they stop paying if let Err(e) = data - .update_user_awaiting_purchased_status(user.id, true, user.clone(), false) + .update_user_role(user.id, new_user_permissions, user.clone(), true) .await { return Json(e.into()); } + + if data.0.0.security.enable_invite_codes + && user.was_purchased + && user.invite_code == 0 + { + // user doesn't come from an invite code, and is a purchased account + // this means their account must be locked if they stop paying + if let Err(e) = data + .update_user_awaiting_purchased_status(user.id, true, user.clone(), false) + .await + { + return Json(e.into()); + } + } + } else if product_id == stripe_cnf.product_ids.dev_pass { + // dev pass + tracing::info!("unsubscribe {} (stripe: {})", user.id, customer_id); + let new_user_permissions = + user.secondary_permissions - SecondaryPermission::DEVELOPER_PASS; + + if let Err(e) = data + .update_user_secondary_role(user.id, new_user_permissions, user.clone(), true) + .await + { + return Json(e.into()); + } + } else { + tracing::error!( + "received an invalid stripe product id, please check config.stripe.product_ids" + ); + return Json(Error::MiscError("Unknown product ID".to_string()).into()); } + // send notification if let Err(e) = data .create_notification(Notification::new( "Sorry to see you go... :(".to_string(), @@ -269,46 +344,112 @@ pub async fn stripe_webhook( let customer_id = invoice.customer.expect("TETRATTO_STRIPE_NO_CUSTOMER").id(); + let item = match invoice.lines.as_ref().expect("no line items?").data.get(0) { + Some(i) => i, + None => { + if let Err(e) = data + .create_audit_log_entry(AuditLogEntry::new( + 0, + format!("too few invoice line items: stripe {customer_id}"), + )) + .await + { + return Json(e.into()); + } + + return Json(Error::MiscError("Too few line items".to_string()).into()); + } + }; + + let product_id = item + .price + .as_ref() + .unwrap() + .product + .as_ref() + .unwrap() + .id() + .to_string(); + let user = match data.get_user_by_stripe_id(customer_id.as_str()).await { Ok(ua) => ua, Err(e) => return Json(e.into()), }; - if !user.permissions.check(FinePermission::SUPPORTER) { - // the user isn't currently a supporter, there's no reason to send this notification - return Json(ApiReturn { - ok: true, - message: "Acceptable".to_string(), - payload: (), - }); - } + // handle each subscription item + if product_id == stripe_cnf.product_ids.supporter { + // supporter + if !user.permissions.check(FinePermission::SUPPORTER) { + // the user isn't currently a supporter, there's no reason to send this notification + return Json(ApiReturn { + ok: true, + message: "Acceptable".to_string(), + payload: (), + }); + } - tracing::info!( - "unsubscribe (pay fail) {} (stripe: {})", - user.id, - customer_id - ); - let new_user_permissions = user.permissions - FinePermission::SUPPORTER; + tracing::info!( + "unsubscribe (pay fail) {} (stripe: {})", + user.id, + customer_id + ); + let new_user_permissions = user.permissions - FinePermission::SUPPORTER; - if let Err(e) = data - .update_user_role(user.id, new_user_permissions, user.clone(), true) - .await - { - return Json(e.into()); - } - - if data.0.0.security.enable_invite_codes && user.was_purchased && user.invite_code == 0 - { - // user doesn't come from an invite code, and is a purchased account - // this means their account must be locked if they stop paying if let Err(e) = data - .update_user_awaiting_purchased_status(user.id, true, user.clone(), false) + .update_user_role(user.id, new_user_permissions, user.clone(), true) .await { return Json(e.into()); } + + if data.0.0.security.enable_invite_codes + && user.was_purchased + && user.invite_code == 0 + { + // user doesn't come from an invite code, and is a purchased account + // this means their account must be locked if they stop paying + if let Err(e) = data + .update_user_awaiting_purchased_status(user.id, true, user.clone(), false) + .await + { + return Json(e.into()); + } + } + } else if product_id == stripe_cnf.product_ids.dev_pass { + // dev pass + if !user + .secondary_permissions + .check(SecondaryPermission::DEVELOPER_PASS) + { + return Json(ApiReturn { + ok: true, + message: "Acceptable".to_string(), + payload: (), + }); + } + + tracing::info!( + "unsubscribe (pay fail) {} (stripe: {})", + user.id, + customer_id + ); + let new_user_permissions = + user.secondary_permissions - SecondaryPermission::DEVELOPER_PASS; + + if let Err(e) = data + .update_user_secondary_role(user.id, new_user_permissions, user.clone(), true) + .await + { + return Json(e.into()); + } + } else { + tracing::error!( + "received an invalid stripe product id, please check config.stripe.product_ids" + ); + return Json(Error::MiscError("Unknown product ID".to_string()).into()); } + // send notification if let Err(e) = data .create_notification(Notification::new( "It seems your recent payment has failed :(".to_string(), diff --git a/crates/core/src/config.rs b/crates/core/src/config.rs index d695c39..e1637b1 100644 --- a/crates/core/src/config.rs +++ b/crates/core/src/config.rs @@ -194,22 +194,30 @@ pub struct StripeConfig { /// /// pub billing_portal_url: String, - /// The text representation of the price of supporter. (like `$4 USD`) - pub supporter_price_text: String, + /// The text representation of prices. (like `$4 USD`) + pub price_texts: StripePriceTexts, /// Product IDs from the Stripe dashboard. /// /// These are checked when we receive a webhook to ensure we provide the correct product. pub product_ids: StripeProductIds, } +#[derive(Clone, Serialize, Deserialize, Debug, Default)] +pub struct StripePriceTexts { + pub supporter: String, + pub dev_pass: String, +} + #[derive(Clone, Serialize, Deserialize, Debug, Default)] pub struct StripePaymentLinks { pub supporter: String, + pub dev_pass: String, } #[derive(Clone, Serialize, Deserialize, Debug, Default)] pub struct StripeProductIds { pub supporter: String, + pub dev_pass: String, } /// Manuals config (search help, etc) diff --git a/crates/core/src/database/app_data.rs b/crates/core/src/database/app_data.rs index 7ddece5..d6225fc 100644 --- a/crates/core/src/database/app_data.rs +++ b/crates/core/src/database/app_data.rs @@ -1,5 +1,5 @@ use oiseau::cache::Cache; -use crate::model::apps::{AppDataQuery, AppDataQueryResult, AppDataSelectMode, AppDataSelectQuery}; +use crate::model::apps::{AppDataQuery, AppDataQueryResult, AppDataSelectMode}; use crate::model::{apps::AppData, permissions::FinePermission, Error, Result}; use crate::{auto_method, DataManager}; use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; @@ -51,13 +51,7 @@ impl DataManager { Err(e) => return Err(Error::DatabaseConnection(e.to_string())), }; - let query_str = query.to_string().replace( - "%q%", - &match query.query { - AppDataSelectQuery::KeyIs(_) => format!("k = $1"), - AppDataSelectQuery::LikeJson(_, _) => format!("v LIKE $1"), - }, - ); + let query_str = query.to_string().replace("%q%", &query.query.selector()); let res = match query.mode { AppDataSelectMode::One(_) => AppDataQueryResult::One( @@ -98,13 +92,7 @@ impl DataManager { let query_str = query .to_string() - .replace( - "%q%", - &match query.query { - AppDataSelectQuery::KeyIs(_) => format!("k = $1"), - AppDataSelectQuery::LikeJson(_, _) => format!("v LIKE $1"), - }, - ) + .replace("%q%", &query.query.selector()) .replace("SELECT * FROM", "SELECT id FROM"); if let Err(e) = execute!( diff --git a/crates/core/src/database/apps.rs b/crates/core/src/database/apps.rs index 1fa5f31..b605cb6 100644 --- a/crates/core/src/database/apps.rs +++ b/crates/core/src/database/apps.rs @@ -3,7 +3,7 @@ use crate::model::{ apps::{AppQuota, ThirdPartyApp}, auth::User, oauth::AppScope, - permissions::FinePermission, + permissions::{FinePermission, SecondaryPermission}, Error, Result, }; use crate::{auto_method, DataManager}; @@ -72,10 +72,15 @@ impl DataManager { // check number of apps let owner = self.get_user_by_id(data.owner).await?; - if !owner.permissions.check(FinePermission::SUPPORTER) { - let apps = self.get_apps_by_owner(data.owner).await?; + if !owner + .secondary_permissions + .check(SecondaryPermission::DEVELOPER_PASS) + { + let apps = self + .get_table_row_count_where("apps", &format!("owner = {}", owner.id)) + .await? as usize; - if apps.len() >= Self::MAXIMUM_FREE_APPS { + if apps >= Self::MAXIMUM_FREE_APPS { return Err(Error::MiscError( "You already have the maximum number of apps you can have".to_string(), )); diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 075d0f7..c0b1b59 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -85,7 +85,7 @@ impl DataManager { let res = query_row!( &conn, - &format!("SELECT COUNT(*)::int FROM {} {}", table, r#where), + &format!("SELECT COUNT(*)::int FROM {} WHERE {}", table, r#where), params![], |x| Ok(x.get::(0)) ); diff --git a/crates/core/src/database/communities.rs b/crates/core/src/database/communities.rs index fa7c234..df107e9 100644 --- a/crates/core/src/database/communities.rs +++ b/crates/core/src/database/communities.rs @@ -3,6 +3,7 @@ use super::common::NAME_REGEX; use oiseau::cache::Cache; use crate::model::communities::{CommunityContext, CommunityJoinAccess, CommunityMembership}; use crate::model::communities_permissions::CommunityPermission; +use crate::model::permissions::SecondaryPermission; use crate::model::{ Error, Result, auth::User, @@ -255,7 +256,11 @@ impl DataManager { // check is_forge // only supporters can CREATE forge communities... anybody can contribute to them - if data.is_forge && !owner.permissions.check(FinePermission::SUPPORTER) { + if data.is_forge + && !owner + .secondary_permissions + .check(SecondaryPermission::DEVELOPER_PASS) + { return Err(Error::RequiresSupporter); } diff --git a/crates/core/src/model/apps.rs b/crates/core/src/model/apps.rs index bca5c81..2482c5f 100644 --- a/crates/core/src/model/apps.rs +++ b/crates/core/src/model/apps.rs @@ -147,6 +147,8 @@ impl AppData { #[derive(Serialize, Deserialize, Debug, Clone)] pub enum AppDataSelectQuery { KeyIs(String), + KeyLike(String), + ValueLike(String), LikeJson(String, String), } @@ -154,11 +156,24 @@ impl Display for AppDataSelectQuery { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&match self { Self::KeyIs(k) => k.to_owned(), + Self::KeyLike(k) => k.to_owned(), + Self::ValueLike(v) => v.to_owned(), Self::LikeJson(k, v) => format!("%\"{k}\":\"{v}\"%"), }) } } +impl AppDataSelectQuery { + pub fn selector(&self) -> String { + match self { + AppDataSelectQuery::KeyIs(_) => format!("k = $1"), + AppDataSelectQuery::KeyLike(_) => format!("k LIKE $1"), + AppDataSelectQuery::ValueLike(_) => format!("v LIKE $1"), + AppDataSelectQuery::LikeJson(_, _) => format!("v LIKE $1"), + } + } +} + #[derive(Serialize, Deserialize, Debug, Clone)] pub enum AppDataSelectMode { /// Select a single row (with offset). @@ -179,14 +194,14 @@ impl Display for AppDataSelectMode { Self::One(offset) => format!("LIMIT 1 OFFSET {offset}"), Self::Many(limit, offset) => { format!( - "LIMIT {} OFFSET {offset}", - if *limit > 1024 { 1024 } else { *limit } + "ORDER BY k DESC LIMIT {} OFFSET {offset}", + if *limit > 24 { 24 } else { *limit } ) } Self::ManyJson(order_by_top_level_key, limit, offset) => { format!( "ORDER BY v::jsonb->>'{order_by_top_level_key}' DESC LIMIT {} OFFSET {offset}", - if *limit > 1024 { 1024 } else { *limit } + if *limit > 24 { 24 } else { *limit } ) } }) From 884a89904ebdb92e93c06888651bbd9970781c25 Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 18 Jul 2025 20:04:26 -0400 Subject: [PATCH 72/93] add: channel mutes --- crates/app/src/langs/en-US.toml | 2 + crates/app/src/public/html/chats/app.lisp | 24 ++++++++ .../app/src/public/html/chats/channels.lisp | 16 +++++ crates/app/src/public/html/components.lisp | 6 ++ crates/app/src/public/js/atto.js | 2 +- .../src/routes/api/v1/channels/channels.rs | 59 +++++++++++++++++++ .../src/routes/api/v1/communities/emojis.rs | 2 + crates/app/src/routes/api/v1/mod.rs | 8 +++ crates/core/src/database/auth.rs | 7 ++- crates/core/src/database/common.rs | 1 + crates/core/src/database/drivers/common.rs | 1 + .../src/database/drivers/sql/create_users.sql | 3 +- .../drivers/sql/version_migrations.sql | 3 + crates/core/src/database/messages.rs | 5 ++ crates/core/src/model/auth.rs | 4 ++ crates/core/src/model/oauth.rs | 2 + crates/core/src/model/uploads.rs | 11 +++- 17 files changed, 149 insertions(+), 7 deletions(-) create mode 100644 crates/core/src/database/drivers/sql/version_migrations.sql diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index c77bfd5..5a2b0e1 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -222,6 +222,8 @@ version = "1.0.0" "chats:action.add_someone" = "Add someone" "chats:action.kick_member" = "Kick member" "chats:action.mention_user" = "Mention user" +"chats:action.mute" = "Mute" +"chats:action.unmute" = "Unmute" "stacks:link.stacks" = "Stacks" "stacks:label.my_stacks" = "My stacks" diff --git a/crates/app/src/public/html/chats/app.lisp b/crates/app/src/public/html/chats/app.lisp index 0dc16c3..a5bf139 100644 --- a/crates/app/src/public/html/chats/app.lisp +++ b/crates/app/src/public/html/chats/app.lisp @@ -210,6 +210,30 @@ }); }; + globalThis.mute_channel = async (id, mute = true) => { + await trigger(\"atto::debounce\", [\"channels::mute\"]); + fetch(`/api/v1/channels/${id}/mute`, { + method: mute ? \"POST\" : \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + if (mute) { + document.querySelector(`[ui_ident=channel\\\\.mute\\\\:${id}]`).classList.add(\"hidden\"); + document.querySelector(`[ui_ident=channel\\\\.unmute\\\\:${id}]`).classList.remove(\"hidden\"); + } else { + document.querySelector(`[ui_ident=channel\\\\.mute\\\\:${id}]`).classList.remove(\"hidden\"); + document.querySelector(`[ui_ident=channel\\\\.unmute\\\\:${id}]`).classList.add(\"hidden\"); + } + } + }); + }; + globalThis.update_channel_title = async (id) => { await trigger(\"atto::debounce\", [\"channels::update_title\"]); const title = await trigger(\"atto::prompt\", [\"New channel title:\"]); diff --git a/crates/app/src/public/html/chats/channels.lisp b/crates/app/src/public/html/chats/channels.lisp index a87dbeb..f789d32 100644 --- a/crates/app/src/public/html/chats/channels.lisp +++ b/crates/app/src/public/html/chats/channels.lisp @@ -31,6 +31,22 @@ (text "{{ icon \"user-plus\" }}") (span (text "{{ text \"chats:action.add_someone\" }}"))) + ; mute/unmute + (button + ("class" "lowered small {% if channel.id in user.channel_mutes -%} hidden {%- endif %}") + ("ui_ident" "channel.mute:{{ channel.id }}") + ("onclick" "mute_channel('{{ channel.id }}')") + (icon (text "bell-off")) + (span + (str (text "chats:action.mute")))) + (button + ("class" "lowered small {% if not channel.id in user.channel_mutes -%} hidden {%- endif %}") + ("ui_ident" "channel.unmute:{{ channel.id }}") + ("onclick" "mute_channel('{{ channel.id }}', false)") + (icon (text "bell-ring")) + (span + (str (text "chats:action.unmute")))) + ; ... (text "{%- endif %}") (button ("class" "lowered small") diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 982c099..d3c1a7f 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -113,6 +113,12 @@ ("style" "color: var(--color-primary)") ("class" "flex items-center") (text "{{ icon \"badge-check\" }}")) + (text "{%- endif %} {% if user.permissions|has_staff_badge -%}") + (span + ("title" "Staff") + ("style" "color: var(--color-primary);") + ("class" "flex items-center") + (text "{{ icon \"shield-user\" }}")) (text "{%- endif %}")) (text "{%- endif %} {%- endmacro %} {% macro repost(repost, post, owner, secondary=false, community=false, show_community=true, can_manage_post=false) -%}") (div diff --git a/crates/app/src/public/js/atto.js b/crates/app/src/public/js/atto.js index d8ffb86..9c556cf 100644 --- a/crates/app/src/public/js/atto.js +++ b/crates/app/src/public/js/atto.js @@ -689,7 +689,7 @@ media_theme_pref(); }); self.define("hooks::check_message_reactions", async ({ $ }) => { - const observer = $.offload_work_to_client_when_in_view( + const observer = await $.offload_work_to_client_when_in_view( async (element) => { const reactions = await ( await fetch( diff --git a/crates/app/src/routes/api/v1/channels/channels.rs b/crates/app/src/routes/api/v1/channels/channels.rs index e3ead5a..0251e18 100644 --- a/crates/app/src/routes/api/v1/channels/channels.rs +++ b/crates/app/src/routes/api/v1/channels/channels.rs @@ -293,3 +293,62 @@ pub async fn get_request( Err(e) => Json(e.into()), } } + +pub async fn mute_channel_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageChannelMutes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if user.channel_mutes.contains(&id) { + return Json(Error::MiscError("Channel already muted".to_string()).into()); + } + + user.channel_mutes.push(id); + match data + .update_user_channel_mutes(user.id, user.channel_mutes) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Channel muted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +pub async fn unmute_channel_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageChannelMutes) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + let pos = match user.channel_mutes.iter().position(|x| *x == id) { + Some(x) => x, + None => return Json(Error::MiscError("Channel not muted".to_string()).into()), + }; + + user.channel_mutes.remove(pos); + match data + .update_user_channel_mutes(user.id, user.channel_mutes) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Channel muted".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} diff --git a/crates/app/src/routes/api/v1/communities/emojis.rs b/crates/app/src/routes/api/v1/communities/emojis.rs index 1db4c0c..1d2400f 100644 --- a/crates/app/src/routes/api/v1/communities/emojis.rs +++ b/crates/app/src/routes/api/v1/communities/emojis.rs @@ -17,6 +17,8 @@ use tetratto_core::model::{ /// Expand a unicode emoji into its Gemoji shortcode. pub async fn get_emoji_shortcode(emoji: String) -> impl IntoResponse { match emoji.as_str() { + // matches `CustomEmoji::replace` + "💯" => "100".to_string(), "👍" => "thumbs_up".to_string(), "👎" => "thumbs_down".to_string(), _ => match emojis::get(&emoji) { diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 3b6e61e..2420007 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -570,6 +570,14 @@ pub fn routes() -> Router { "/channels/{id}/kick", post(channels::channels::kick_member_request), ) + .route( + "/channels/{id}/mute", + post(channels::channels::mute_channel_request), + ) + .route( + "/channels/{id}/mute", + delete(channels::channels::unmute_channel_request), + ) .route("/channels/{id}", get(channels::channels::get_request)) .route( "/channels/community/{id}", diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 65c5307..64530b5 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -120,6 +120,7 @@ impl DataManager { browser_session: get!(x->26(String)), seller_data: serde_json::from_str(&get!(x->27(String)).to_string()).unwrap(), ban_reason: get!(x->28(String)), + channel_mutes: serde_json::from_str(&get!(x->29(String)).to_string()).unwrap(), } } @@ -276,7 +277,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29)", + "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30)", params![ &(data.id as i64), &(data.created as i64), @@ -306,7 +307,8 @@ impl DataManager { &if data.was_purchased { 1_i32 } else { 0_i32 }, &data.browser_session, &serde_json::to_string(&data.seller_data).unwrap(), - &data.ban_reason + &data.ban_reason, + &serde_json::to_string(&data.channel_mutes).unwrap(), ] ); @@ -1004,6 +1006,7 @@ impl DataManager { auto_method!(update_user_browser_session(&str)@get_user_by_id -> "UPDATE users SET browser_session = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); auto_method!(update_user_seller_data(StripeSellerData)@get_user_by_id -> "UPDATE users SET seller_data = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(update_user_ban_reason(&str)@get_user_by_id -> "UPDATE users SET ban_reason = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); + auto_method!(update_user_channel_mutes(Vec)@get_user_by_id -> "UPDATE users SET channel_mutes = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); auto_method!(get_user_by_stripe_id(&str)@get_user_from_row -> "SELECT * FROM users WHERE stripe_id = $1" --name="user" --returns=User); auto_method!(update_user_stripe_id(&str)@get_user_by_id -> "UPDATE users SET stripe_id = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index c0b1b59..1bf00cf 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -44,6 +44,7 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap(); execute!(&conn, common::CREATE_TABLE_PRODUCTS).unwrap(); execute!(&conn, common::CREATE_TABLE_APP_DATA).unwrap(); + execute!(&conn, common::VERSION_MIGRATIONS).unwrap(); self.0 .1 diff --git a/crates/core/src/database/drivers/common.rs b/crates/core/src/database/drivers/common.rs index 2535f43..d2239a6 100644 --- a/crates/core/src/database/drivers/common.rs +++ b/crates/core/src/database/drivers/common.rs @@ -1,3 +1,4 @@ +pub const VERSION_MIGRATIONS: &str = include_str!("./sql/version_migrations.sql"); pub const CREATE_TABLE_USERS: &str = include_str!("./sql/create_users.sql"); pub const CREATE_TABLE_COMMUNITIES: &str = include_str!("./sql/create_communities.sql"); pub const CREATE_TABLE_POSTS: &str = include_str!("./sql/create_posts.sql"); diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql index 57b2078..1cbbbc8 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -27,5 +27,6 @@ CREATE TABLE IF NOT EXISTS users ( was_purchased INT NOT NULL, browser_session TEXT NOT NULL, seller_data TEXT NOT NULL, - ban_reason TEXT NOT NULL + ban_reason TEXT NOT NULL, + channel_mutes TEXT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/version_migrations.sql b/crates/core/src/database/drivers/sql/version_migrations.sql new file mode 100644 index 0000000..0f5682b --- /dev/null +++ b/crates/core/src/database/drivers/sql/version_migrations.sql @@ -0,0 +1,3 @@ +-- users channel_mutes +ALTER TABLE users +ADD COLUMN IF NOT EXISTS channel_mutes TEXT DEFAULT '[]'; diff --git a/crates/core/src/database/messages.rs b/crates/core/src/database/messages.rs index 64157f0..3acb2ee 100644 --- a/crates/core/src/database/messages.rs +++ b/crates/core/src/database/messages.rs @@ -190,6 +190,11 @@ impl DataManager { continue; } + let user = self.get_user_by_id(member).await?; + if user.channel_mutes.contains(&channel.id) { + continue; + } + let mut notif = Notification::new( "You've received a new message!".to_string(), format!( diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index a9dadf1..ffcb264 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -86,6 +86,9 @@ pub struct User { /// The reason the user was banned. #[serde(default)] pub ban_reason: String, + /// IDs of channels the user has muted. + #[serde(default)] + pub channel_mutes: Vec, } pub type UserConnections = @@ -387,6 +390,7 @@ impl User { browser_session: String::new(), seller_data: StripeSellerData::default(), ban_reason: String::new(), + channel_mutes: Vec::new(), } } diff --git a/crates/core/src/model/oauth.rs b/crates/core/src/model/oauth.rs index 72884ae..aa0e00a 100644 --- a/crates/core/src/model/oauth.rs +++ b/crates/core/src/model/oauth.rs @@ -144,6 +144,8 @@ pub enum AppScope { UserManageServices, /// Manage the user's products. UserManageProducts, + /// Manage the user's channel mutes. + UserManageChannelMutes, /// Edit posts created by the user. UserEditPosts, /// Edit drafts created by the user. diff --git a/crates/core/src/model/uploads.rs b/crates/core/src/model/uploads.rs index 9ab2d97..bed6dad 100644 --- a/crates/core/src/model/uploads.rs +++ b/crates/core/src/model/uploads.rs @@ -131,9 +131,14 @@ impl CustomEmoji { if emoji.1 == 0 { out = out.replace( &emoji.0, - match emojis::get_by_shortcode(&emoji.2) { - Some(e) => e.as_str(), - None => &emoji.0, + match emoji.2.as_str() { + "100" => "💯", + "thumbs_up" => "👍", + "thumbs_down" => "👎", + _ => match emojis::get_by_shortcode(&emoji.2) { + Some(e) => e.as_str(), + None => &emoji.0, + }, }, ); } else { From 0138bf4cd45db76adde975de4e9646e181d7804c Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 19 Jul 2025 00:44:12 -0400 Subject: [PATCH 73/93] add: user requests in js app sdk --- Cargo.lock | 1 + crates/app/Cargo.toml | 2 +- crates/app/src/cookie.rs | 68 +++++++ crates/app/src/main.rs | 1 + .../app/src/public/html/developer/link.lisp | 1 + crates/app/src/public/js/app_sdk.js | 170 +++++++++++++++++- crates/app/src/routes/api/v1/apps.rs | 2 +- .../routes/api/v1/auth/connections/last_fm.rs | 2 +- .../src/routes/api/v1/auth/connections/mod.rs | 2 +- .../routes/api/v1/auth/connections/spotify.rs | 2 +- .../routes/api/v1/auth/connections/stripe.rs | 2 +- crates/app/src/routes/api/v1/auth/images.rs | 2 +- crates/app/src/routes/api/v1/auth/ipbans.rs | 2 +- crates/app/src/routes/api/v1/auth/mod.rs | 2 +- crates/app/src/routes/api/v1/auth/profile.rs | 2 +- crates/app/src/routes/api/v1/auth/social.rs | 2 +- .../src/routes/api/v1/auth/user_warnings.rs | 2 +- .../src/routes/api/v1/channels/channels.rs | 2 +- .../api/v1/channels/message_reactions.rs | 2 +- .../src/routes/api/v1/channels/messages.rs | 2 +- .../routes/api/v1/communities/communities.rs | 2 +- .../src/routes/api/v1/communities/drafts.rs | 2 +- .../src/routes/api/v1/communities/emojis.rs | 2 +- .../src/routes/api/v1/communities/images.rs | 2 +- .../src/routes/api/v1/communities/posts.rs | 2 +- .../routes/api/v1/communities/questions.rs | 2 +- crates/app/src/routes/api/v1/domains.rs | 2 +- crates/app/src/routes/api/v1/journals.rs | 2 +- crates/app/src/routes/api/v1/notes.rs | 2 +- crates/app/src/routes/api/v1/notifications.rs | 2 +- crates/app/src/routes/api/v1/products.rs | 2 +- crates/app/src/routes/api/v1/reactions.rs | 2 +- crates/app/src/routes/api/v1/reports.rs | 2 +- crates/app/src/routes/api/v1/requests.rs | 2 +- crates/app/src/routes/api/v1/services.rs | 2 +- crates/app/src/routes/api/v1/stacks.rs | 2 +- crates/app/src/routes/api/v1/uploads.rs | 2 +- crates/app/src/routes/api/v1/util.rs | 2 +- crates/app/src/routes/pages/auth.rs | 2 +- crates/app/src/routes/pages/chats.rs | 2 +- crates/app/src/routes/pages/communities.rs | 2 +- crates/app/src/routes/pages/developer.rs | 2 +- crates/app/src/routes/pages/forge.rs | 2 +- crates/app/src/routes/pages/journals.rs | 2 +- crates/app/src/routes/pages/littleweb.rs | 2 +- crates/app/src/routes/pages/marketplace.rs | 2 +- crates/app/src/routes/pages/misc.rs | 2 +- crates/app/src/routes/pages/mod.rs | 2 +- crates/app/src/routes/pages/mod_panel.rs | 2 +- crates/app/src/routes/pages/profile.rs | 2 +- crates/app/src/routes/pages/stacks.rs | 2 +- crates/core/src/model/apps.rs | 2 +- 52 files changed, 282 insertions(+), 53 deletions(-) create mode 100644 crates/app/src/cookie.rs diff --git a/Cargo.lock b/Cargo.lock index 8ea8eb1..614d3bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3276,6 +3276,7 @@ dependencies = [ "axum-extra", "cf-turnstile", "contrasted", + "cookie", "emojis", "futures-util", "image", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index f9eb7d4..170d252 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -22,7 +22,6 @@ ammonia = "4.1.1" tetratto-shared = { path = "../shared" } tetratto-core = { path = "../core" } tetratto-l10n = { path = "../l10n" } - image = "0.25.6" reqwest = { version = "0.12.22", features = ["json", "stream"] } regex = "1.11.1" @@ -42,3 +41,4 @@ async-stripe = { version = "0.41.0", features = [ emojis = "0.7.0" webp = "0.3.0" nanoneo = "0.2.0" +cookie = "0.18.1" diff --git a/crates/app/src/cookie.rs b/crates/app/src/cookie.rs new file mode 100644 index 0000000..45fd9a4 --- /dev/null +++ b/crates/app/src/cookie.rs @@ -0,0 +1,68 @@ +use std::convert::Infallible; +use axum::{ + extract::FromRequestParts, + http::{request::Parts, HeaderMap}, +}; +use cookie::{Cookie, CookieJar as CookieCookieJar}; + +/// This is required because "Cookie" his a forbidden header for some fucking reason. +/// Stupidest thing I've ever encountered in JavaScript, absolute fucking insanity. +/// +/// Anyway, most of this shit is just from the original source for axum_extra::extract::CookieJar, +/// just edited to use X-Cookie instead. +/// +/// Stuff from axum_extra will have links to the original provided. +pub struct CookieJar { + jar: CookieCookieJar, +} + +/// +impl FromRequestParts for CookieJar +where + S: Send + Sync, +{ + type Rejection = Infallible; + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + Ok(Self::from_headers(&parts.headers)) + } +} + +fn cookies_from_request( + header: String, + headers: &HeaderMap, +) -> impl Iterator> + '_ { + headers + .get_all(header) + .into_iter() + .filter_map(|value| value.to_str().ok()) + .flat_map(|value| value.split(';')) + .filter_map(|cookie| Cookie::parse_encoded(cookie.to_owned()).ok()) +} + +impl CookieJar { + /// + /// + /// Modified only to prefer "X-Cookie" header. + pub fn from_headers(headers: &HeaderMap) -> Self { + let mut jar = CookieCookieJar::new(); + + for cookie in cookies_from_request( + if headers.contains_key("X-Cookie") { + "X-Cookie".to_string() + } else { + "Cookie".to_string() + }, + headers, + ) { + jar.add_original(cookie.clone()); + } + + Self { jar } + } + + /// + pub fn get(&self, name: &str) -> Option<&Cookie<'static>> { + self.jar.get(name) + } +} diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index bf74220..8347c23 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -2,6 +2,7 @@ #![doc(html_favicon_url = "/public/favicon.svg")] #![doc(html_logo_url = "/public/tetratto_bunny.webp")] mod assets; +mod cookie; mod image; mod macros; mod routes; diff --git a/crates/app/src/public/html/developer/link.lisp b/crates/app/src/public/html/developer/link.lisp index 2c94309..f50ad18 100644 --- a/crates/app/src/public/html/developer/link.lisp +++ b/crates/app/src/public/html/developer/link.lisp @@ -83,6 +83,7 @@ const search = new URLSearchParams(window.location.search); search.append(\"verifier\", verifier); search.append(\"token\", res.payload); + search.append(\"uid\", \"{{ user.id }}\"); window.location.href = `{{ app.redirect|remove_script_tags|safe }}?${search.toString()}`; } diff --git a/crates/app/src/public/js/app_sdk.js b/crates/app/src/public/js/app_sdk.js index 10de985..9d18e9b 100644 --- a/crates/app/src/public/js/app_sdk.js +++ b/crates/app/src/public/js/app_sdk.js @@ -3,7 +3,45 @@ import { JSONStringify as json_stringify, } from "https://unpkg.com/json-with-bigint@3.4.4/json-with-bigint.js"; -export default function tetratto(tetratto_host, api_key) { +/// PKCE key generation. +export const PKCE = { + /// Create a verifier for [`PKCE::challenge`]. + verifier: async (length) => { + let text = ""; + const possible = + "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789"; + + for (let i = 0; i < length; i++) { + text += possible.charAt( + Math.floor(Math.random() * possible.length), + ); + } + + return text; + }, + /// Create the challenge needed to request a user token. + challenge: async (verifier) => { + const data = new TextEncoder().encode(verifier); + const digest = await window.crypto.subtle.digest("SHA-256", data); + return btoa( + String.fromCharCode.apply(null, [...new Uint8Array(digest)]), + ) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/, ""); + }, +}; + +export default function tetratto({ + host = "https://tetratto.com", + api_key = null, + app_id = 0n, + user_token = null, + user_verifier = null, + user_id = 0n, +}) { + const GRANT_URL = `${host}/auth/connections_link/app/${app_id}`; + function api_promise(res) { return new Promise((resolve, reject) => { if (res.ok) { @@ -14,11 +52,16 @@ export default function tetratto(tetratto_host, api_key) { }); } + // app data async function app() { + if (!api_key) { + throw Error("No API key provided."); + } + return api_promise( json_parse( await ( - await fetch(`${tetratto_host}/api/v1/app_data/app`, { + await fetch(`${host}/api/v1/app_data/app`, { method: "GET", headers: { "Atto-Secret-Key": api_key, @@ -30,10 +73,14 @@ export default function tetratto(tetratto_host, api_key) { } async function query(body) { + if (!api_key) { + throw Error("No API key provided."); + } + return api_promise( json_parse( await ( - await fetch(`${tetratto_host}/api/v1/app_data/query`, { + await fetch(`${host}/api/v1/app_data/query`, { method: "POST", headers: { "Content-Type": "application/json", @@ -47,10 +94,14 @@ export default function tetratto(tetratto_host, api_key) { } async function insert(key, value) { + if (!api_key) { + throw Error("No API key provided."); + } + return api_promise( json_parse( await ( - await fetch(`${tetratto_host}/api/v1/app_data`, { + await fetch(`${host}/api/v1/app_data`, { method: "POST", headers: { "Content-Type": "application/json", @@ -67,10 +118,14 @@ export default function tetratto(tetratto_host, api_key) { } async function remove(id) { + if (!api_key) { + throw Error("No API key provided."); + } + return api_promise( json_parse( await ( - await fetch(`${tetratto_host}/api/v1/app_data/${id}`, { + await fetch(`${host}/api/v1/app_data/${id}`, { method: "DELETE", headers: { "Atto-Secret-Key": api_key, @@ -82,10 +137,14 @@ export default function tetratto(tetratto_host, api_key) { } async function remove_query(body) { + if (!api_key) { + throw Error("No API key provided."); + } + return api_promise( json_parse( await ( - await fetch(`${tetratto_host}/api/v1/app_data/query`, { + await fetch(`${host}/api/v1/app_data/query`, { method: "DELETE", headers: { "Content-Type": "application/json", @@ -98,11 +157,110 @@ export default function tetratto(tetratto_host, api_key) { ); } + // user connection + /// Extract the verifier, token, and user ID from the URL. + function extract_verifier_token_uid() { + const search = new URLSearchParams(window.location.search); + return [ + search.get("verifier"), + search.get("token"), + BigInt(search.get("uid")), + ]; + } + + /// Accept a connection grant and store it in localStorage. + function localstorage_accept_connection() { + const [verifier, token, uid] = extract_verifier_token_uid(); + window.localStorage.setItem("atto:grant.verifier", verifier); + window.localStorage.setItem("atto:grant.token", token); + window.localStorage.setItem("atto:grant.user_id", uid); + } + + async function refresh_token(verifier) { + if (!user_token) { + throw Error("No user token provided."); + } + + return api_promise( + json_parse( + await ( + await fetch( + `${host}/api/v1/auth/user/${user_id}/grants/${app_id}/refresh`, + { + method, + headers: { + "Content-Type": "application/json", + "X-Cookie": `__Secure-atto-token=${user_token}`, + }, + body: json_stringify({ + verifier, + }), + }, + ) + ).text(), + ), + ); + } + + async function request({ + api_path, + method = "POST", + content_type = "application/json", + body = "{}", + }) { + if (!user_token) { + throw Error("No user token provided."); + } + + return api_promise( + json_parse( + await ( + await fetch(`${host}/api/v1/${api_path}`, { + method, + headers: { + "Content-Type": content_type, + "X-Cookie": `__Secure-atto-token=${user_token}`, + }, + body: + content_type === "application/json" + ? json_stringify(body) + : body, + }) + ).text(), + ), + ); + } + + // ... return { + // app data app, query, insert, remove, remove_query, + // user connection + GRANT_URL, + extract_verifier_token_uid, + refresh_token, + localstorage_accept_connection, + request, }; } + +export function from_localstorage({ + host = "https://tetratto.com", + app_id = 0n, +}) { + const user_verifier = window.localStorage.getItem("atto:grant.verifier"); + const user_token = window.localStorage.getItem("atto:grant.token"); + const user_id = window.localStorage.getItem("atto:grant.user_id"); + + return tetratto({ + host, + app_id, + user_verifier, + user_id, + user_token, + }); +} diff --git a/crates/app/src/routes/api/v1/apps.rs b/crates/app/src/routes/api/v1/apps.rs index 2b1a314..3b5cd60 100644 --- a/crates/app/src/routes/api/v1/apps.rs +++ b/crates/app/src/routes/api/v1/apps.rs @@ -7,7 +7,7 @@ use crate::{ State, }; use axum::{Extension, Json, extract::Path, response::IntoResponse}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ apps::{AppQuota, ThirdPartyApp}, oauth::{AuthGrant, PkceChallengeMethod}, diff --git a/crates/app/src/routes/api/v1/auth/connections/last_fm.rs b/crates/app/src/routes/api/v1/auth/connections/last_fm.rs index 9740b5a..be4176c 100644 --- a/crates/app/src/routes/api/v1/auth/connections/last_fm.rs +++ b/crates/app/src/routes/api/v1/auth/connections/last_fm.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; use axum::{extract::Path, response::IntoResponse, Extension, Json}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::{ database::connections::last_fm::LastFmConnection, model::{ diff --git a/crates/app/src/routes/api/v1/auth/connections/mod.rs b/crates/app/src/routes/api/v1/auth/connections/mod.rs index 5cd9813..8a98355 100644 --- a/crates/app/src/routes/api/v1/auth/connections/mod.rs +++ b/crates/app/src/routes/api/v1/auth/connections/mod.rs @@ -5,7 +5,7 @@ pub mod stripe; use std::collections::HashMap; use axum::{extract::Path, response::IntoResponse, Extension, Json}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use serde::Deserialize; use tetratto_core::model::{ auth::{ConnectionService, ExternalConnectionData}, diff --git a/crates/app/src/routes/api/v1/auth/connections/spotify.rs b/crates/app/src/routes/api/v1/auth/connections/spotify.rs index 8d0db30..d83057e 100644 --- a/crates/app/src/routes/api/v1/auth/connections/spotify.rs +++ b/crates/app/src/routes/api/v1/auth/connections/spotify.rs @@ -1,5 +1,5 @@ use axum::{response::IntoResponse, Extension, Json}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::{ database::connections::spotify::SpotifyConnection, model::{ diff --git a/crates/app/src/routes/api/v1/auth/connections/stripe.rs b/crates/app/src/routes/api/v1/auth/connections/stripe.rs index 1d5be0d..2110924 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -1,7 +1,7 @@ use std::{str::FromStr, time::Duration}; use axum::{http::HeaderMap, response::IntoResponse, Extension, Json}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ auth::{Notification, User}, moderation::AuditLogEntry, diff --git a/crates/app/src/routes/api/v1/auth/images.rs b/crates/app/src/routes/api/v1/auth/images.rs index 4619a80..cbaf344 100644 --- a/crates/app/src/routes/api/v1/auth/images.rs +++ b/crates/app/src/routes/api/v1/auth/images.rs @@ -4,7 +4,7 @@ use axum::{ extract::{Path, Query}, response::IntoResponse, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use pathbufd::{PathBufD, pathd}; use serde::Deserialize; use std::{ diff --git a/crates/app/src/routes/api/v1/auth/ipbans.rs b/crates/app/src/routes/api/v1/auth/ipbans.rs index 8a71d25..7163091 100644 --- a/crates/app/src/routes/api/v1/auth/ipbans.rs +++ b/crates/app/src/routes/api/v1/auth/ipbans.rs @@ -4,7 +4,7 @@ use crate::{ routes::api::v1::CreateIpBan, }; use axum::{Extension, Json, extract::Path, response::IntoResponse}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{addr::RemoteAddr, auth::IpBan, permissions::FinePermission}; /// Create a new IP ban. diff --git a/crates/app/src/routes/api/v1/auth/mod.rs b/crates/app/src/routes/api/v1/auth/mod.rs index 085844a..dff259e 100644 --- a/crates/app/src/routes/api/v1/auth/mod.rs +++ b/crates/app/src/routes/api/v1/auth/mod.rs @@ -16,7 +16,7 @@ use axum::{ response::{IntoResponse, Redirect}, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use serde::Deserialize; use tetratto_core::model::addr::RemoteAddr; use tetratto_shared::hash::hash; diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index 5119e0d..fdb71cf 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -18,7 +18,7 @@ use axum::{ response::{IntoResponse, Redirect}, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use futures_util::{sink::SinkExt, stream::StreamExt}; use tetratto_core::{ cache::Cache, diff --git a/crates/app/src/routes/api/v1/auth/social.rs b/crates/app/src/routes/api/v1/auth/social.rs index 86a601d..84e20c8 100644 --- a/crates/app/src/routes/api/v1/auth/social.rs +++ b/crates/app/src/routes/api/v1/auth/social.rs @@ -9,7 +9,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ addr::RemoteAddr, auth::{AchievementName, FollowResult, IpBlock, Notification, UserBlock, UserFollow}, diff --git a/crates/app/src/routes/api/v1/auth/user_warnings.rs b/crates/app/src/routes/api/v1/auth/user_warnings.rs index 321ab78..3020ec6 100644 --- a/crates/app/src/routes/api/v1/auth/user_warnings.rs +++ b/crates/app/src/routes/api/v1/auth/user_warnings.rs @@ -9,7 +9,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{auth::UserWarning, oauth, permissions::FinePermission}; /// Create a new user warning. diff --git a/crates/app/src/routes/api/v1/channels/channels.rs b/crates/app/src/routes/api/v1/channels/channels.rs index 0251e18..2059a0f 100644 --- a/crates/app/src/routes/api/v1/channels/channels.rs +++ b/crates/app/src/routes/api/v1/channels/channels.rs @@ -1,5 +1,5 @@ use axum::{Extension, Json, extract::Path, response::IntoResponse}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{oauth, channels::Channel, ApiReturn, Error}; use crate::{ get_user_from_token, diff --git a/crates/app/src/routes/api/v1/channels/message_reactions.rs b/crates/app/src/routes/api/v1/channels/message_reactions.rs index b9ccb53..5f5c79c 100644 --- a/crates/app/src/routes/api/v1/channels/message_reactions.rs +++ b/crates/app/src/routes/api/v1/channels/message_reactions.rs @@ -1,6 +1,6 @@ use crate::{get_user_from_token, routes::api::v1::CreateMessageReaction, State}; use axum::{Extension, Json, extract::Path, response::IntoResponse}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{channels::MessageReaction, oauth, ApiReturn, Error}; pub async fn get_request( diff --git a/crates/app/src/routes/api/v1/channels/messages.rs b/crates/app/src/routes/api/v1/channels/messages.rs index e88138e..92a5c48 100644 --- a/crates/app/src/routes/api/v1/channels/messages.rs +++ b/crates/app/src/routes/api/v1/channels/messages.rs @@ -7,7 +7,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::{ cache::{Cache, redis::Commands}, model::{ diff --git a/crates/app/src/routes/api/v1/communities/communities.rs b/crates/app/src/routes/api/v1/communities/communities.rs index 539cc08..a0793b1 100644 --- a/crates/app/src/routes/api/v1/communities/communities.rs +++ b/crates/app/src/routes/api/v1/communities/communities.rs @@ -3,7 +3,7 @@ use axum::{ extract::Path, response::{IntoResponse, Redirect}, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ auth::Notification, communities::{Community, CommunityMembership}, diff --git a/crates/app/src/routes/api/v1/communities/drafts.rs b/crates/app/src/routes/api/v1/communities/drafts.rs index 75f0948..559e4b3 100644 --- a/crates/app/src/routes/api/v1/communities/drafts.rs +++ b/crates/app/src/routes/api/v1/communities/drafts.rs @@ -3,7 +3,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{auth::AchievementName, communities::PostDraft, oauth, ApiReturn, Error}; use crate::{ get_user_from_token, diff --git a/crates/app/src/routes/api/v1/communities/emojis.rs b/crates/app/src/routes/api/v1/communities/emojis.rs index 1d2400f..84fadc0 100644 --- a/crates/app/src/routes/api/v1/communities/emojis.rs +++ b/crates/app/src/routes/api/v1/communities/emojis.rs @@ -7,7 +7,7 @@ use crate::{ State, }; use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ oauth, uploads::{CustomEmoji, MediaType, MediaUpload}, diff --git a/crates/app/src/routes/api/v1/communities/images.rs b/crates/app/src/routes/api/v1/communities/images.rs index 3ddee00..9f32ef3 100644 --- a/crates/app/src/routes/api/v1/communities/images.rs +++ b/crates/app/src/routes/api/v1/communities/images.rs @@ -1,5 +1,5 @@ use axum::{Extension, Json, body::Body, extract::Path, response::IntoResponse}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use pathbufd::{PathBufD, pathd}; use std::fs::exists; use tetratto_core::model::{ApiReturn, Error, permissions::FinePermission, oauth}; diff --git a/crates/app/src/routes/api/v1/communities/posts.rs b/crates/app/src/routes/api/v1/communities/posts.rs index d65ce53..13729b3 100644 --- a/crates/app/src/routes/api/v1/communities/posts.rs +++ b/crates/app/src/routes/api/v1/communities/posts.rs @@ -4,7 +4,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ addr::RemoteAddr, auth::AchievementName, diff --git a/crates/app/src/routes/api/v1/communities/questions.rs b/crates/app/src/routes/api/v1/communities/questions.rs index e67b91b..de6cbb2 100644 --- a/crates/app/src/routes/api/v1/communities/questions.rs +++ b/crates/app/src/routes/api/v1/communities/questions.rs @@ -4,7 +4,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ addr::RemoteAddr, auth::{AchievementName, IpBlock}, diff --git a/crates/app/src/routes/api/v1/domains.rs b/crates/app/src/routes/api/v1/domains.rs index f1af2e6..1e57049 100644 --- a/crates/app/src/routes/api/v1/domains.rs +++ b/crates/app/src/routes/api/v1/domains.rs @@ -9,7 +9,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ auth::AchievementName, littleweb::{Domain, ServiceFsMime}, diff --git a/crates/app/src/routes/api/v1/journals.rs b/crates/app/src/routes/api/v1/journals.rs index 0b1b394..d018903 100644 --- a/crates/app/src/routes/api/v1/journals.rs +++ b/crates/app/src/routes/api/v1/journals.rs @@ -3,7 +3,7 @@ use axum::{ extract::{Json, Path}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_shared::snow::Snowflake; use crate::{ get_user_from_token, diff --git a/crates/app/src/routes/api/v1/notes.rs b/crates/app/src/routes/api/v1/notes.rs index 6b274ff..bba335e 100644 --- a/crates/app/src/routes/api/v1/notes.rs +++ b/crates/app/src/routes/api/v1/notes.rs @@ -3,7 +3,7 @@ use axum::{ extract::{Json, Path}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_shared::unix_epoch_timestamp; use crate::{ get_user_from_token, diff --git a/crates/app/src/routes/api/v1/notifications.rs b/crates/app/src/routes/api/v1/notifications.rs index 06b2397..de683ae 100644 --- a/crates/app/src/routes/api/v1/notifications.rs +++ b/crates/app/src/routes/api/v1/notifications.rs @@ -5,7 +5,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{oauth, ApiReturn, Error}; pub async fn delete_request( diff --git a/crates/app/src/routes/api/v1/products.rs b/crates/app/src/routes/api/v1/products.rs index 05d8e9c..4d53814 100644 --- a/crates/app/src/routes/api/v1/products.rs +++ b/crates/app/src/routes/api/v1/products.rs @@ -15,7 +15,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ oauth, products::Product, diff --git a/crates/app/src/routes/api/v1/reactions.rs b/crates/app/src/routes/api/v1/reactions.rs index 261a48d..b8589e4 100644 --- a/crates/app/src/routes/api/v1/reactions.rs +++ b/crates/app/src/routes/api/v1/reactions.rs @@ -5,7 +5,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{addr::RemoteAddr, oauth, reactions::Reaction, ApiReturn, Error}; pub async fn get_request( diff --git a/crates/app/src/routes/api/v1/reports.rs b/crates/app/src/routes/api/v1/reports.rs index 459b8a9..8509896 100644 --- a/crates/app/src/routes/api/v1/reports.rs +++ b/crates/app/src/routes/api/v1/reports.rs @@ -1,7 +1,7 @@ use super::CreateReport; use crate::{State, get_user_from_token}; use axum::{Extension, Json, extract::Path, response::IntoResponse}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ApiReturn, Error, moderation::Report}; pub async fn create_request( diff --git a/crates/app/src/routes/api/v1/requests.rs b/crates/app/src/routes/api/v1/requests.rs index 0169b72..90236cc 100644 --- a/crates/app/src/routes/api/v1/requests.rs +++ b/crates/app/src/routes/api/v1/requests.rs @@ -4,7 +4,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{oauth, ApiReturn, Error}; pub async fn delete_request( diff --git a/crates/app/src/routes/api/v1/services.rs b/crates/app/src/routes/api/v1/services.rs index a847338..556924a 100644 --- a/crates/app/src/routes/api/v1/services.rs +++ b/crates/app/src/routes/api/v1/services.rs @@ -6,7 +6,7 @@ use crate::{ State, }; use axum::{extract::Path, response::IntoResponse, Extension, Json}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{auth::AchievementName, littleweb::Service, oauth, ApiReturn, Error}; use tetratto_shared::unix_epoch_timestamp; diff --git a/crates/app/src/routes/api/v1/stacks.rs b/crates/app/src/routes/api/v1/stacks.rs index 1fe5c87..e46cfdc 100644 --- a/crates/app/src/routes/api/v1/stacks.rs +++ b/crates/app/src/routes/api/v1/stacks.rs @@ -4,7 +4,7 @@ use axum::{ response::IntoResponse, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::{ model::{ oauth, diff --git a/crates/app/src/routes/api/v1/uploads.rs b/crates/app/src/routes/api/v1/uploads.rs index 02673fe..a1d11f8 100644 --- a/crates/app/src/routes/api/v1/uploads.rs +++ b/crates/app/src/routes/api/v1/uploads.rs @@ -1,6 +1,6 @@ use std::fs::exists; use axum::{body::Body, extract::Path, response::IntoResponse, Extension, Json}; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use pathbufd::PathBufD; use crate::{get_user_from_token, routes::api::v1::UpdateUploadAlt, State}; use super::auth::images::read_image; diff --git a/crates/app/src/routes/api/v1/util.rs b/crates/app/src/routes/api/v1/util.rs index 8714968..501f0d9 100644 --- a/crates/app/src/routes/api/v1/util.rs +++ b/crates/app/src/routes/api/v1/util.rs @@ -7,7 +7,7 @@ use axum::{ response::IntoResponse, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use pathbufd::PathBufD; use serde::Deserialize; use tetratto_core::model::permissions::FinePermission; diff --git a/crates/app/src/routes/pages/auth.rs b/crates/app/src/routes/pages/auth.rs index e9f1699..a675e6a 100644 --- a/crates/app/src/routes/pages/auth.rs +++ b/crates/app/src/routes/pages/auth.rs @@ -4,7 +4,7 @@ use axum::{ response::{Html, IntoResponse}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{Error, auth::ConnectionService}; use super::render_error; diff --git a/crates/app/src/routes/pages/chats.rs b/crates/app/src/routes/pages/chats.rs index e6ef791..65ff437 100644 --- a/crates/app/src/routes/pages/chats.rs +++ b/crates/app/src/routes/pages/chats.rs @@ -5,7 +5,7 @@ use axum::{ response::{Html, IntoResponse, Redirect}, Extension, Json, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ channels::Message, communities_permissions::CommunityPermission, permissions::FinePermission, Error, diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs index 901ec75..6f5524f 100644 --- a/crates/app/src/routes/pages/communities.rs +++ b/crates/app/src/routes/pages/communities.rs @@ -10,7 +10,7 @@ use axum::{ response::{Html, IntoResponse}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use serde::Deserialize; use tera::Context; use tetratto_core::model::{ diff --git a/crates/app/src/routes/pages/developer.rs b/crates/app/src/routes/pages/developer.rs index de4c4e1..0d421f7 100644 --- a/crates/app/src/routes/pages/developer.rs +++ b/crates/app/src/routes/pages/developer.rs @@ -5,7 +5,7 @@ use axum::{ extract::Path, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{apps::AppData, permissions::FinePermission, Error}; /// `/developer` diff --git a/crates/app/src/routes/pages/forge.rs b/crates/app/src/routes/pages/forge.rs index be1769c..c09c04f 100644 --- a/crates/app/src/routes/pages/forge.rs +++ b/crates/app/src/routes/pages/forge.rs @@ -8,7 +8,7 @@ use axum::{ response::{Html, IntoResponse}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{communities::Community, Error}; /// `/forges` diff --git a/crates/app/src/routes/pages/journals.rs b/crates/app/src/routes/pages/journals.rs index db76e93..8ac0a05 100644 --- a/crates/app/src/routes/pages/journals.rs +++ b/crates/app/src/routes/pages/journals.rs @@ -3,7 +3,7 @@ use axum::{ response::{Html, IntoResponse, Redirect}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use crate::{ assets::initial_context, check_user_blocked_or_private, get_lang, get_user_from_token, diff --git a/crates/app/src/routes/pages/littleweb.rs b/crates/app/src/routes/pages/littleweb.rs index 9e347e1..18233ff 100644 --- a/crates/app/src/routes/pages/littleweb.rs +++ b/crates/app/src/routes/pages/littleweb.rs @@ -8,7 +8,7 @@ use axum::{ extract::{Query, Path}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{littleweb::TLDS_VEC, permissions::SecondaryPermission, Error}; use serde::Deserialize; use tetratto_shared::hash::salt; diff --git a/crates/app/src/routes/pages/marketplace.rs b/crates/app/src/routes/pages/marketplace.rs index 0de9be7..8d5a3be 100644 --- a/crates/app/src/routes/pages/marketplace.rs +++ b/crates/app/src/routes/pages/marketplace.rs @@ -7,7 +7,7 @@ use axum::{ response::{Html, IntoResponse}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::Error; /// `/settings/seller` diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs index 5d017ef..7ee2f72 100644 --- a/crates/app/src/routes/pages/misc.rs +++ b/crates/app/src/routes/pages/misc.rs @@ -7,7 +7,7 @@ use axum::{ response::{Html, IntoResponse}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use serde::Deserialize; use tetratto_core::model::{ auth::{AchievementName, DefaultTimelineChoice, ACHIEVEMENTS}, diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs index 83e29ad..2f3c9d5 100644 --- a/crates/app/src/routes/pages/mod.rs +++ b/crates/app/src/routes/pages/mod.rs @@ -15,7 +15,7 @@ use axum::{ routing::{get, post}, Router, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use serde::Deserialize; use tetratto_core::{ model::{Error, auth::User}, diff --git a/crates/app/src/routes/pages/mod_panel.rs b/crates/app/src/routes/pages/mod_panel.rs index 7a9b6f7..2b82cf1 100644 --- a/crates/app/src/routes/pages/mod_panel.rs +++ b/crates/app/src/routes/pages/mod_panel.rs @@ -5,7 +5,7 @@ use axum::{ response::{Html, IntoResponse}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use serde::Deserialize; use tetratto_core::{ cache::Cache, diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index 11966a6..15a3ee8 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -8,7 +8,7 @@ use axum::{ extract::{Path, Query}, response::{Html, IntoResponse}, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use serde::Deserialize; use tera::Context; use tetratto_core::model::{ diff --git a/crates/app/src/routes/pages/stacks.rs b/crates/app/src/routes/pages/stacks.rs index e8285e9..f4ee986 100644 --- a/crates/app/src/routes/pages/stacks.rs +++ b/crates/app/src/routes/pages/stacks.rs @@ -3,7 +3,7 @@ use axum::{ response::{Html, IntoResponse}, Extension, }; -use axum_extra::extract::CookieJar; +use crate::cookie::CookieJar; use tetratto_core::model::{ auth::User, permissions::FinePermission, diff --git a/crates/core/src/model/apps.rs b/crates/core/src/model/apps.rs index 2482c5f..309d850 100644 --- a/crates/core/src/model/apps.rs +++ b/crates/core/src/model/apps.rs @@ -67,7 +67,7 @@ pub struct ThirdPartyApp { /// if the verifier doesn't match, it won't pass the challenge. /// /// Requests to API endpoints using your grant token should be sent with a - /// cookie (in the `Cookie` header) named `Atto-Grant`. This cookie should + /// cookie (in the `Cookie` or `X-Cookie` header) named `Atto-Grant`. This cookie should /// contain the token you received from either the initial connection, /// or a token refresh. pub redirect: String, From 9ccbc69405542277b75a0ca573c5f68fa5f952c6 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 19 Jul 2025 02:00:04 -0400 Subject: [PATCH 74/93] add: app sdk client auth flow example --- crates/app/src/macros.rs | 2 +- crates/app/src/public/html/developer/app.lisp | 3 +- .../app/src/public/html/developer/home.lisp | 6 +-- crates/app/src/public/js/app_sdk.js | 34 +++++++++------- crates/core/src/database/auth.rs | 4 +- example/.gitignore | 1 + example/app_sdk_test.js | 8 ++-- .../examples/auth_flow_example/index.html | 39 +++++++++++++++++++ .../examples/auth_flow_example/redirect.html | 25 ++++++++++++ 9 files changed, 95 insertions(+), 27 deletions(-) create mode 100644 example/public/examples/auth_flow_example/index.html create mode 100644 example/public/examples/auth_flow_example/redirect.html diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index fd141ea..44669a4 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -112,7 +112,7 @@ macro_rules! get_user_from_token { Ok((grant, ua)) => { if grant.scopes.contains(&$grant_scope) { if ua.permissions.check_banned() { - Some(tetratto_core::model::auth::User::banned()) + None } else { Some(ua) } diff --git a/crates/app/src/public/html/developer/app.lisp b/crates/app/src/public/html/developer/app.lisp index 6795001..b1661e8 100644 --- a/crates/app/src/public/html/developer/app.lisp +++ b/crates/app/src/public/html/developer/app.lisp @@ -180,7 +180,8 @@ (li (b (text "Redirect URL: ")) (text "{{ app.redirect }}")) (li (b (text "Quota status: ")) (text "{{ app.quota_status }}")) (li (b (text "User grants: ")) (text "{{ app.grants }}")) - (li (b (text "Grant URL: ")) (text "{{ config.host }}/auth/connections_link/app/{{ app.id }}"))) + (li (b (text "Grant URL: ")) (text "{{ config.host }}/auth/connections_link/app/{{ app.id }}")) + (li (b (text "App ID (for SDK): ")) (text "{{ app.id }}"))) (a ("class" "button") diff --git a/crates/app/src/public/html/developer/home.lisp b/crates/app/src/public/html/developer/home.lisp index d96be6f..160181b 100644 --- a/crates/app/src/public/html/developer/home.lisp +++ b/crates/app/src/public/html/developer/home.lisp @@ -41,8 +41,7 @@ ("id" "homepage") ("placeholder" "homepage") ("required" "") - ("minlength" "2") - ("maxlength" "32"))) + ("minlength" "2"))) (div ("class" "flex flex-col gap-1") (label @@ -53,8 +52,7 @@ ("name" "redirect") ("id" "redirect") ("placeholder" "redirect URL") - ("minlength" "2") - ("maxlength" "32"))) + ("minlength" "2"))) (button (text "{{ text \"communities:action.create\" }}")))) diff --git a/crates/app/src/public/js/app_sdk.js b/crates/app/src/public/js/app_sdk.js index 9d18e9b..4b5599b 100644 --- a/crates/app/src/public/js/app_sdk.js +++ b/crates/app/src/public/js/app_sdk.js @@ -176,11 +176,7 @@ export default function tetratto({ window.localStorage.setItem("atto:grant.user_id", uid); } - async function refresh_token(verifier) { - if (!user_token) { - throw Error("No user token provided."); - } - + async function refresh_token() { return api_promise( json_parse( await ( @@ -190,10 +186,10 @@ export default function tetratto({ method, headers: { "Content-Type": "application/json", - "X-Cookie": `__Secure-atto-token=${user_token}`, + "X-Cookie": `Atto-Grant=${user_token}`, }, body: json_stringify({ - verifier, + verifier: user_verifier, }), }, ) @@ -203,10 +199,10 @@ export default function tetratto({ } async function request({ - api_path, + route, method = "POST", content_type = "application/json", - body = "{}", + body = {}, }) { if (!user_token) { throw Error("No user token provided."); @@ -215,16 +211,19 @@ export default function tetratto({ return api_promise( json_parse( await ( - await fetch(`${host}/api/v1/${api_path}`, { + await fetch(`${host}/api/v1/${route}`, { method, headers: { - "Content-Type": content_type, - "X-Cookie": `__Secure-atto-token=${user_token}`, + "Content-Type": + method === "GET" ? null : content_type, + "X-Cookie": `Atto-Grant=${user_token}`, }, body: - content_type === "application/json" - ? json_stringify(body) - : body, + method === "GET" + ? null + : content_type === "application/json" + ? json_stringify(body) + : body, }) ).text(), ), @@ -233,6 +232,11 @@ export default function tetratto({ // ... return { + user_id, + user_token, + user_verifier, + app_id, + api_key, // app data app, query, diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 64530b5..c520794 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -196,8 +196,8 @@ impl DataManager { let res = query_row!( &conn, - "SELECT * FROM users WHERE (SELECT jsonb_array_elements(grants::jsonb) @> ('{\"token\":\"' || $1 || '\"}')::jsonb)", - &[&token], + "SELECT * FROM users WHERE grants LIKE $1", + &[&format!("%\"token\":\"{token}\"%")], |x| Ok(Self::get_user_from_row(x)) ); diff --git a/example/.gitignore b/example/.gitignore index 004f366..b5ba3f2 100644 --- a/example/.gitignore +++ b/example/.gitignore @@ -4,6 +4,7 @@ html/* public/* !public/footer.html !public/robots.txt +!public/examples media/* icons/* langs/* diff --git a/example/app_sdk_test.js b/example/app_sdk_test.js index 000eb8d..bd69519 100644 --- a/example/app_sdk_test.js +++ b/example/app_sdk_test.js @@ -1,10 +1,10 @@ // @ts-nocheck // APP_API_KEY=... deno run --allow-net --allow-import --allow-env -r app_sdk_test.js const deno = Deno; -const sdk = (await import("http://localhost:4118/js/app_sdk.js")).default( - "http://localhost:4118", - deno.env.get("APP_API_KEY"), -); +const sdk = (await import("http://localhost:4118/js/app_sdk.js")).default({ + host: "http://localhost:4118", + api_key: deno.env.get("APP_API_KEY"), +}); // check data used console.log("data used:", (await sdk.app()).data_used); diff --git a/example/public/examples/auth_flow_example/index.html b/example/public/examples/auth_flow_example/index.html new file mode 100644 index 0000000..5a4c023 --- /dev/null +++ b/example/public/examples/auth_flow_example/index.html @@ -0,0 +1,39 @@ + + + + + + Auth flow example + + + + +

    + + + + diff --git a/example/public/examples/auth_flow_example/redirect.html b/example/public/examples/auth_flow_example/redirect.html new file mode 100644 index 0000000..532eb22 --- /dev/null +++ b/example/public/examples/auth_flow_example/redirect.html @@ -0,0 +1,25 @@ + + + + + + Auth flow example redirect + + +

    Waiting...

    + + + + From 63d3c2350d90658ac148c033a671f6a588cf9d15 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 19 Jul 2025 03:17:21 -0400 Subject: [PATCH 75/93] add: user is_deactivated --- crates/app/src/langs/en-US.toml | 4 + crates/app/src/macros.rs | 13 +++ crates/app/src/public/html/mod/profile.lisp | 10 ++ .../app/src/public/html/profile/settings.lisp | 92 ++++++++++++++----- crates/app/src/public/html/root.lisp | 50 +++++++++- crates/app/src/routes/api/v1/auth/profile.rs | 32 ++++++- crates/app/src/routes/api/v1/mod.rs | 9 ++ crates/core/src/database/auth.rs | 42 ++++++++- crates/core/src/database/common.rs | 5 +- .../src/database/drivers/sql/create_users.sql | 3 +- .../drivers/sql/version_migrations.sql | 4 + crates/core/src/database/posts.rs | 4 +- crates/core/src/model/auth.rs | 5 + 13 files changed, 243 insertions(+), 30 deletions(-) diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 5a2b0e1..3246755 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -184,6 +184,10 @@ version = "1.0.0" "settings:label.generate_invites" = "Generate invites" "settings:label.add_to_stack" = "Add to stack" "settings:label.alt_text" = "Alt text" +"settings:label.deactivate_account" = "Deactivate account" +"settings:label.activate_account" = "Activate account" +"settings:label.deactivate" = "Deactivate" +"settings:label.account_deactivated" = "Account deactivated" "settings:tab.security" = "Security" "settings:tab.blocks" = "Blocks" "settings:tab.billing" = "Billing" diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 44669a4..1aa9a2d 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -192,6 +192,19 @@ macro_rules! user_banned { #[macro_export] macro_rules! check_user_blocked_or_private { ($user:expr, $other_user:ident, $data:ident, $jar:ident) => { + // check is_deactivated + if $other_user.is_deactivated { + return Err(Html( + render_error( + Error::GeneralNotFound("user".to_string()), + &$jar, + &$data, + &$user, + ) + .await, + )); + } + // check require_account if $user.is_none() && $other_user.settings.require_account { return Err(Html( diff --git a/crates/app/src/public/html/mod/profile.lisp b/crates/app/src/public/html/mod/profile.lisp index 6f07c93..2b68c90 100644 --- a/crates/app/src/public/html/mod/profile.lisp +++ b/crates/app/src/public/html/mod/profile.lisp @@ -212,6 +212,11 @@ \"{{ profile.awaiting_purchase }}\", \"checkbox\", ], + [ + [\"is_deactivated\", \"Is deactivated\"], + \"{{ profile.is_deactivated }}\", + \"checkbox\", + ], [ [\"role\", \"Permission level\"], \"{{ profile.permissions }}\", @@ -235,6 +240,11 @@ awaiting_purchase: value, }); }, + is_deactivated: (value) => { + profile_request(false, \"deactivated\", { + is_deactivated: value, + }); + }, role: (new_role) => { return update_user_role(new_role); }, diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index b8e251c..59b64d0 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -284,29 +284,50 @@ ("ui_ident" "delete_account") (div ("class" "card small flex items-center gap-2 red") - (text "{{ icon \"skull\" }}") - (b - (text "{{ text \"settings:label.delete_account\" }}"))) - (form - ("class" "card flex flex-col gap-2") - ("onsubmit" "delete_account(event)") - (div - ("class" "flex flex-col gap-1") - (label - ("for" "current_password") - (text "{{ text \"settings:label.current_password\" }}")) - (input - ("type" "password") - ("name" "current_password") - ("id" "current_password") - ("placeholder" "current_password") - ("required" "") - ("minlength" "6") - ("autocomplete" "off"))) - (button - (text "{{ icon \"trash\" }}") - (span - (text "{{ text \"general:action.delete\" }}"))))) + (icon (text "skull")) + (b (str (text "communities:label.danger_zone")))) + (div + ("class" "card lowered flex flex-col gap-2") + (details + ("class" "accordion") + (summary + ("class" "flex items-center gap-2") + (icon_class (text "chevron-down") (text "dropdown_arrow")) + (str (text "settings:label.deactivate_account"))) + (div + ("class" "inner flex flex-col gap-2") + (p (text "Deactivating your account will treat it as deleted, but all your data will be recoverable if you change your mind. This option is recommended over a full deletion.")) + (button + ("onclick" "deactivate_account()") + (icon (text "lock")) + (span + (str (text "settings:label.deactivate")))))) + (details + ("class" "accordion") + (summary + ("class" "flex items-center gap-2") + (icon_class (text "chevron-down") (text "dropdown_arrow")) + (str (text "settings:label.delete_account"))) + (form + ("class" "inner flex flex-col gap-2") + ("onsubmit" "delete_account(event)") + (div + ("class" "flex flex-col gap-1") + (label + ("for" "current_password") + (text "{{ text \"settings:label.current_password\" }}")) + (input + ("type" "password") + ("name" "current_password") + ("id" "current_password") + ("placeholder" "current_password") + ("required" "") + ("minlength" "6") + ("autocomplete" "off"))) + (button + (text "{{ icon \"trash\" }}") + (span + (text "{{ text \"general:action.delete\" }}"))))))) (button ("onclick" "save_settings()") ("id" "save_button") @@ -1612,6 +1633,31 @@ }); } + globalThis.deactivate_account = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you want to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/auth/user/{{ profile.id }}/deactivate\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ is_deactivated: true }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + // presets globalThis.apply_preset = async (preset) => { if ( diff --git a/crates/app/src/public/html/root.lisp b/crates/app/src/public/html/root.lisp index 3dd15a0..5cf7da9 100644 --- a/crates/app/src/public/html/root.lisp +++ b/crates/app/src/public/html/root.lisp @@ -77,7 +77,6 @@ (div ("class" "card lowered w-full") (text "{{ user.ban_reason|markdown|safe }}")))))) - ; if we aren't banned, just show the page body (text "{% elif user and user.awaiting_purchase %}") ; account waiting for payment message @@ -142,6 +141,55 @@ } }); }")))))) + (text "{% elif user.is_deactivated -%}") + ; account deactivated message + (article + (main + (div + ("class" "card-nest") + (div + ("class" "card small flex items-center gap-2 red") + (icon (text "frown")) + (str (text "settings:label.account_deactivated"))) + + (div + ("class" "card flex flex-col gap-2 no_p_margin") + (p (text "You have deactivated your account. You can undo this with the button below if you'd like.")) + (hr) + (button + ("onclick" "activate_account()") + (icon (text "lock-open")) + (str (text "settings:label.activate_account"))))))) + + (script + (text "globalThis.activate_account = async () => { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you want to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/auth/user/{{ user.id }}/deactivate\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ is_deactivated: false }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.reload(); + } + }); + };")) (text "{% else %}") ; page body (text "{% block body %}{% endblock %}") diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index fdb71cf..aec31ef 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -5,8 +5,8 @@ use crate::{ routes::api::v1::{ AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken, UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserBanReason, - UpdateUserInviteCode, UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, - UpdateUserUsername, + UpdateUserInviteCode, UpdateUserIsDeactivated, UpdateUserIsVerified, UpdateUserPassword, + UpdateUserRole, UpdateUserUsername, }, State, }; @@ -372,6 +372,34 @@ pub async fn update_user_awaiting_purchase_request( } } +/// Update the deactivated status of the given user. +/// +/// Does not support third-party grants. +pub async fn update_user_is_deactivated_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let user = match get_user_from_token!(jar, data) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + match data + .update_user_is_deactivated(id, req.is_deactivated, user) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Deactivated status updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + /// Update the role of the given user. /// /// Does not support third-party grants. diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 2420007..60bbf20 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -351,6 +351,10 @@ pub fn routes() -> Router { "/auth/user/{id}/awaiting_purchase", post(auth::profile::update_user_awaiting_purchase_request), ) + .route( + "/auth/user/{id}/deactivate", + post(auth::profile::update_user_is_deactivated_request), + ) .route( "/auth/user/{id}/totp", post(auth::profile::enable_totp_request), @@ -836,6 +840,11 @@ pub struct UpdateUserAwaitingPurchase { pub awaiting_purchase: bool, } +#[derive(Deserialize)] +pub struct UpdateUserIsDeactivated { + pub is_deactivated: bool, +} + #[derive(Deserialize)] pub struct UpdateNotificationRead { pub read: bool, diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index c520794..513be6d 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -121,6 +121,7 @@ impl DataManager { seller_data: serde_json::from_str(&get!(x->27(String)).to_string()).unwrap(), ban_reason: get!(x->28(String)), channel_mutes: serde_json::from_str(&get!(x->29(String)).to_string()).unwrap(), + is_deactivated: get!(x->30(i32)) as i8 == 1, } } @@ -277,7 +278,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30)", + "INSERT INTO users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $25, $26, $27, $28, $29, $30, $31)", params![ &(data.id as i64), &(data.created as i64), @@ -309,6 +310,7 @@ impl DataManager { &serde_json::to_string(&data.seller_data).unwrap(), &data.ban_reason, &serde_json::to_string(&data.channel_mutes).unwrap(), + &if data.is_deactivated { 1_i32 } else { 0_i32 }, ] ); @@ -626,6 +628,44 @@ impl DataManager { Ok(()) } + pub async fn update_user_is_deactivated(&self, id: usize, x: bool, user: User) -> Result<()> { + if id != user.id && !user.permissions.check(FinePermission::MANAGE_USERS) { + return Err(Error::NotAllowed); + } + + let other_user = self.get_user_by_id(id).await?; + + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "UPDATE users SET is_deactivated = $1 WHERE id = $2", + params![&{ if x { 1 } else { 0 } }, &(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.cache_clear_user(&other_user).await; + + // create audit log entry + self.create_audit_log_entry(AuditLogEntry::new( + user.id, + format!( + "invoked `update_user_is_deactivated` with x value `{}` and y value `{}`", + other_user.id, x + ), + )) + .await?; + + // ... + Ok(()) + } + pub async fn update_user_password( &self, id: usize, diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs index 1bf00cf..d37c330 100644 --- a/crates/core/src/database/common.rs +++ b/crates/core/src/database/common.rs @@ -44,7 +44,10 @@ impl DataManager { execute!(&conn, common::CREATE_TABLE_SERVICES).unwrap(); execute!(&conn, common::CREATE_TABLE_PRODUCTS).unwrap(); execute!(&conn, common::CREATE_TABLE_APP_DATA).unwrap(); - execute!(&conn, common::VERSION_MIGRATIONS).unwrap(); + + for x in common::VERSION_MIGRATIONS.split(";") { + execute!(&conn, x).unwrap(); + } self.0 .1 diff --git a/crates/core/src/database/drivers/sql/create_users.sql b/crates/core/src/database/drivers/sql/create_users.sql index 1cbbbc8..6a939e5 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -28,5 +28,6 @@ CREATE TABLE IF NOT EXISTS users ( browser_session TEXT NOT NULL, seller_data TEXT NOT NULL, ban_reason TEXT NOT NULL, - channel_mutes TEXT NOT NULL + channel_mutes TEXT NOT NULL, + is_deactivated INT NOT NULL ) diff --git a/crates/core/src/database/drivers/sql/version_migrations.sql b/crates/core/src/database/drivers/sql/version_migrations.sql index 0f5682b..c0c863a 100644 --- a/crates/core/src/database/drivers/sql/version_migrations.sql +++ b/crates/core/src/database/drivers/sql/version_migrations.sql @@ -1,3 +1,7 @@ -- users channel_mutes ALTER TABLE users ADD COLUMN IF NOT EXISTS channel_mutes TEXT DEFAULT '[]'; + +-- users is_deactivated +ALTER TABLE users +ADD COLUMN IF NOT EXISTS is_deactivated INT DEFAULT 0; diff --git a/crates/core/src/database/posts.rs b/crates/core/src/database/posts.rs index 701c053..0891fed 100644 --- a/crates/core/src/database/posts.rs +++ b/crates/core/src/database/posts.rs @@ -397,7 +397,9 @@ impl DataManager { continue; } - if ua.permissions.check_banned() | ignore_users.contains(&owner) + if (ua.permissions.check_banned() + | ignore_users.contains(&owner) + | ua.is_deactivated) && !ua.permissions.check(FinePermission::MANAGE_POSTS) { continue; diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index ffcb264..37d2bf9 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -89,6 +89,10 @@ pub struct User { /// IDs of channels the user has muted. #[serde(default)] pub channel_mutes: Vec, + /// If the user is deactivated. Deactivated users act almost like deleted + /// users, but their data is not wiped. + #[serde(default)] + pub is_deactivated: bool, } pub type UserConnections = @@ -391,6 +395,7 @@ impl User { seller_data: StripeSellerData::default(), ban_reason: String::new(), channel_mutes: Vec::new(), + is_deactivated: false, } } From f05074ffc5ff89f6ed57b9f63088fb3e1c5efdf3 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 19 Jul 2025 03:20:13 -0400 Subject: [PATCH 76/93] fix: delete apps and app_data when deleting user --- crates/core/src/database/auth.rs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 513be6d..3bd8678 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -537,6 +537,11 @@ impl DataManager { self.delete_userfollow(follow.id, &user, true).await?; } + // delete apps + for app in self.get_apps_by_owner(id).await? { + self.delete_app(app.id, &user).await?; + } + // remove images let avatar = PathBufD::current().extend(&[ self.0.0.dirs.media.as_str(), From fe1e53c47ac27236c4d1483835772020bb6d9bab Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 19 Jul 2025 15:31:06 -0400 Subject: [PATCH 77/93] add: apps rust sdk --- Cargo.lock | 2 + Cargo.toml | 1 + crates/app/Cargo.toml | 4 + crates/app/src/macros.rs | 8 +- crates/app/src/public/js/app_sdk.js | 24 +++ crates/core/Cargo.toml | 31 ++- crates/core/examples/sdk_db.rs | 65 ++++++ crates/core/src/lib.rs | 7 + crates/core/src/sdk.rs | 313 ++++++++++++++++++++++++++++ crates/l10n/Cargo.toml | 1 + example/app_sdk_test.js | 16 +- 11 files changed, 461 insertions(+), 11 deletions(-) create mode 100644 crates/core/examples/sdk_db.rs create mode 100644 crates/core/src/sdk.rs diff --git a/Cargo.lock b/Cargo.lock index 614d3bb..1a9e39a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2669,6 +2669,7 @@ dependencies = [ "js-sys", "log", "mime", + "mime_guess", "native-tls", "percent-encoding", "pin-project-lite", @@ -3317,6 +3318,7 @@ dependencies = [ "serde_json", "tetratto-l10n", "tetratto-shared", + "tokio", "toml 0.9.2", "totp-rs", ] diff --git a/Cargo.toml b/Cargo.toml index e8d6326..b5beca0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,6 +4,7 @@ members = ["crates/app", "crates/shared", "crates/core", "crates/l10n"] package.authors = ["trisuaso"] package.repository = "https://trisua.com/t/tetratto" package.license = "AGPL-3.0-or-later" +package.homepage = "https://tetratto.com" [profile.dev] incremental = true diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 170d252..5c60129 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -2,6 +2,10 @@ name = "tetratto" version = "11.0.0" edition = "2024" +authors.workspace = true +repository.workspace = true +license.workspace = true +homepage.workspace = true [dependencies] pathbufd = "0.1.4" diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 1aa9a2d..13333da 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -193,7 +193,13 @@ macro_rules! user_banned { macro_rules! check_user_blocked_or_private { ($user:expr, $other_user:ident, $data:ident, $jar:ident) => { // check is_deactivated - if $other_user.is_deactivated { + if ($user.is_none() && $other_user.is_deactivated) + | !$user + .as_ref() + .unwrap() + .permissions + .check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS) + { return Err(Html( render_error( Error::GeneralNotFound("user".to_string()), diff --git a/crates/app/src/public/js/app_sdk.js b/crates/app/src/public/js/app_sdk.js index 4b5599b..2ca1f53 100644 --- a/crates/app/src/public/js/app_sdk.js +++ b/crates/app/src/public/js/app_sdk.js @@ -117,6 +117,29 @@ export default function tetratto({ ); } + async function update(id, value) { + if (!api_key) { + throw Error("No API key provided."); + } + + return api_promise( + json_parse( + await ( + await fetch(`${host}/api/v1/app_data/${id}/value`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Atto-Secret-Key": api_key, + }, + body: json_stringify({ + value, + }), + }) + ).text(), + ), + ); + } + async function remove(id) { if (!api_key) { throw Error("No API key provided."); @@ -241,6 +264,7 @@ export default function tetratto({ app, query, insert, + update, remove, remove_query, // user connection diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 72d6481..526b195 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -2,6 +2,16 @@ name = "tetratto-core" version = "11.0.0" edition = "2024" +authors.workspace = true +repository.workspace = true +license.workspace = true +homepage.workspace = true + +[features] +database = ["dep:oiseau", "dep:base64", "dep:base16ct", "dep:async-recursion", "dep:md-5"] +types = ["dep:totp-rs", "dep:paste", "dep:bitflags"] +sdk = ["types", "dep:reqwest"] +default = ["database", "types", "sdk"] [dependencies] pathbufd = "0.1.4" @@ -10,17 +20,20 @@ toml = "0.9.2" 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.22", features = ["json"] } -bitflags = "2.9.1" -async-recursion = "1.1.1" -md-5 = "0.10.6" -base16ct = { version = "0.2.0", features = ["alloc"] } -base64 = "0.22.1" +totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"], optional = true } +reqwest = { version = "0.12.22", features = ["json", "multipart"], optional = true } +bitflags = { version = "2.9.1", optional = true } +async-recursion = { version = "1.1.1", optional = true } +md-5 = { version = "0.10.6", optional = true } +base16ct = { version = "0.2.0", features = ["alloc"], optional = true } +base64 = { version = "0.22.1", optional = true } emojis = "0.7.0" regex = "1.11.1" oiseau = { version = "0.1.2", default-features = false, features = [ "postgres", "redis", -] } -paste = "1.0.15" +], optional = true } +paste = { version = "1.0.15", optional = true } + +[dev-dependencies] +tokio = { version = "1.46.1", features = ["macros", "rt-multi-thread"] } diff --git a/crates/core/examples/sdk_db.rs b/crates/core/examples/sdk_db.rs new file mode 100644 index 0000000..becdca1 --- /dev/null +++ b/crates/core/examples/sdk_db.rs @@ -0,0 +1,65 @@ +extern crate tetratto_core; +use tetratto_core::{ + model::apps::{AppDataSelectMode, AppDataSelectQuery, AppDataQueryResult}, + sdk::{DataClient, SimplifiedQuery}, +}; +use std::env::var; + +// mirror of https://trisua.com/t/tetratto/src/branch/master/example/app_sdk_test.js ... but in rust +#[tokio::main] +pub async fn main() { + let client = DataClient::new( + Some("http://localhost:4118".to_string()), + var("APP_API_KEY").unwrap(), + ); + + println!("data used: {}", client.get_app().await.unwrap().data_used); + + // record insert + client + .insert("rust_test".to_string(), "Hello, world!".to_string()) + .await + .unwrap(); + println!("record created"); + println!("data used: {}", client.get_app().await.unwrap().data_used); + + // testing record query then delete + let record = match client + .query(&SimplifiedQuery { + query: AppDataSelectQuery::KeyIs("rust_test".to_string()), + mode: AppDataSelectMode::One(0), + }) + .await + .unwrap() + { + AppDataQueryResult::One(x) => x, + AppDataQueryResult::Many(_) => unreachable!(), + }; + + println!("{:?}", record); + + client + .update(record.id, "Hello, world! 1".to_string()) + .await + .unwrap(); + println!("record updated"); + println!("data used: {}", client.get_app().await.unwrap().data_used); + + let record = match client + .query(&SimplifiedQuery { + query: AppDataSelectQuery::KeyIs("rust_test".to_string()), + mode: AppDataSelectMode::One(0), + }) + .await + .unwrap() + { + AppDataQueryResult::One(x) => x, + AppDataQueryResult::Many(_) => unreachable!(), + }; + + println!("{:?}", record); + + client.remove(record.id).await.unwrap(); + println!("record deleted"); + println!("data used: {}", client.get_app().await.unwrap().data_used); +} diff --git a/crates/core/src/lib.rs b/crates/core/src/lib.rs index aa61770..b785f89 100644 --- a/crates/core/src/lib.rs +++ b/crates/core/src/lib.rs @@ -1,6 +1,13 @@ +#[cfg(feature = "types")] pub mod config; +#[cfg(feature = "database")] pub mod database; +#[cfg(feature = "types")] pub mod model; +#[cfg(feature = "sdk")] +pub mod sdk; +#[cfg(feature = "database")] pub use database::DataManager; +#[cfg(feature = "database")] pub use oiseau::cache; diff --git a/crates/core/src/sdk.rs b/crates/core/src/sdk.rs new file mode 100644 index 0000000..72b82e8 --- /dev/null +++ b/crates/core/src/sdk.rs @@ -0,0 +1,313 @@ +use crate::model::{ + apps::{ + AppDataQuery, AppDataQueryResult, AppDataSelectMode, AppDataSelectQuery, ThirdPartyApp, + }, + ApiReturn, Error, Result, +}; +use reqwest::{ + multipart::{Form, Part}, + Client as HttpClient, +}; +pub use reqwest::Method; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; + +macro_rules! api_return_ok { + ($ret:ty, $res:ident) => { + match $res.json::>().await { + Ok(x) => { + if x.ok { + Ok(x.payload) + } else { + Err(Error::MiscError(x.message)) + } + } + Err(e) => Err(Error::MiscError(e.to_string())), + } + }; +} + +/// A simplified app data query which matches what the API endpoint actually requires. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SimplifiedQuery { + pub query: AppDataSelectQuery, + pub mode: AppDataSelectMode, +} + +/// The data client is used to access an app's data storage capabilities. +#[derive(Debug, Clone)] +pub struct DataClient { + /// The HTTP client associated with this client. + pub http: HttpClient, + /// The app's API key. You can retrieve this from the web dashboard. + pub api_key: String, + /// The origin of the Tetratto server. When creating with [`DataClient::new`], + /// you can provide `None` to use `https://tetratto.com`. + pub host: String, +} + +impl DataClient { + /// Create a new [`DataClient`]. + pub fn new(host: Option, api_key: String) -> Self { + Self { + http: HttpClient::new(), + api_key, + host: host.unwrap_or("https://tetratto.com".to_string()), + } + } + + /// Get the current app using the provided API key. + /// + /// # Usage + /// ```rust + /// let client = DataClient::new("https://tetratto.com".to_string(), "...".to_string()); + /// let app = client.get_app().await.expect("failed to get app"); + /// ``` + pub async fn get_app(&self) -> Result { + match self + .http + .get(format!("{}/api/v1/app_data/app", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .send() + .await + { + Ok(x) => api_return_ok!(ThirdPartyApp, x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + + /// Query the app's data. + pub async fn query(&self, query: &SimplifiedQuery) -> Result { + match self + .http + .post(format!("{}/api/v1/app_data/query", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .json(&query) + .send() + .await + { + Ok(x) => api_return_ok!(AppDataQueryResult, x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + + /// Insert a key, value pair into the app's data. + pub async fn insert(&self, key: String, value: String) -> Result { + match self + .http + .post(format!("{}/api/v1/app_data", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .json(&serde_json::Value::Object({ + let mut map = serde_json::Map::new(); + map.insert("key".to_string(), serde_json::Value::String(key)); + map.insert("value".to_string(), serde_json::Value::String(value)); + map + })) + .send() + .await + { + Ok(x) => api_return_ok!(String, x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + + /// Update a record's value given its ID and the new value. + pub async fn update(&self, id: usize, value: String) -> Result<()> { + match self + .http + .post(format!("{}/api/v1/app_data/{id}/value", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .json(&serde_json::Value::Object({ + let mut map = serde_json::Map::new(); + map.insert("value".to_string(), serde_json::Value::String(value)); + map + })) + .send() + .await + { + Ok(x) => api_return_ok!((), x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + + /// Delete a row from the app's data by its `id`. + pub async fn remove(&self, id: usize) -> Result<()> { + match self + .http + .delete(format!("{}/api/v1/app_data/{id}", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .send() + .await + { + Ok(x) => api_return_ok!((), x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + + /// Delete row(s) from the app's data by a query. + pub async fn remove_query(&self, query: &AppDataQuery) -> Result<()> { + match self + .http + .delete(format!("{}/api/v1/app_data/query", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .json(&query) + .send() + .await + { + Ok(x) => api_return_ok!((), x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } +} + +/// The state of the [`ApiClient`]. +#[derive(Debug, Clone, Default)] +pub struct ApiClientState { + /// The token you received from an app grant request. + pub user_token: String, + /// The verifier you received from an app grant request. + pub user_verifier: String, + /// The ID of the user this client is connecting to. + pub user_id: usize, + /// The ID of the app that is being used for user grants. + /// + /// You can get this from the web dashboard. + pub app_id: usize, +} + +/// The API client is used to manage authentication flow and send requests on behalf of a user. +/// +/// This client assumes you already have the required information for the given user. +/// If you don't, try using the JS SDK to extract this information. +#[derive(Debug, Clone)] +pub struct ApiClient { + /// The HTTP client associated with this client. + pub http: HttpClient, + /// The general state of the client. Will be updated whenever you refresh the user's token. + pub state: ApiClientState, + /// The origin of the Tetratto server. When creating with [`ApiClient::new`], + /// you can provide `None` to use `https://tetratto.com`. + pub host: String, +} + +impl ApiClient { + /// Create a new [`ApiClient`]. + pub fn new(host: Option, state: ApiClientState) -> Self { + Self { + http: HttpClient::new(), + state, + host: host.unwrap_or("https://tetratto.com".to_string()), + } + } + + /// Refresh the client's user_token. + pub async fn refresh_token(&mut self) -> Result { + match self + .http + .post(format!( + "{}/api/v1/auth/user/{}/grants/{}/refresh", + self.host, self.state.user_id, self.state.app_id + )) + .header("X-Cookie", &format!("Atto-Grant={}", self.state.user_token)) + .json(&serde_json::Value::Object({ + let mut map = serde_json::Map::new(); + map.insert( + "verifier".to_string(), + serde_json::Value::String(self.state.user_verifier.to_owned()), + ); + map + })) + .send() + .await + { + Ok(x) => { + let ret = api_return_ok!(String, x)?; + self.state.user_token = ret.clone(); + Ok(ret) + } + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + + /// Send a simple JSON request to the given endpoint. + pub async fn request( + &self, + route: String, + method: Method, + body: Option<&B>, + ) -> Result> + where + T: Serialize + DeserializeOwned, + B: Serialize + ?Sized, + { + if let Some(body) = body { + match self + .http + .request(method, format!("{}/api/v1/auth/{route}", self.host)) + .header("X-Cookie", &format!("Atto-Grant={}", self.state.user_token)) + .json(&body) + .send() + .await + { + Ok(x) => api_return_ok!(ApiReturn, x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } else { + match self + .http + .request(method, format!("{}/api/v1/auth/{route}", self.host)) + .header("X-Cookie", &format!("Atto-Grant={}", self.state.user_token)) + .send() + .await + { + Ok(x) => api_return_ok!(ApiReturn, x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + } + + /// Send a JSON request with attachments to the given endpoint. + /// + /// This type of request is only required for routes which use JsonMultipart, + /// such as `POST /api/v1/posts` (`create_post`). + /// + /// Method is locked to `POST` for this type of request. + pub async fn request_attachments( + &self, + route: String, + attachments: Vec>, + body: &B, + ) -> Result> + where + T: Serialize + DeserializeOwned, + B: Serialize + ?Sized, + { + let mut multipart_body = Form::new(); + + // add attachments + for v in attachments.clone() { + // the file name doesn't matter + multipart_body = multipart_body.part(String::new(), Part::bytes(v)); + } + + drop(attachments); + + // add json + multipart_body = multipart_body.part( + String::new(), + Part::text(serde_json::to_string(body).unwrap()), + ); + + // ... + match self + .http + .post(format!("{}/api/v1/auth/{route}", self.host)) + .header("X-Cookie", &format!("Atto-Grant={}", self.state.user_token)) + .multipart(multipart_body) + .send() + .await + { + Ok(x) => api_return_ok!(ApiReturn, x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } +} diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index d7661c2..bfccdbf 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -5,6 +5,7 @@ edition = "2024" authors.workspace = true repository.workspace = true license.workspace = true +homepage.workspace = true [dependencies] pathbufd = "0.1.4" diff --git a/example/app_sdk_test.js b/example/app_sdk_test.js index bd69519..37f2063 100644 --- a/example/app_sdk_test.js +++ b/example/app_sdk_test.js @@ -15,7 +15,7 @@ console.log("record created"); console.log("data used:", (await sdk.app()).data_used); // testing record query then delete -const record = ( +let record = ( await sdk.query({ query: { KeyIs: "deno_test" }, mode: { One: 0 }, @@ -23,6 +23,20 @@ const record = ( ).One; console.log(record); + +await sdk.update("deno_test", "Hello, Deno! 1"); +console.log("record updated"); +console.log("data used:", (await sdk.app()).data_used); + +record = ( + await sdk.query({ + query: { KeyIs: "deno_test" }, + mode: { One: 0 }, + }) +).One; + +console.log(record); + await sdk.remove(record.id); console.log("record deleted"); console.log("data used:", (await sdk.app()).data_used); From 7d30d65a3b9bb83ab2a0e2499e86d1fddb7a3366 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 19 Jul 2025 15:38:58 -0400 Subject: [PATCH 78/93] fix: profile panic --- crates/app/src/macros.rs | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/crates/app/src/macros.rs b/crates/app/src/macros.rs index 13333da..69730e0 100644 --- a/crates/app/src/macros.rs +++ b/crates/app/src/macros.rs @@ -194,11 +194,13 @@ macro_rules! check_user_blocked_or_private { ($user:expr, $other_user:ident, $data:ident, $jar:ident) => { // check is_deactivated if ($user.is_none() && $other_user.is_deactivated) - | !$user - .as_ref() - .unwrap() - .permissions - .check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS) + | ($user.is_some() + && !$user + .as_ref() + .unwrap() + .permissions + .check(tetratto_core::model::permissions::FinePermission::MANAGE_USERS) + && $other_user.is_deactivated) { return Err(Html( render_error( From 35b66c94d08f85ccf3ecf826ae0d9cc31cfce79f Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 19 Jul 2025 21:30:41 -0400 Subject: [PATCH 79/93] chore: publish l10n, shared, and core --- Cargo.lock | 4 ++-- crates/app/Cargo.toml | 2 +- crates/core/Cargo.toml | 7 ++++--- crates/l10n/Cargo.toml | 1 + crates/shared/Cargo.toml | 3 ++- justfile | 14 ++++++++++++++ 6 files changed, 24 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 1a9e39a..a3a09d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2885,9 +2885,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.140" +version = "1.0.141" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20068b6e96dc6c9bd23e01df8827e6c7e1f2fddd43c21810382803c136b99373" +checksum = "30b9eff21ebe718216c6ec64e1d9ac57087aad11efc64e32002bce4a0d4c03d3" dependencies = [ "itoa", "memchr", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 5c60129..09efda7 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -29,7 +29,7 @@ tetratto-l10n = { path = "../l10n" } image = "0.25.6" reqwest = { version = "0.12.22", features = ["json", "stream"] } regex = "1.11.1" -serde_json = "1.0.140" +serde_json = "1.0.141" mime_guess = "2.0.5" cf-turnstile = "0.2.0" contrasted = "0.1.3" diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 526b195..2dd5dfa 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,5 +1,6 @@ [package] name = "tetratto-core" +description = "The core behind Tetratto" version = "11.0.0" edition = "2024" authors.workspace = true @@ -17,9 +18,9 @@ default = ["database", "types", "sdk"] pathbufd = "0.1.4" serde = { version = "1.0.219", features = ["derive"] } toml = "0.9.2" -tetratto-shared = { path = "../shared" } -tetratto-l10n = { path = "../l10n" } -serde_json = "1.0.140" +tetratto-shared = { version = "11.0.0", path = "../shared" } +tetratto-l10n = { version = "11.0.0", path = "../l10n" } +serde_json = "1.0.141" totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"], optional = true } reqwest = { version = "0.12.22", features = ["json", "multipart"], optional = true } bitflags = { version = "2.9.1", optional = true } diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index bfccdbf..6b8cb59 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -1,5 +1,6 @@ [package] name = "tetratto-l10n" +description = "Localization for Tetratto" version = "11.0.0" edition = "2024" authors.workspace = true diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index f21f611..38fd768 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -1,5 +1,6 @@ [package] name = "tetratto-shared" +description = "Shared stuff for Tetratto" version = "11.0.0" edition = "2024" authors.workspace = true @@ -12,7 +13,7 @@ chrono = "0.4.41" markdown = "1.0.0" hex_fmt = "0.3.0" rand = "0.9.1" -serde = "1.0.219" +serde = { version = "1.0.219", features = ["derive"] } sha2 = "0.10.9" snowflaked = "1.0.3" uuid = { version = "1.17.0", features = ["v4"] } diff --git a/justfile b/justfile index a83d0c4..b106417 100644 --- a/justfile +++ b/justfile @@ -12,3 +12,17 @@ doc: test: cd example && LITTLEWEB=true PORT=4119 cargo run & cd example && cargo run + +publish-shared: + cargo publish --allow-dirty --package tetratto-shared + +publish-l10n: + cargo publish --allow-dirty --package tetratto-l10n + +publish-core: + cargo publish --allow-dirty --package tetratto-core + +publish: + just publish-shared + just publish-l10n + just publish-core From 6f2d556c65940a360e574d844383d1bd4626008e Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 19 Jul 2025 23:21:01 -0400 Subject: [PATCH 80/93] add: app data rename method --- Cargo.lock | 8 ++--- crates/app/Cargo.toml | 2 +- crates/app/src/main.rs | 2 +- crates/app/src/public/js/app_sdk.js | 24 ++++++++++++++ crates/app/src/routes/api/v1/app_data.rs | 41 +++++++++++++++++++++++- crates/app/src/routes/api/v1/mod.rs | 6 ++++ crates/core/Cargo.toml | 6 ++-- crates/core/src/sdk.rs | 19 +++++++++++ crates/l10n/Cargo.toml | 2 +- crates/shared/Cargo.toml | 2 +- 10 files changed, 100 insertions(+), 12 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a3a09d1..b03e9ca 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3269,7 +3269,7 @@ dependencies = [ [[package]] name = "tetratto" -version = "11.0.0" +version = "12.0.0" dependencies = [ "ammonia", "async-stripe", @@ -3301,7 +3301,7 @@ dependencies = [ [[package]] name = "tetratto-core" -version = "11.0.0" +version = "12.0.0" dependencies = [ "async-recursion", "base16ct", @@ -3325,7 +3325,7 @@ dependencies = [ [[package]] name = "tetratto-l10n" -version = "11.0.0" +version = "12.0.0" dependencies = [ "pathbufd", "serde", @@ -3334,7 +3334,7 @@ dependencies = [ [[package]] name = "tetratto-shared" -version = "11.0.0" +version = "12.0.0" dependencies = [ "ammonia", "chrono", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 09efda7..e5ca15f 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "tetratto" -version = "11.0.0" +version = "12.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 8347c23..bfac36f 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -131,7 +131,7 @@ async fn main() { let client = Client::new(); let mut app = Router::new(); - // cretae stripe client + // create stripe client let stripe_client = if let Some(ref stripe) = config.stripe { Some(StripeClient::new(stripe.secret.clone())) } else { diff --git a/crates/app/src/public/js/app_sdk.js b/crates/app/src/public/js/app_sdk.js index 2ca1f53..cd21e6a 100644 --- a/crates/app/src/public/js/app_sdk.js +++ b/crates/app/src/public/js/app_sdk.js @@ -140,6 +140,29 @@ export default function tetratto({ ); } + async function rename(id, key) { + if (!api_key) { + throw Error("No API key provided."); + } + + return api_promise( + json_parse( + await ( + await fetch(`${host}/api/v1/app_data/${id}/key`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Atto-Secret-Key": api_key, + }, + body: json_stringify({ + key, + }), + }) + ).text(), + ), + ); + } + async function remove(id) { if (!api_key) { throw Error("No API key provided."); @@ -265,6 +288,7 @@ export default function tetratto({ query, insert, update, + rename, remove, remove_query, // user connection diff --git a/crates/app/src/routes/api/v1/app_data.rs b/crates/app/src/routes/api/v1/app_data.rs index f9da1c8..d5e8c3f 100644 --- a/crates/app/src/routes/api/v1/app_data.rs +++ b/crates/app/src/routes/api/v1/app_data.rs @@ -1,6 +1,6 @@ use crate::{ get_app_from_key, - routes::api::v1::{InsertAppData, QueryAppData, UpdateAppDataValue}, + routes::api::v1::{InsertAppData, QueryAppData, UpdateAppDataValue, UpdateAppDataKey}, State, }; use axum::{extract::Path, http::HeaderMap, response::IntoResponse, Extension, Json}; @@ -94,6 +94,37 @@ pub async fn create_request( } } +pub async fn update_key_request( + headers: HeaderMap, + Extension(data): Extension, + Path(id): Path, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let app = match get_app_from_key!(data, headers) { + Some(x) => x, + None => return Json(Error::NotAllowed.into()), + }; + + let app_data = match data.get_app_data_by_id(id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if app_data.app != app.id { + return Json(Error::NotAllowed.into()); + } + + match data.update_app_data_key(id, &req.key).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Data updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + pub async fn update_value_request( headers: HeaderMap, Extension(data): Extension, @@ -116,6 +147,10 @@ pub async fn update_value_request( Err(e) => return Json(e.into()), }; + if app_data.app != app.id { + return Json(Error::NotAllowed.into()); + } + // check size let size_without = app.data_used - app_data.value.len(); let new_size = size_without + req.value.len(); @@ -155,6 +190,10 @@ pub async fn delete_request( Err(e) => return Json(e.into()), }; + if app_data.app != app.id { + return Json(Error::NotAllowed.into()); + } + // ... if let Err(e) = data .add_app_data_used(app.id, -(app_data.value.len() as i32)) diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 60bbf20..6b56e9b 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -439,6 +439,7 @@ pub fn routes() -> Router { .route("/app_data", post(app_data::create_request)) .route("/app_data/app", get(app_data::get_app_request)) .route("/app_data/{id}", delete(app_data::delete_request)) + .route("/app_data/{id}/key", post(app_data::update_key_request)) .route("/app_data/{id}/value", post(app_data::update_value_request)) .route("/app_data/query", post(app_data::query_request)) .route("/app_data/query", delete(app_data::delete_query_request)) @@ -1176,6 +1177,11 @@ pub struct UpdateUploadAlt { pub alt: String, } +#[derive(Deserialize)] +pub struct UpdateAppDataKey { + pub key: String, +} + #[derive(Deserialize)] pub struct UpdateAppDataValue { pub value: String, diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 2dd5dfa..98a0947 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "tetratto-core" description = "The core behind Tetratto" -version = "11.0.0" +version = "12.0.0" edition = "2024" authors.workspace = true repository.workspace = true @@ -18,8 +18,8 @@ default = ["database", "types", "sdk"] pathbufd = "0.1.4" serde = { version = "1.0.219", features = ["derive"] } toml = "0.9.2" -tetratto-shared = { version = "11.0.0", path = "../shared" } -tetratto-l10n = { version = "11.0.0", path = "../l10n" } +tetratto-shared = { version = "12.0.0", path = "../shared" } +tetratto-l10n = { version = "12.0.0", path = "../l10n" } serde_json = "1.0.141" totp-rs = { version = "5.7.0", features = ["qr", "gen_secret"], optional = true } reqwest = { version = "0.12.22", features = ["json", "multipart"], optional = true } diff --git a/crates/core/src/sdk.rs b/crates/core/src/sdk.rs index 72b82e8..71ba502 100644 --- a/crates/core/src/sdk.rs +++ b/crates/core/src/sdk.rs @@ -129,6 +129,25 @@ impl DataClient { } } + /// Update a record's key given its ID and the new key. + pub async fn rename(&self, id: usize, key: String) -> Result<()> { + match self + .http + .post(format!("{}/api/v1/app_data/{id}/key", self.host)) + .header("Atto-Secret-Key", &self.api_key) + .json(&serde_json::Value::Object({ + let mut map = serde_json::Map::new(); + map.insert("key".to_string(), serde_json::Value::String(key)); + map + })) + .send() + .await + { + Ok(x) => api_return_ok!((), x), + Err(e) => Err(Error::MiscError(e.to_string())), + } + } + /// Delete a row from the app's data by its `id`. pub async fn remove(&self, id: usize) -> Result<()> { match self diff --git a/crates/l10n/Cargo.toml b/crates/l10n/Cargo.toml index 6b8cb59..5993ffc 100644 --- a/crates/l10n/Cargo.toml +++ b/crates/l10n/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "tetratto-l10n" description = "Localization for Tetratto" -version = "11.0.0" +version = "12.0.0" edition = "2024" authors.workspace = true repository.workspace = true diff --git a/crates/shared/Cargo.toml b/crates/shared/Cargo.toml index 38fd768..02b14fc 100644 --- a/crates/shared/Cargo.toml +++ b/crates/shared/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "tetratto-shared" description = "Shared stuff for Tetratto" -version = "11.0.0" +version = "12.0.0" edition = "2024" authors.workspace = true repository.workspace = true From 270d7550d63d594bc11c7b97629829cc54e054bb Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 20 Jul 2025 03:12:27 -0400 Subject: [PATCH 81/93] fix: app data limits --- crates/core/src/database/app_data.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/crates/core/src/database/app_data.rs b/crates/core/src/database/app_data.rs index d6225fc..2b8520c 100644 --- a/crates/core/src/database/app_data.rs +++ b/crates/core/src/database/app_data.rs @@ -117,13 +117,13 @@ impl DataManager { let app = self.get_app_by_id(data.app).await?; // check values - if data.key.len() < 2 { + if data.key.len() < 1 { return Err(Error::DataTooShort("key".to_string())); - } else if data.key.len() > 32 { + } else if data.key.len() > 128 { return Err(Error::DataTooLong("key".to_string())); } - if data.value.len() < 2 { + if data.value.len() < 1 { return Err(Error::DataTooShort("value".to_string())); } else if data.value.len() > Self::MAXIMUM_DATA_SIZE { return Err(Error::DataTooLong("value".to_string())); From d58e47cbbefe8c63d532ffc8e9871a17d02ed248 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 20 Jul 2025 03:33:03 -0400 Subject: [PATCH 82/93] fix: only add delta bytes when changing app data value --- crates/app/src/routes/api/v1/app_data.rs | 9 ++++++++- crates/core/src/database/app_data.rs | 2 +- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/crates/app/src/routes/api/v1/app_data.rs b/crates/app/src/routes/api/v1/app_data.rs index d5e8c3f..5e0182e 100644 --- a/crates/app/src/routes/api/v1/app_data.rs +++ b/crates/app/src/routes/api/v1/app_data.rs @@ -160,7 +160,14 @@ pub async fn update_value_request( } // ... - if let Err(e) = data.add_app_data_used(app.id, req.value.len() as i32).await { + // we only need to add the delta size (the next size - the old size) + if let Err(e) = data + .add_app_data_used( + app.id, + (req.value.len() as i32) - (app_data.value.len() as i32), + ) + .await + { return Json(e.into()); } diff --git a/crates/core/src/database/app_data.rs b/crates/core/src/database/app_data.rs index 2b8520c..9aeafc1 100644 --- a/crates/core/src/database/app_data.rs +++ b/crates/core/src/database/app_data.rs @@ -5,7 +5,7 @@ use crate::{auto_method, DataManager}; use oiseau::{PostgresRow, execute, get, query_row, query_rows, params}; pub const FREE_DATA_LIMIT: usize = 512_000; -pub const PASS_DATA_LIMIT: usize = 5_242_880; +pub const PASS_DATA_LIMIT: usize = 26_214_400; impl DataManager { /// Get a [`AppData`] from an SQL row. From 55460fc60ac66c5e098d9ff79f3de0479a262ef0 Mon Sep 17 00:00:00 2001 From: trisua Date: Sun, 20 Jul 2025 15:04:16 -0400 Subject: [PATCH 83/93] add: actually parse arrow alignment for markdown --- crates/app/src/public/html/components.lisp | 2 +- crates/shared/src/markdown.rs | 126 ++++++++++++++++++++- 2 files changed, 123 insertions(+), 5 deletions(-) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index d3c1a7f..8475223 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -2415,7 +2415,7 @@ (ul ("style" "margin-bottom: var(--pad-4)") (li - (text "Increased app storage limit (500 KB->5 MB)")) + (text "Increased app storage limit (500 KB->25 MB)")) (li (text "Ability to create forges")) (li diff --git a/crates/shared/src/markdown.rs b/crates/shared/src/markdown.rs index 82d6b79..540ca97 100644 --- a/crates/shared/src/markdown.rs +++ b/crates/shared/src/markdown.rs @@ -4,6 +4,7 @@ use std::collections::HashSet; /// Render markdown input into HTML pub fn render_markdown(input: &str) -> String { + let input = &parse_alignment(input); let options = Options { compile: CompileOptions { allow_any_img_src: false, @@ -45,7 +46,7 @@ pub fn render_markdown(input: &str) -> String { Builder::default() .generic_attributes(allowed_attributes) .add_tags(&[ - "video", "source", "img", "b", "span", "p", "i", "strong", "em", "a", + "video", "source", "img", "b", "span", "p", "i", "strong", "em", "a", "align", ]) .rm_tags(&["script", "style", "link", "canvas"]) .add_tag_attributes("a", &["href", "target"]) @@ -57,7 +58,124 @@ pub fn render_markdown(input: &str) -> String { "loading=\"lazy\" src=\"/api/v1/util/proxy?url=http", ) .replace("