diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 8a96808..4e5aaf8 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -208,6 +208,7 @@ version = "1.0.0" "settings:tab.billing" = "Billing" "settings:tab.uploads" = "Uploads" "settings:tab.invites" = "Invites" +"setttings:label.applied_configurations" = "Applied configurations" "mod_panel:label.open_reported_content" = "Open reported content" "mod_panel:label.manage_profile" = "Manage profile" @@ -345,3 +346,6 @@ version = "1.0.0" "economy:label.automail_message" = "Automail message" "economy:action.buy" = "Buy" "economy:label.already_purchased" = "Already purchased" +"economy:label.snippet_data" = "Snippet data" +"economy:action.apply" = "Apply" +"economy:action.unapply" = "Unapply" diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index 7115bee..b1e4599 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -2491,6 +2491,8 @@ (text "Create infinite Littleweb sites")) (li (text "Create infinite Littleweb domains")) + (li + (text "Create and sell CSS snippet products")) (text "{% if config.security.enable_invite_codes -%}") (li diff --git a/crates/app/src/public/html/economy/edit.lisp b/crates/app/src/public/html/economy/edit.lisp index 328133f..175b566 100644 --- a/crates/app/src/public/html/economy/edit.lisp +++ b/crates/app/src/public/html/economy/edit.lisp @@ -150,35 +150,60 @@ (icon (text "package-check")) (b (str (text "economy:label.fulfillment_style")))) - (form + (div ("class" "card flex flex_col gap_2") - ("onsubmit" "update_method_from_form(event)") - (p (text "If you choose to send an automated mail letter upon purchase, users will automatically receive the message you supply below.")) - (p (text "If you disable automail, you'll be required to manually mail users who have purchased your product before the transfer is finalized.")) + (select + ("id" "fulfillment_style_select") + ("onchange" "mirror_fulfillment_style_select(true)") + (option ("value" "mail") (text "Mail") ("selected" "{{ not product.method == \"ProfileStyle\" }}")) + (option ("value" "snippet") (text "CSS Snippet") ("selected" "{{ product.method == \"ProfileStyle\" }}"))) + (form + ("class" "flex flex_col gap_2 hidden") + ("id" "mail_fulfillment") + ("onsubmit" "update_method_from_form(event)") + (p (text "If you choose to send an automated mail letter upon purchase, users will automatically receive the message you supply below.")) + (p (text "If you disable automail, you'll be required to manually mail users who have purchased your product before the transfer is finalized.")) + (text "{% set is_automail = product.method != \"ManualMail\" and product.method != \"ProfileStyle\" %}") - (label - ("for" "use_automail") - ("class" "flex items_center gap_2") - (input - ("type" "checkbox") - ("id" "use_automail") - ("name" "use_automail") - ("class" "w_content") - ("oninput" "mirror_use_automail()") - ("checked" "{% if product.method != \"ManualMail\" -%} true {%- else -%} false {%- endif %}")) - (span - (str (text "economy:label.use_automail")))) - (div - ("class" "flex flex_col gap_1") (label - ("for" "automail_message") - (str (text "economy:label.automail_message"))) - (textarea - ("name" "automail_message") - ("id" "automail_message") - ("placeholder" "automail_message") - (text "{% if product.method != \"ManualMail\" -%} {{ product.method.AutoMail }} {%- endif %}"))) - (button (str (text "general:action.save"))))) + ("for" "use_automail") + ("class" "flex items_center gap_2") + (input + ("type" "checkbox") + ("id" "use_automail") + ("name" "use_automail") + ("class" "w_content") + ("oninput" "mirror_use_automail()") + ("checked" "{% if is_automail -%} true {%- else -%} false {%- endif %}")) + (span + (str (text "economy:label.use_automail")))) + (div + ("class" "flex flex_col gap_1") + (label + ("for" "automail_message") + (str (text "economy:label.automail_message"))) + (textarea + ("name" "automail_message") + ("id" "automail_message") + ("placeholder" "automail_message") + (text "{% if is_automail -%} {{ product.method.AutoMail }} {%- endif %}"))) + (button (str (text "general:action.save")))) + (form + ("class" "flex flex_col gap_2 hidden") + ("id" "snippet_fulfillment") + ("onsubmit" "update_data_from_form(event)") + (text "{{ components::supporter_ad(body=\"Become a supporter to create snippets!\") }}") + (div + ("class" "flex flex_col gap_1") + (label + ("for" "data") + (str (text "economy:label.snippet_data"))) + (textarea + ("name" "data") + ("id" "data") + ("placeholder" "data") + (text "{{ product.data }}"))) + (button (str (text "general:action.save")))))) (div ("class" "flex gap_2") @@ -347,6 +372,28 @@ }); } + async function update_data_from_form(e) { + e.preventDefault(); + await trigger(\"atto::debounce\", [\"products::update\"]); + + fetch(\"/api/v1/products/{{ product.id }}/data\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + data: e.target.data.value, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + async function delete_product() { if ( !(await trigger(\"atto::confirm\", [ @@ -378,7 +425,46 @@ } } + globalThis.mirror_fulfillment_style_select = (send = false) => { + const selected = document.getElementById(\"fulfillment_style_select\").selectedOptions[0].value; + + if (selected === \"mail\") { + document.getElementById(\"mail_fulfillment\").classList.remove(\"hidden\"); + document.getElementById(\"snippet_fulfillment\").classList.add(\"hidden\"); + + if (send) { + update_method_from_form({ + preventDefault: () => {}, + target: document.getElementById(\"mail_fulfillment\"), + }); + } + } else { + document.getElementById(\"mail_fulfillment\").classList.add(\"hidden\"); + document.getElementById(\"snippet_fulfillment\").classList.remove(\"hidden\"); + + if (send) { + fetch(\"/api/v1/products/{{ product.id }}/method\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + method: \"ProfileStyle\", + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + } + } + setTimeout(() => { mirror_use_automail(); + mirror_fulfillment_style_select(); }, 150);")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/economy/product.lisp b/crates/app/src/public/html/economy/product.lisp index 7a1ce86..d671551 100644 --- a/crates/app/src/public/html/economy/product.lisp +++ b/crates/app/src/public/html/economy/product.lisp @@ -19,26 +19,44 @@ ("class" "card lowered w_full no_p_margin") (text "{{ product.description|markdown|safe }}")) + (text "{% if already_purchased -%}") + (span + ("class" "green flex items_center gap_2") + (icon (text "circle-check")) + (str (text "economy:label.already_purchased"))) + (text "{%- endif %}") + (div ("class" "flex gap_2 items_center") + (text "{% if user.id != product.owner -%}") + (text "{% if not already_purchased -%}") + ; price (a ("class" "button camo lowered") ("href" "/wallet") ("target" "_blank") (icon (text "badge-cent")) (text "{{ product.price }}")) - (text "{% if user.id != product.owner -%}") - (text "{% if not already_purchased -%}") + ; buy button (button ("onclick" "purchase()") ("disabled" "{{ product.stock == 0 }}") (icon (text "piggy-bank")) (str (text "economy:action.buy"))) (text "{% else %}") - (span - ("class" "green flex items_center gap_2") - (icon (text "circle-check")) - (str (text "economy:label.already_purchased"))) + ; profile style snippets + (text "{% if product.method == \"ProfileStyle\" -%} {% if not product.id in applied_configurations_mapped -%}") + (button + ("onclick" "apply()") + (icon (text "check")) + (str (text "economy:action.apply"))) + (text "{% else %}") + (button + ("onclick" "remove()") + (icon (text "x")) + (str (text "economy:action.unapply"))) + (text "{%- endif %} {%- endif %}") + ; ... (text "{%- endif %}") (text "{% else %}") (a @@ -69,6 +87,59 @@ res.ok ? \"success\" : \"error\", res.message, ]); + + if (res.ok) { + window.location.reload(); + } + }); + } + + async function apply() { + await trigger(\"atto::debounce\", [\"user::update\"]); + fetch(\"/api/v1/auth/user/{{ user.id }}/applied_configuration\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + \"type\": \"StyleSnippet\", + \"id\": \"{{ product.id }}\", + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.reload(); + } + }); + } + + async function remove() { + await trigger(\"atto::debounce\", [\"user::update\"]); + fetch(\"/api/v1/auth/user/{{ user.id }}/applied_configuration\", { + method: \"DELETE\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + \"id\": \"{{ product.id }}\", + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + + if (res.ok) { + window.location.reload(); + } }); }")) (text "{% endblock %}") diff --git a/crates/app/src/public/html/profile/base.lisp b/crates/app/src/public/html/profile/base.lisp index e4e230b..1fde607 100644 --- a/crates/app/src/public/html/profile/base.lisp +++ b/crates/app/src/public/html/profile/base.lisp @@ -468,6 +468,12 @@ ("class" "rhs w_full flex flex_col gap_4") (text "{% block content %}{% endblock %}"))))) +(text "{% if not use_user_theme -%}") +(text "{% for cnf in applied_configurations -%}") +(text "{{ cnf|safe }}") +(text "{%- endfor %}") +(text "{%- endif %}") + (text "{% if not is_self and profile.settings.warning -%}") (script (text "setTimeout(() => { diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 4150508..8bda6c2 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -1162,6 +1162,26 @@ ("class" "fade") (text "This represents the site theme shown to users viewing your profile."))))) + (text "{% if profile.applied_configurations|length > 0 -%}") + (div + ("class" "card_nest") + ("ui_ident" "applied_configurations") + (div + ("class" "card small flex items_center gap_2") + (icon (text "cog")) + (str (text "setttings:label.applied_configurations"))) + (div + ("class" "card") + (p (text "Products that you have purchased and applied to your profile are displayed below. Snippets are always synced to the product, meaning the owner could update it at any time.")) + (ul + (text "{% for cnf in profile.applied_configurations -%}") + (li + (text "{{ cnf[0] }} ") + (a + ("href" "/product/{{ cnf[1] }}") + (text "{{ cnf[1] }}"))) + (text "{%- endfor %}")))) + (text "{%- endif %}") (button ("onclick" "save_settings()") ("id" "save_button") @@ -1742,6 +1762,7 @@ \"import_export\", \"theme_preference\", \"profile_theme\", + \"applied_configurations\", ]); ui.generate_settings_ui( diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs index ba3c17a..cb4955d 100644 --- a/crates/app/src/routes/api/v1/auth/profile.rs +++ b/crates/app/src/routes/api/v1/auth/profile.rs @@ -3,10 +3,11 @@ use crate::{ get_user_from_token, model::{ApiReturn, Error}, routes::api::v1::{ - AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, RefreshGrantToken, - UpdateSecondaryUserRole, UpdateUserAwaitingPurchase, UpdateUserBanExpire, - UpdateUserBanReason, UpdateUserInviteCode, UpdateUserIsDeactivated, UpdateUserIsVerified, - UpdateUserPassword, UpdateUserRole, UpdateUserUsername, + AddAppliedConfiguration, AppendAssociations, AwardAchievement, DeleteUser, DisableTotp, + RefreshGrantToken, RemoveAppliedConfiguration, UpdateSecondaryUserRole, + UpdateUserAwaitingPurchase, UpdateUserBanExpire, UpdateUserBanReason, UpdateUserInviteCode, + UpdateUserIsDeactivated, UpdateUserIsVerified, UpdateUserPassword, UpdateUserRole, + UpdateUserUsername, }, State, }; @@ -24,6 +25,7 @@ use tetratto_core::{ cache::Cache, model::{ auth::{AchievementName, InviteCode, Token, UserSettings, SELF_SERVE_ACHIEVEMENTS}, + economy::CoinTransferMethod, moderation::AuditLogEntry, oauth, permissions::FinePermission, @@ -180,6 +182,106 @@ pub async fn update_user_settings_request( } } +/// Add the given applied configuration. +pub async fn add_applied_configuration_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProfile) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if user.id != id && !user.permissions.check(FinePermission::MANAGE_USERS) { + return Json(Error::NotAllowed.into()); + } + + let product_id: usize = match req.id.parse() { + Ok(x) => x, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }; + + let product = match data.get_product_by_id(product_id).await { + Ok(x) => x, + Err(e) => return Json(e.into()), + }; + + if data + .get_transfer_by_sender_method(user.id, CoinTransferMethod::Purchase(product.id)) + .await + .is_err() + { + return Json(Error::NotAllowed.into()); + } + + // update + user.applied_configurations.push((req.r#type, product.id)); + + // ... + match data + .update_user_applied_configurations(id, user.applied_configurations) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Applied configurations updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + +/// Remove the given applied configuration. +pub async fn remove_applied_configuration_request( + jar: CookieJar, + Path(id): Path, + Extension(data): Extension, + Json(req): Json, +) -> impl IntoResponse { + let data = &(data.read().await).0; + let mut user = match get_user_from_token!(jar, data, oauth::AppScope::UserManageProfile) { + Some(ua) => ua, + None => return Json(Error::NotAllowed.into()), + }; + + if user.id != id && !user.permissions.check(FinePermission::MANAGE_USERS) { + return Json(Error::NotAllowed.into()); + } + + let product_id: usize = match req.id.parse() { + Ok(x) => x, + Err(e) => return Json(Error::MiscError(e.to_string()).into()), + }; + + // update + user.applied_configurations.remove( + match user + .applied_configurations + .iter() + .position(|x| x.1 == product_id) + { + Some(x) => x, + None => return Json(Error::GeneralNotFound("configuration".to_string()).into()), + }, + ); + + // ... + match data + .update_user_applied_configurations(id, user.applied_configurations) + .await + { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Applied configurations updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + /// Append associations to the current user. pub async fn append_associations_request( jar: CookieJar, diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index d24b953..7ce8444 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -25,7 +25,7 @@ use axum::{ use serde::Deserialize; use tetratto_core::model::{ apps::{AppDataSelectMode, AppDataSelectQuery, AppQuota, DeveloperPassStorageQuota}, - auth::AchievementName, + auth::{AchievementName, AppliedConfigType}, communities::{ CommunityContext, CommunityJoinAccess, CommunityReadAccess, CommunityWriteAccess, PollOption, PostContext, @@ -333,6 +333,14 @@ pub fn routes() -> Router { "/auth/user/{id}/settings", post(auth::profile::update_user_settings_request), ) + .route( + "/auth/user/{id}/applied_configuration", + post(auth::profile::add_applied_configuration_request), + ) + .route( + "/auth/user/{id}/applied_configuration", + delete(auth::profile::remove_applied_configuration_request), + ) .route( "/auth/user/{id}/role", post(auth::profile::update_user_role_request), @@ -728,6 +736,7 @@ pub fn routes() -> Router { "/products/{id}/description", post(products::update_description_request), ) + .route("/products/{id}/data", post(products::update_data_request)) .route( "/products/{id}/on_sale", post(products::update_on_sale_request), @@ -1274,6 +1283,11 @@ pub struct UpdateProductDescription { pub description: String, } +#[derive(Deserialize)] +pub struct UpdateProductData { + pub data: String, +} + #[derive(Deserialize)] pub struct UpdateProductOnSale { pub on_sale: bool, @@ -1298,3 +1312,14 @@ pub struct UpdateProductMethod { pub struct UpdateProductStock { pub stock: i32, } + +#[derive(Deserialize)] +pub struct AddAppliedConfiguration { + pub r#type: AppliedConfigType, + pub id: String, +} + +#[derive(Deserialize)] +pub struct RemoveAppliedConfiguration { + pub id: String, +} diff --git a/crates/app/src/routes/api/v1/products.rs b/crates/app/src/routes/api/v1/products.rs index 424ae6d..b255de6 100644 --- a/crates/app/src/routes/api/v1/products.rs +++ b/crates/app/src/routes/api/v1/products.rs @@ -1,9 +1,15 @@ use crate::{get_user_from_token, State, cookie::CookieJar}; use axum::{extract::Path, response::IntoResponse, Extension, Json}; -use tetratto_core::model::{economy::Product, oauth, ApiReturn, Error}; +use tetratto_core::model::{ + economy::{Product, ProductFulfillmentMethod}, + oauth, + permissions::FinePermission, + ApiReturn, Error, +}; use super::{ - CreateProduct, UpdateProductDescription, UpdateProductMethod, UpdateProductOnSale, - UpdateProductPrice, UpdateProductSingleUse, UpdateProductStock, UpdateProductTitle, + CreateProduct, UpdateProductData, UpdateProductDescription, UpdateProductMethod, + UpdateProductOnSale, UpdateProductPrice, UpdateProductSingleUse, UpdateProductStock, + UpdateProductTitle, }; pub async fn create_request( @@ -112,6 +118,33 @@ pub async fn update_description_request( } } +pub async fn update_data_request( + jar: CookieJar, + Extension(data): Extension, + Path(id): Path, + Json(mut 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()), + }; + + req.data = req.data.trim().to_string(); + if req.data.len() > 16384 { + return Json(Error::DataTooLong("data".to_string()).into()); + } + + match data.update_product_data(id, &user, &req.data).await { + Ok(_) => Json(ApiReturn { + ok: true, + message: "Product updated".to_string(), + payload: (), + }), + Err(e) => Json(e.into()), + } +} + pub async fn update_on_sale_request( jar: CookieJar, Extension(data): Extension, @@ -205,6 +238,12 @@ pub async fn update_method_request( None => return Json(Error::NotAllowed.into()), }; + if req.method == ProductFulfillmentMethod::ProfileStyle + && !user.permissions.check(FinePermission::SUPPORTER) + { + return Json(Error::RequiresSupporter.into()); + } + match data.update_product_method(id, &user, req.method).await { Ok(_) => Json(ApiReturn { ok: true, diff --git a/crates/app/src/routes/pages/economy.rs b/crates/app/src/routes/pages/economy.rs index 402f03f..63aa300 100644 --- a/crates/app/src/routes/pages/economy.rs +++ b/crates/app/src/routes/pages/economy.rs @@ -147,12 +147,19 @@ pub async fn product_request( false }; + let applied_configurations_mapped: Vec = + user.applied_configurations.iter().map(|x| x.1).collect(); + let lang = get_lang!(jar, data.0); let mut context = initial_context(&data.0.0.0, lang, &Some(user)).await; context.insert("product", &product); context.insert("owner", &owner); context.insert("already_purchased", &already_purchased); + context.insert( + "applied_configurations_mapped", + &applied_configurations_mapped, + ); // return Ok(Html( diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs index 3f58805..dc6eca1 100644 --- a/crates/app/src/routes/pages/profile.rs +++ b/crates/app/src/routes/pages/profile.rs @@ -232,6 +232,7 @@ pub fn profile_context( user: &Option, profile: &User, communities: &Vec, + applied_configurations: Vec, is_self: bool, is_following: bool, is_following_you: bool, @@ -244,6 +245,7 @@ pub fn profile_context( context.insert("is_following_you", &is_following_you); context.insert("is_blocking", &is_blocking); context.insert("warning_hash", &hash(profile.settings.warning.clone())); + context.insert("applied_configurations", &applied_configurations); context.insert( "is_supporter", @@ -376,6 +378,10 @@ pub async fn posts_request( &user, &other_user, &communities, + match data.0.get_applied_configurations(&other_user).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }, is_self, is_following, is_following_you, @@ -492,6 +498,10 @@ pub async fn replies_request( &user, &other_user, &communities, + match data.0.get_applied_configurations(&other_user).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }, is_self, is_following, is_following_you, @@ -604,6 +614,10 @@ pub async fn media_request( &user, &other_user, &communities, + match data.0.get_applied_configurations(&other_user).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }, is_self, is_following, is_following_you, @@ -690,9 +704,13 @@ pub async fn shop_request( context.insert("page", &props.page); profile_context( &mut context, - &Some(user), + &Some(user.clone()), &other_user, &communities, + match data.0.get_applied_configurations(&other_user).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }, is_self, is_following, is_following_you, @@ -784,9 +802,13 @@ pub async fn outbox_request( context.insert("page", &props.page); profile_context( &mut context, - &Some(user), + &Some(user.clone()), &other_user, &communities, + match data.0.get_applied_configurations(&other_user).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), + }, is_self, is_following, is_following_you, @@ -896,6 +918,10 @@ pub async fn following_request( &user, &other_user, &communities, + match data.0.get_applied_configurations(&other_user).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }, is_self, is_following, is_following_you, @@ -1005,6 +1031,10 @@ pub async fn followers_request( &user, &other_user, &communities, + match data.0.get_applied_configurations(&other_user).await { + Ok(x) => x, + Err(e) => return Err(Html(render_error(e, &jar, &data, &user).await)), + }, is_self, is_following, is_following_you, diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index bdc887d..0e9da53 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -1,15 +1,15 @@ use super::common::NAME_REGEX; use oiseau::cache::Cache; -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; use crate::model::{ Error, Result, auth::{Token, User, UserSettings}, - permissions::FinePermission, + permissions::{FinePermission, SecondaryPermission}, + oauth::AuthGrant, + moderation::AuditLogEntry, + auth::{ + Achievement, AchievementName, AchievementRarity, Notification, UserConnections, + ACHIEVEMENTS, AppliedConfigType, + }, }; use pathbufd::PathBufD; use std::fs::{exists, remove_file}; @@ -130,6 +130,7 @@ impl DataManager { ban_expire: get!(x->30(i64)) as usize, coins: get!(x->31(i32)), checkouts: serde_json::from_str(&get!(x->32(String)).to_string()).unwrap(), + applied_configurations: serde_json::from_str(&get!(x->33(String)).to_string()).unwrap(), } } @@ -286,7 +287,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, $31, $32, $33)", + "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, $32, $33, $34)", params![ &(data.id as i64), &(data.created as i64), @@ -321,6 +322,7 @@ impl DataManager { &(data.ban_expire as i64), &(data.coins as i32), &serde_json::to_string(&data.checkouts).unwrap(), + &serde_json::to_string(&data.applied_configurations).unwrap(), ] ); @@ -1091,6 +1093,29 @@ impl DataManager { Ok((totp.get_secret_base32(), qr, recovery)) } + /// Get all applied configurations as a vector of strings from the given user. + pub async fn get_applied_configurations(&self, user: &User) -> Result> { + let mut out = Vec::new(); + + for config in &user.applied_configurations { + let product = match self.get_product_by_id(config.1).await { + Ok(x) => x, + Err(_) => continue, + }; + + out.push(match config.0 { + AppliedConfigType::StyleSnippet => { + format!( + "", + product.data.replace("<", "<").replace(">", ">") + ) + } + }) + } + + Ok(out) + } + pub async fn cache_clear_user(&self, user: &User) { self.0.1.remove(format!("atto.user:{}", user.id)).await; self.0 @@ -1119,6 +1144,7 @@ impl DataManager { auto_method!(update_user_ban_expire(i64)@get_user_by_id -> "UPDATE users SET ban_expire = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); auto_method!(update_user_coins(i32)@get_user_by_id -> "UPDATE users SET coins = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); auto_method!(update_user_checkouts(Vec)@get_user_by_id -> "UPDATE users SET checkouts = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); + auto_method!(update_user_applied_configurations(Vec<(AppliedConfigType, usize)>)@get_user_by_id -> "UPDATE users SET applied_configurations = $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/drivers/sql/create_products.sql b/crates/core/src/database/drivers/sql/create_products.sql index b1ce39b..5d3afe8 100644 --- a/crates/core/src/database/drivers/sql/create_products.sql +++ b/crates/core/src/database/drivers/sql/create_products.sql @@ -8,5 +8,6 @@ CREATE TABLE IF NOT EXISTS products ( on_sale INT NOT NULL, price INT NOT NULL, stock INT NOT NULL, - single_use INT NOT NULL + single_use INT NOT NULL, + data TEXT 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 d86bcbe..8ec2c22 100644 --- a/crates/core/src/database/drivers/sql/create_users.sql +++ b/crates/core/src/database/drivers/sql/create_users.sql @@ -31,5 +31,6 @@ CREATE TABLE IF NOT EXISTS users ( is_deactivated INT NOT NULL, ban_expire BIGINT NOT NULL, coins INT NOT NULL, - checkouts TEXT NOT NULL + checkouts TEXT NOT NULL, + applied_configurations 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 index 33f4725..f1e95a3 100644 --- a/crates/core/src/database/drivers/sql/version_migrations.sql +++ b/crates/core/src/database/drivers/sql/version_migrations.sql @@ -53,3 +53,11 @@ ADD COLUMN IF NOT EXISTS single_use INT DEFAULT 1; -- transfers source ALTER TABLE transfers ADD COLUMN IF NOT EXISTS source TEXT DEFAULT '"General"'; + +-- products single_use +ALTER TABLE products +ADD COLUMN IF NOT EXISTS data TEXT DEFAULT ''; + +-- users applied_configurations +ALTER TABLE users +ADD COLUMN IF NOT EXISTS applied_configurations TEXT DEFAULT '[]'; diff --git a/crates/core/src/database/products.rs b/crates/core/src/database/products.rs index 4c2f6f8..de18074 100644 --- a/crates/core/src/database/products.rs +++ b/crates/core/src/database/products.rs @@ -24,6 +24,7 @@ impl DataManager { price: get!(x->7(i32)), stock: get!(x->8(i32)), single_use: get!(x->9(i32)) as i8 == 1, + data: get!(x->10(String)), } } @@ -106,7 +107,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, $10, $11)", params![ &(data.id as i64), &(data.created as i64), @@ -118,6 +119,7 @@ impl DataManager { &data.price, &(data.stock as i32), &{ if data.single_use { 1 } else { 0 } }, + &data.data, ] ); @@ -219,6 +221,21 @@ If your product is a purchase of goods or services, please be sure to fulfill th // return Ok(transfer) } + ProductFulfillmentMethod::ProfileStyle => { + // pretty much an automail without the message + self.create_transfer(&mut transfer, true).await?; + + self.create_letter(Letter::new( + self.0.0.system_user, + vec![customer.id], + format!("Thank you for purchasing \"{}\"", product.title), + "You've purchased a CSS snippet which can be applied to your profile through the product's page!".to_string(), + 0, + )) + .await?; + + Ok(transfer) + } } } @@ -253,6 +270,7 @@ If your product is a purchase of goods or services, please be sure to fulfill th auto_method!(update_product_on_sale(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET on_sale = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); auto_method!(update_product_method(ProductFulfillmentMethod)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET method = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.product:{}"); auto_method!(update_product_single_use(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET single_use = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); + auto_method!(update_product_data(&str)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET data = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); auto_method!(update_product_stock(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET stock = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); auto_method!(incr_product_stock() -> "UPDATE products SET stock = stock + 1 WHERE id = $1" --cache-key-tmpl="atto.product:{}" --incr); diff --git a/crates/core/src/database/requests.rs b/crates/core/src/database/requests.rs index 84356ac..aad24ab 100644 --- a/crates/core/src/database/requests.rs +++ b/crates/core/src/database/requests.rs @@ -29,7 +29,14 @@ impl DataManager { .get(format!("atto.request:{}:{}", id, linked_asset)) .await { - return Ok(serde_json::from_str(&cached).unwrap()); + if let Ok(x) = serde_json::from_str(&cached) { + return Ok(x); + } else { + self.0 + .1 + .remove(format!("atto.request:{}:{}", id, linked_asset)) + .await; + } } let conn = match self.0.connect().await { diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index f63db1f..902e97f 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -101,11 +101,20 @@ pub struct User { /// already applied this purchase. #[serde(default)] pub checkouts: Vec, + /// The IDs of products to be applied to the user's profile. + #[serde(default)] + pub applied_configurations: Vec<(AppliedConfigType, usize)>, } pub type UserConnections = HashMap; +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum AppliedConfigType { + /// An HTML `