From e5e6d5cddbe14bd4d91395ee2b7b0b30db8d3642 Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 8 Aug 2025 14:17:40 -0400 Subject: [PATCH 01/20] add: single_use products --- crates/app/src/langs/en-US.toml | 2 ++ crates/app/src/public/html/economy/edit.lisp | 33 +++++++++++++++++++ .../app/src/public/html/economy/product.lisp | 7 ++++ crates/app/src/public/html/profile/base.lisp | 2 +- crates/app/src/routes/api/v1/mod.rs | 9 +++++ crates/app/src/routes/api/v1/products.rs | 27 ++++++++++++++- crates/app/src/routes/pages/economy.rs | 12 ++++++- .../database/drivers/sql/create_products.sql | 3 +- .../drivers/sql/version_migrations.sql | 4 +++ crates/core/src/database/products.rs | 23 +++++++++++-- crates/core/src/database/transfers.rs | 31 ++++++++++++++++- crates/core/src/model/economy.rs | 3 ++ 12 files changed, 149 insertions(+), 7 deletions(-) diff --git a/crates/app/src/langs/en-US.toml b/crates/app/src/langs/en-US.toml index 73507f4..8a96808 100644 --- a/crates/app/src/langs/en-US.toml +++ b/crates/app/src/langs/en-US.toml @@ -337,9 +337,11 @@ version = "1.0.0" "economy:label.create_new" = "Create new product" "economy:label.price" = "Price" "economy:label.on_sale" = "On sale" +"economy:label.single_use" = "Only allow users to purchase once" "economy:label.stock" = "Stock" "economy:label.unlimited" = "Unlimited" "economy:label.fulfillment_style" = "Fulfillment style" "economy:label.use_automail" = "Use automail" "economy:label.automail_message" = "Automail message" "economy:action.buy" = "Buy" +"economy:label.already_purchased" = "Already purchased" diff --git a/crates/app/src/public/html/economy/edit.lisp b/crates/app/src/public/html/economy/edit.lisp index 30accd2..ec94f12 100644 --- a/crates/app/src/public/html/economy/edit.lisp +++ b/crates/app/src/public/html/economy/edit.lisp @@ -77,6 +77,18 @@ ("oninput" "event.preventDefault(); update_on_sale_from_form(event.target.checked)")) (span (str (text "economy:label.on_sale")))) + (label + ("for" "single_use") + ("class" "flex items_center gap_2") + (input + ("type" "checkbox") + ("id" "single_use") + ("name" "single_use") + ("class" "w_content") + ("checked" "{{ product.single_use }}") + ("oninput" "event.preventDefault(); update_single_use_from_form(event.target.checked)")) + (span + (str (text "economy:label.single_use")))) (div ("class" "flex flex_col gap_1") (label @@ -240,6 +252,27 @@ }); } + async function update_single_use_from_form(single_use) { + await trigger(\"atto::debounce\", [\"products::update\"]); + + fetch(\"/api/v1/products/{{ product.id }}/single_use\", { + method: \"POST\", + headers: { + \"Content-Type\": \"application/json\", + }, + body: JSON.stringify({ + single_use, + }), + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + } + async function update_price_from_form(e) { e.preventDefault(); await trigger(\"atto::debounce\", [\"products::update\"]); diff --git a/crates/app/src/public/html/economy/product.lisp b/crates/app/src/public/html/economy/product.lisp index bcceb2b..7a1ce86 100644 --- a/crates/app/src/public/html/economy/product.lisp +++ b/crates/app/src/public/html/economy/product.lisp @@ -28,12 +28,19 @@ (icon (text "badge-cent")) (text "{{ product.price }}")) (text "{% if user.id != product.owner -%}") + (text "{% if not already_purchased -%}") (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"))) + (text "{%- endif %}") + (text "{% else %}") (a ("class" "button") ("href" "/product/{{ product.id }}/edit") diff --git a/crates/app/src/public/html/profile/base.lisp b/crates/app/src/public/html/profile/base.lisp index c1788b3..e4e230b 100644 --- a/crates/app/src/public/html/profile/base.lisp +++ b/crates/app/src/public/html/profile/base.lisp @@ -304,7 +304,7 @@ globalThis.request_transfer = async () => { await trigger(\"atto::debounce\", [\"economy::transfer\"]); - const amount = Number.parseInt((await trigger(\"atto::prompt\", [\"Request amount:\"])) || \"\"); + const amount = Number.parseInt((await trigger(\"atto::prompt\", [\"Request amount:\"])) || \"0\"); if (amount === 0) { return; diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs index 3a3b081..d24b953 100644 --- a/crates/app/src/routes/api/v1/mod.rs +++ b/crates/app/src/routes/api/v1/mod.rs @@ -732,6 +732,10 @@ pub fn routes() -> Router { "/products/{id}/on_sale", post(products::update_on_sale_request), ) + .route( + "/products/{id}/single_use", + post(products::update_single_use_request), + ) .route("/products/{id}/price", post(products::update_price_request)) .route( "/products/{id}/method", @@ -1275,6 +1279,11 @@ pub struct UpdateProductOnSale { pub on_sale: bool, } +#[derive(Deserialize)] +pub struct UpdateProductSingleUse { + pub single_use: bool, +} + #[derive(Deserialize)] pub struct UpdateProductPrice { pub price: i32, diff --git a/crates/app/src/routes/api/v1/products.rs b/crates/app/src/routes/api/v1/products.rs index e532d01..71bc962 100644 --- a/crates/app/src/routes/api/v1/products.rs +++ b/crates/app/src/routes/api/v1/products.rs @@ -3,7 +3,7 @@ use axum::{extract::Path, response::IntoResponse, Extension, Json}; use tetratto_core::model::{economy::Product, oauth, ApiReturn, Error}; use super::{ CreateProduct, UpdateProductDescription, UpdateProductMethod, UpdateProductOnSale, - UpdateProductPrice, UpdateProductStock, UpdateProductTitle, + UpdateProductPrice, UpdateProductSingleUse, UpdateProductStock, UpdateProductTitle, }; pub async fn create_request( @@ -137,6 +137,31 @@ pub async fn update_on_sale_request( } } +pub async fn update_single_use_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_single_use(id, &user, if req.single_use { 1 } else { 0 }) + .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, diff --git a/crates/app/src/routes/pages/economy.rs b/crates/app/src/routes/pages/economy.rs index 36c9cbe..402f03f 100644 --- a/crates/app/src/routes/pages/economy.rs +++ b/crates/app/src/routes/pages/economy.rs @@ -4,7 +4,7 @@ use axum::{ Extension, }; use crate::cookie::CookieJar; -use tetratto_core::model::Error; +use tetratto_core::model::{economy::CoinTransferMethod, Error}; use crate::{assets::initial_context, get_lang, get_user_from_token, State}; use super::{render_error, PaginatedQuery}; @@ -138,11 +138,21 @@ pub async fn product_request( Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)), }; + let already_purchased = if product.single_use { + data.0 + .get_transfer_by_sender_method(user.id, CoinTransferMethod::Purchase(product.id)) + .await + .is_ok() + } else { + false + }; + 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); // return Ok(Html( diff --git a/crates/core/src/database/drivers/sql/create_products.sql b/crates/core/src/database/drivers/sql/create_products.sql index be32f3c..b1ce39b 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 ( method TEXT NOT NULL, on_sale INT NOT NULL, price INT NOT NULL, - stock INT NOT NULL + stock INT NOT NULL, + single_use 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 a935589..e4f30d5 100644 --- a/crates/core/src/database/drivers/sql/version_migrations.sql +++ b/crates/core/src/database/drivers/sql/version_migrations.sql @@ -45,3 +45,7 @@ ADD COLUMN IF NOT EXISTS data TEXT DEFAULT '"Null"'; -- users checkouts ALTER TABLE users ADD COLUMN IF NOT EXISTS checkouts TEXT DEFAULT '[]'; + +-- products single_use +ALTER TABLE products +ADD COLUMN IF NOT EXISTS single_use INT DEFAULT 1; diff --git a/crates/core/src/database/products.rs b/crates/core/src/database/products.rs index 714531d..f888e07 100644 --- a/crates/core/src/database/products.rs +++ b/crates/core/src/database/products.rs @@ -21,6 +21,7 @@ impl DataManager { on_sale: get!(x->6(i32)) as i8 == 1, price: get!(x->7(i32)), stock: get!(x->8(i32)), + single_use: get!(x->9(i32)) as i8 == 1, } } @@ -103,7 +104,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), @@ -113,7 +114,8 @@ impl DataManager { &serde_json::to_string(&data.method).unwrap(), &{ if data.on_sale { 1 } else { 0 } }, &data.price, - &(data.stock as i32) + &(data.stock as i32), + &{ if data.single_use { 1 } else { 0 } }, ] ); @@ -131,6 +133,22 @@ impl DataManager { customer: &mut User, ) -> Result { let product = self.get_product_by_id(product).await?; + + // handle single_use product + if product.single_use { + if self + .get_transfer_by_sender_method( + customer.id, + CoinTransferMethod::Purchase(product.id), + ) + .await + .is_ok() + { + return Err(Error::MiscError("You already own this product".to_string())); + } + } + + // ... let mut transfer = CoinTransfer::new( customer.id, product.owner, @@ -231,6 +249,7 @@ If your product is a purchase of goods or services, please be sure to fulfill th auto_method!(update_product_price(i32)@get_product_by_id:FinePermission::MANAGE_USERS; -> "UPDATE products SET price = $1 WHERE id = $2" --cache-key-tmpl="atto.product:{}"); 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_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/transfers.rs b/crates/core/src/database/transfers.rs index 40507b8..3668695 100644 --- a/crates/core/src/database/transfers.rs +++ b/crates/core/src/database/transfers.rs @@ -5,7 +5,7 @@ use crate::model::{ auth::{Notification, User}, }; use crate::{auto_method, DataManager}; -use oiseau::{cache::Cache, execute, get, params, query_rows, PostgresRow}; +use oiseau::{cache::Cache, execute, get, params, query_row, query_rows, PostgresRow}; impl DataManager { /// Get a [`CoinTransfer`] from an SQL row. @@ -101,6 +101,35 @@ impl DataManager { Ok(res.unwrap()) } + /// Get a transfer by user and method. + /// + /// # Arguments + /// * `id` - the ID of the user to fetch transfers for + /// * `method` - the transfer method + pub async fn get_transfer_by_sender_method( + &self, + id: usize, + method: CoinTransferMethod, + ) -> 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 transfers WHERE sender = $1 AND method = $2 LIMIT 1", + params![&(id as i64), &serde_json::to_string(&method).unwrap()], + |x| { Ok(Self::get_transfer_from_row(x)) } + ); + + if res.is_err() { + return Err(Error::GeneralNotFound("transfer".to_string())); + } + + Ok(res.unwrap()) + } + /// Create a new transfer in the database. /// /// # Arguments diff --git a/crates/core/src/model/economy.rs b/crates/core/src/model/economy.rs index c7ed372..15cf435 100644 --- a/crates/core/src/model/economy.rs +++ b/crates/core/src/model/economy.rs @@ -29,6 +29,8 @@ pub struct Product { /// /// A negative stock means the product has unlimited stock. pub stock: i32, + /// If this product is limited to one purchase per person. + pub single_use: bool, } impl Product { @@ -44,6 +46,7 @@ impl Product { on_sale: false, price: 0, stock: 0, + single_use: true, } } } From a08552338bf00e2b488e90f1dadadbe98a6105aa Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 8 Aug 2025 14:52:53 -0400 Subject: [PATCH 02/20] fix: wallet panic --- crates/app/src/public/html/economy/edit.lisp | 39 +++++++++++++++++--- crates/app/src/routes/api/v1/products.rs | 2 +- crates/core/src/database/products.rs | 2 +- crates/core/src/database/transfers.rs | 16 +++++--- 4 files changed, 46 insertions(+), 13 deletions(-) diff --git a/crates/app/src/public/html/economy/edit.lisp b/crates/app/src/public/html/economy/edit.lisp index ec94f12..356eeb3 100644 --- a/crates/app/src/public/html/economy/edit.lisp +++ b/crates/app/src/public/html/economy/edit.lisp @@ -180,11 +180,19 @@ (text "{% if product.method != \"ManualMail\" -%} {{ product.method.AutoMail }} {%- endif %}"))) (button (str (text "general:action.save"))))) - (a - ("class" "button secondary") - ("href" "/product/{{ product.id }}") - (icon (text "arrow-left")) - (str (text "general:action.back")))) + (div + ("class" "flex gap_2") + (a + ("class" "button secondary") + ("href" "/product/{{ product.id }}") + (icon (text "arrow-left")) + (str (text "general:action.back"))) + + (button + ("class" "lowered red") + ("onclick" "delete_product()") + (icon (text "trash")) + (str (text "general:action.delete"))))) (script (text "async function update_title_from_form(e) { @@ -339,6 +347,27 @@ }); } + async function delete_product() { + if ( + !(await trigger(\"atto::confirm\", [ + \"Are you sure you would like to do this?\", + ])) + ) { + return; + } + + fetch(\"/api/v1/products/{{ product.id }}\", { + method: \"DELETE\", + }) + .then((res) => res.json()) + .then((res) => { + trigger(\"atto::toast\", [ + res.ok ? \"success\" : \"error\", + res.message, + ]); + }); + }; + globalThis.mirror_use_automail = () => { const use_automail = document.getElementById(\"use_automail\").checked; diff --git a/crates/app/src/routes/api/v1/products.rs b/crates/app/src/routes/api/v1/products.rs index 71bc962..424ae6d 100644 --- a/crates/app/src/routes/api/v1/products.rs +++ b/crates/app/src/routes/api/v1/products.rs @@ -174,7 +174,7 @@ pub async fn update_price_request( None => return Json(Error::NotAllowed.into()), }; - if req.price < 25 { + if req.price < 25 && req.price != 0 { return Json( Error::MiscError( "Price is too low, please use a price of 25 coins or more".to_string(), diff --git a/crates/core/src/database/products.rs b/crates/core/src/database/products.rs index f888e07..9137bc3 100644 --- a/crates/core/src/database/products.rs +++ b/crates/core/src/database/products.rs @@ -58,7 +58,7 @@ impl DataManager { Ok(res.unwrap()) } - const MAXIMUM_FREE_PRODUCTS: usize = 5; + const MAXIMUM_FREE_PRODUCTS: usize = 10; /// Create a new product in the database. /// diff --git a/crates/core/src/database/transfers.rs b/crates/core/src/database/transfers.rs index 3668695..0226c6b 100644 --- a/crates/core/src/database/transfers.rs +++ b/crates/core/src/database/transfers.rs @@ -54,13 +54,17 @@ impl DataManager { match transfer.method { CoinTransferMethod::Transfer => None, CoinTransferMethod::Purchase(id) => { - Some(if let Some(product) = seen_products.get(&id) { - product.to_owned() + if let Some(product) = seen_products.get(&id) { + Some(product.to_owned()) } else { - let product = self.get_product_by_id(id).await?; - seen_products.insert(product.id, product.clone()); - product - }) + match self.get_product_by_id(id).await { + Ok(product) => { + seen_products.insert(product.id, product.clone()); + Some(product) + } + Err(_) => None, + } + } } }, transfer.is_pending, From 98426d0989bb5f6b62f9127d41dbb4fca42a050b Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 8 Aug 2025 16:01:23 -0400 Subject: [PATCH 03/20] add: store coin transfer source --- crates/app/src/public/css/style.css | 4 ++ crates/app/src/public/html/components.lisp | 8 ++- crates/app/src/public/html/economy/edit.lisp | 4 +- .../routes/api/v1/auth/connections/stripe.rs | 4 +- crates/app/src/routes/api/v1/transfers.rs | 3 +- crates/core/src/database/auth.rs | 55 +++++++++++++++++++ .../database/drivers/sql/create_transfers.sql | 3 +- .../drivers/sql/version_migrations.sql | 4 ++ crates/core/src/database/products.rs | 5 +- crates/core/src/database/transfers.rs | 4 +- crates/core/src/model/economy.rs | 20 ++++++- 11 files changed, 105 insertions(+), 9 deletions(-) diff --git a/crates/app/src/public/css/style.css b/crates/app/src/public/css/style.css index a3145db..4266a74 100644 --- a/crates/app/src/public/css/style.css +++ b/crates/app/src/public/css/style.css @@ -556,6 +556,10 @@ input[type="checkbox"]:checked { background-image: url("/icons/check.svg"); } +label { + cursor: pointer; +} + /* pillmenu */ .pillmenu { display: flex; diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index f88a872..f61433e 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -89,7 +89,7 @@ (text "{%- endif %}")) (text "{%- endif %} {%- endmacro %} {% macro full_username(user) -%} {% if user and user.username -%}") (div - ("class" "flex items_center") + ("class" "flex flex_wrap items_center") (a ("href" "/@{{ user.username }}") ("class" "flush flex gap_1") @@ -119,6 +119,12 @@ ("style" "color: var(--color-primary);") ("class" "flex items_center") (text "{{ icon \"star\" }}")) + (text "{%- endif %} {% if user.checkouts|length > 0 -%}") + (span + ("title" "Donator") + ("style" "color: var(--color-primary);") + ("class" "flex items_center") + (text "{{ icon \"hand-heart\" }}")) (text "{%- endif %} {% if user.permissions|has_staff_badge -%}") (span ("title" "Staff") diff --git a/crates/app/src/public/html/economy/edit.lisp b/crates/app/src/public/html/economy/edit.lisp index 356eeb3..328133f 100644 --- a/crates/app/src/public/html/economy/edit.lisp +++ b/crates/app/src/public/html/economy/edit.lisp @@ -92,7 +92,7 @@ (div ("class" "flex flex_col gap_1") (label - ("for" "title") + ("for" "price") (str (text "economy:label.price"))) (input ("type" "number") @@ -130,7 +130,7 @@ (div ("class" "flex flex_col gap_1") (label - ("for" "title") + ("for" "stock") (str (text "economy:label.stock"))) (input ("type" "number") 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 58aa1e8..14ca49a 100644 --- a/crates/app/src/routes/api/v1/auth/connections/stripe.rs +++ b/crates/app/src/routes/api/v1/auth/connections/stripe.rs @@ -7,7 +7,7 @@ use axum::{ }; use tetratto_core::model::{ auth::{Notification, User}, - economy::{CoinTransfer, CoinTransferMethod}, + economy::{CoinTransfer, CoinTransferMethod, CoinTransferSource}, moderation::AuditLogEntry, permissions::{FinePermission, SecondaryPermission}, ApiReturn, Error, @@ -635,6 +635,7 @@ pub async fn handle_stupid_fucking_checkout_success_session( user.id, 100, CoinTransferMethod::Transfer, + CoinTransferSource::Purchase, ), true, ) @@ -651,6 +652,7 @@ pub async fn handle_stupid_fucking_checkout_success_session( user.id, 400, CoinTransferMethod::Transfer, + CoinTransferSource::Purchase, ), true, ) diff --git a/crates/app/src/routes/api/v1/transfers.rs b/crates/app/src/routes/api/v1/transfers.rs index be26656..0995006 100644 --- a/crates/app/src/routes/api/v1/transfers.rs +++ b/crates/app/src/routes/api/v1/transfers.rs @@ -1,7 +1,7 @@ use crate::{get_user_from_token, State, cookie::CookieJar}; use axum::{response::IntoResponse, Extension, Json}; use tetratto_core::model::{ - economy::{CoinTransfer, CoinTransferMethod}, + economy::{CoinTransfer, CoinTransferMethod, CoinTransferSource}, oauth, requests::{ActionData, ActionRequest, ActionType}, ApiReturn, Error, @@ -29,6 +29,7 @@ pub async fn create_request( }, req.amount, CoinTransferMethod::Transfer, // this endpoint is ONLY for regular transfers; products export a buy endpoint for the other method + CoinTransferSource::General, ), true, ) diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs index 1a37e3a..bdc887d 100644 --- a/crates/core/src/database/auth.rs +++ b/crates/core/src/database/auth.rs @@ -538,6 +538,61 @@ impl DataManager { return Err(Error::DatabaseError(e.to_string())); } + // delete transfers + let res = execute!( + &conn, + "DELETE FROM transfers WHERE sender = $1 OR receiver = $2", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // delete products + let res = execute!( + &conn, + "DELETE FROM products WHERE owner = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // delete domains + let res = execute!( + &conn, + "DELETE FROM domains WHERE owner = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // delete services + let res = execute!( + &conn, + "DELETE FROM services WHERE owner = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + // delete letters + let res = execute!( + &conn, + "DELETE FROM letters WHERE owner = $1", + &[&(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + // delete user follows... individually since it requires updating user counts for follow in self.get_userfollows_by_receiver_all(id).await? { self.delete_userfollow(follow.id, &user, true).await?; diff --git a/crates/core/src/database/drivers/sql/create_transfers.sql b/crates/core/src/database/drivers/sql/create_transfers.sql index d747c78..ea157e4 100644 --- a/crates/core/src/database/drivers/sql/create_transfers.sql +++ b/crates/core/src/database/drivers/sql/create_transfers.sql @@ -5,5 +5,6 @@ CREATE TABLE IF NOT EXISTS transfers ( receiver BIGINT NOT NULL, amount INT NOT NULL, is_pending INT NOT NULL, - method TEXT NOT NULL + method TEXT NOT NULL, + source 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 e4f30d5..33f4725 100644 --- a/crates/core/src/database/drivers/sql/version_migrations.sql +++ b/crates/core/src/database/drivers/sql/version_migrations.sql @@ -49,3 +49,7 @@ ADD COLUMN IF NOT EXISTS checkouts TEXT DEFAULT '[]'; -- products single_use ALTER TABLE products ADD COLUMN IF NOT EXISTS single_use INT DEFAULT 1; + +-- transfers source +ALTER TABLE transfers +ADD COLUMN IF NOT EXISTS source TEXT DEFAULT '"General"'; diff --git a/crates/core/src/database/products.rs b/crates/core/src/database/products.rs index 9137bc3..4c2f6f8 100644 --- a/crates/core/src/database/products.rs +++ b/crates/core/src/database/products.rs @@ -1,6 +1,8 @@ use crate::model::{ auth::User, - economy::{CoinTransfer, CoinTransferMethod, Product, ProductFulfillmentMethod}, + economy::{ + CoinTransfer, CoinTransferMethod, CoinTransferSource, Product, ProductFulfillmentMethod, + }, mail::Letter, permissions::FinePermission, Error, Result, @@ -154,6 +156,7 @@ impl DataManager { product.owner, product.price, CoinTransferMethod::Purchase(product.id), + CoinTransferSource::Sale, ); if !product.stock.is_negative() { diff --git a/crates/core/src/database/transfers.rs b/crates/core/src/database/transfers.rs index 0226c6b..ecbde84 100644 --- a/crates/core/src/database/transfers.rs +++ b/crates/core/src/database/transfers.rs @@ -18,6 +18,7 @@ impl DataManager { amount: get!(x->4(i32)), is_pending: get!(x->5(i32)) as i8 == 1, method: serde_json::from_str(&get!(x->6(String))).unwrap(), + source: serde_json::from_str(&get!(x->7(String))).unwrap(), } } @@ -175,7 +176,7 @@ impl DataManager { let res = execute!( &conn, - "INSERT INTO transfers VALUES ($1, $2, $3, $4, $5, $6, $7)", + "INSERT INTO transfers VALUES ($1, $2, $3, $4, $5, $6, $7, $8)", params![ &(data.id as i64), &(data.created as i64), @@ -184,6 +185,7 @@ impl DataManager { &data.amount, &{ if data.is_pending { 1 } else { 0 } }, &serde_json::to_string(&data.method).unwrap(), + &serde_json::to_string(&data.source).unwrap(), ] ); diff --git a/crates/core/src/model/economy.rs b/crates/core/src/model/economy.rs index 15cf435..389cdb7 100644 --- a/crates/core/src/model/economy.rs +++ b/crates/core/src/model/economy.rs @@ -58,6 +58,16 @@ pub enum CoinTransferMethod { Purchase(usize), } +#[derive(Serialize, Deserialize)] +pub enum CoinTransferSource { + /// An unknown source, such as a transfer request. + General, + /// A product sale. + Sale, + /// A purchase of coins through Stripe. + Purchase, +} + #[derive(Serialize, Deserialize)] pub struct CoinTransfer { pub id: usize, @@ -67,11 +77,18 @@ pub struct CoinTransfer { pub amount: i32, pub is_pending: bool, pub method: CoinTransferMethod, + pub source: CoinTransferSource, } impl CoinTransfer { /// Create a new [`CoinTransfer`]. - pub fn new(sender: usize, receiver: usize, amount: i32, method: CoinTransferMethod) -> Self { + pub fn new( + sender: usize, + receiver: usize, + amount: i32, + method: CoinTransferMethod, + source: CoinTransferSource, + ) -> Self { Self { id: Snowflake::new().to_string().parse::().unwrap(), created: unix_epoch_timestamp(), @@ -80,6 +97,7 @@ impl CoinTransfer { amount, is_pending: false, method, + source, } } From 077e9252e3ad3e07700f1685130df28483948f39 Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 8 Aug 2025 21:05:56 -0400 Subject: [PATCH 04/20] add: hide_username_badges --- crates/app/src/public/html/components.lisp | 4 ++-- crates/app/src/public/html/economy/wallet.lisp | 8 +++++++- crates/app/src/public/html/profile/settings.lisp | 8 ++++++++ crates/core/src/model/auth.rs | 3 +++ 4 files changed, 20 insertions(+), 3 deletions(-) diff --git a/crates/app/src/public/html/components.lisp b/crates/app/src/public/html/components.lisp index f61433e..7115bee 100644 --- a/crates/app/src/public/html/components.lisp +++ b/crates/app/src/public/html/components.lisp @@ -107,7 +107,7 @@ (text "{% else %}") (text "{{ self::username(user=user) }}") (text "{%- endif %}")) - (text "{{ self::online_indicator(user=user) }} {% if user.is_verified -%}") + (text "{{ self::online_indicator(user=user) }} {% if not user.settings.hide_username_badges -%} {% if user.is_verified -%}") (span ("title" "Verified") ("style" "color: var(--color-primary)") @@ -131,7 +131,7 @@ ("style" "color: var(--color-primary);") ("class" "flex items_center") (text "{{ icon \"shield-user\" }}")) - (text "{%- endif %}")) + (text "{%- endif %} {%- endif %}")) (text "{%- endif %} {%- endmacro %} {% macro repost(repost, post, owner, secondary=false, community=false, show_community=true, can_manage_post=false) -%}") (div ("style" "display: contents") diff --git a/crates/app/src/public/html/economy/wallet.lisp b/crates/app/src/public/html/economy/wallet.lisp index 0fb5b92..5458233 100644 --- a/crates/app/src/public/html/economy/wallet.lisp +++ b/crates/app/src/public/html/economy/wallet.lisp @@ -11,7 +11,13 @@ (span ("class" "flex items_center gap_2") (icon (text "piggy-bank")) - (span (str (text "general:link.wallet"))))) + (span (str (text "general:link.wallet")))) + + (button + ("class" "lowered small square tiny big_icon") + ("onclick" "document.getElementById('buy_dialog').showModal()") + ("title" "Buy coins") + (icon (text "plus")))) (div ("class" "card lowered flex flex_col gap_4") (button diff --git a/crates/app/src/public/html/profile/settings.lisp b/crates/app/src/public/html/profile/settings.lisp index 0356283..4150508 100644 --- a/crates/app/src/public/html/profile/settings.lisp +++ b/crates/app/src/public/html/profile/settings.lisp @@ -1906,6 +1906,14 @@ \"{{ profile.settings.hide_social_follows }}\", \"checkbox\", ], + [ + [ + \"hide_username_badges\", + \"Hide badges from your username (outside of your profile)\", + ], + \"{{ profile.settings.hide_username_badges }}\", + \"checkbox\", + ], [[], \"Questions\", \"title\"], [ [ diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs index afd6aa1..f63db1f 100644 --- a/crates/core/src/model/auth.rs +++ b/crates/core/src/model/auth.rs @@ -362,6 +362,9 @@ pub struct UserSettings { /// If your profile has the "Shop" tab enabled. #[serde(default)] pub enable_shop: bool, + /// Hide all badges from your username (everywhere but on profile). + #[serde(default)] + pub hide_username_badges: bool, } fn mime_avif() -> String { From 95cb889080958123772a9c648cd116587d92900e Mon Sep 17 00:00:00 2001 From: trisua Date: Fri, 8 Aug 2025 23:44:45 -0400 Subject: [PATCH 05/20] add: ProfileStyle products --- crates/app/src/langs/en-US.toml | 4 + crates/app/src/public/html/components.lisp | 2 + crates/app/src/public/html/economy/edit.lisp | 138 ++++++++++++++---- .../app/src/public/html/economy/product.lisp | 83 ++++++++++- crates/app/src/public/html/profile/base.lisp | 6 + .../app/src/public/html/profile/settings.lisp | 21 +++ crates/app/src/routes/api/v1/auth/profile.rs | 110 +++++++++++++- crates/app/src/routes/api/v1/mod.rs | 27 +++- crates/app/src/routes/api/v1/products.rs | 45 +++++- crates/app/src/routes/pages/economy.rs | 7 + crates/app/src/routes/pages/profile.rs | 34 ++++- crates/core/src/database/auth.rs | 42 +++++- .../database/drivers/sql/create_products.sql | 3 +- .../src/database/drivers/sql/create_users.sql | 3 +- .../drivers/sql/version_migrations.sql | 8 + crates/core/src/database/products.rs | 20 ++- crates/core/src/database/requests.rs | 9 +- crates/core/src/model/auth.rs | 10 ++ crates/core/src/model/economy.rs | 7 + 19 files changed, 525 insertions(+), 54 deletions(-) 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 `