add: developer pass

This commit is contained in:
trisua 2025-07-18 14:52:00 -04:00
parent 636ecce9f4
commit 02f3d08926
14 changed files with 355 additions and 101 deletions

View file

@ -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);

View file

@ -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<String, Value>) -> tera::Result<Va
.into())
}
fn check_dev_pass(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
Ok(
SecondaryPermission::from_bits(value.as_u64().unwrap() as u32)
.unwrap()
.check(SecondaryPermission::DEVELOPER_PASS)
.into(),
)
}
fn check_staff_badge(value: &Value, _: &HashMap<String, Value>) -> tera::Result<Value> {
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);

View file

@ -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

View file

@ -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 %}")

View file

@ -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

View file

@ -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);")

View file

@ -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")

View file

@ -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(),

View file

@ -194,22 +194,30 @@ pub struct StripeConfig {
///
/// <https://docs.stripe.com/no-code/customer-portal>
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)

View file

@ -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!(

View file

@ -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(),
));

View file

@ -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::<usize, i32>(0))
);

View file

@ -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);
}

View file

@ -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 }
)
}
})