From 02f3d089260a55d98dddbd66368d30c83378105b Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 18 Jul 2025 14:52:00 -0400 Subject: [PATCH] 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 } ) } })