From c02b809601822931f2a7e791eba899f9902a87da Mon Sep 17 00:00:00 2001 From: coolbot <76798835+coolbot100s@users.noreply.github.com> Date: Mon, 11 Aug 2025 02:30:22 -0700 Subject: [PATCH 01/21] Update utils.ts (#4157) --- packages/moderation/utils.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/moderation/utils.ts b/packages/moderation/utils.ts index 9feb8c27..ee3a9fd0 100644 --- a/packages/moderation/utils.ts +++ b/packages/moderation/utils.ts @@ -311,7 +311,7 @@ export function flattenProjectVariables(project: Project): Record Date: Mon, 11 Aug 2025 17:40:58 -0400 Subject: [PATCH 02/21] Offers, redemption, preview subscriptions (#4121) * Initial db migration/impl, guarded partner routes * Add guard to /redeem * Add `public` column to products prices, only expose public prices * Query cache * Add partner subscription type * 5 days subscription interval, metadata * Create server on redeem * Query cache * Fix race condition * Unprovision Medal subscriptions * Consider due expiring charge as unprovisionable * Query cache * Use a queue * Promote to full subscription, fmt + clippy * Patch expiring charge on promotion, comments * Additional comments * Add `tags` field to Archon /create request * Address review comments * Query cache * Final fixes to edit_subscription * Appease clippy * fmt --- ...ad04831b4bed925bded054bb8a35d0680bed8.json | 34 + ...daad852d58c61cf2a1affb01acc4984d42341.json | 15 + ...c157de8cfe1b140c73869e0388605fa2389f3.json | 27 + ...7ca9546970f314f902a5197eb2d189cf81f7.json} | 4 +- ...289ad7c128c2dcae659b41f08b1a1b0af94bf.json | 18 + ...6cb695ac1000bfee0a7a5b0d5f4db8ec3a8b1.json | 41 + ...c5db30ab6ad0bb346caf781efdb5aab524286.json | 59 ++ ...6781f6c5ae3f3bd1813ef2bceba8b8291b380.json | 19 + ...590f7933e9f2465e9458fab005fe33d96ec7a.json | 23 + ...90c3aebf59dfdbff277533e507ae3ef08c652.json | 29 + ...5503c85e3f167216e0e2403e08095a3e32c9.json} | 4 +- .../20250804221014_users-redeemals.sql | 9 + .../20250805001654_product-prices-public.sql | 4 + .../src/database/models/charge_item.rs | 3 +- apps/labrinth/src/database/models/mod.rs | 1 + .../src/database/models/product_item.rs | 200 +++- .../src/database/models/users_redeemals.rs | 299 ++++++ apps/labrinth/src/models/v3/billing.rs | 24 +- apps/labrinth/src/routes/internal/billing.rs | 859 +++++++++++++----- apps/labrinth/src/routes/internal/medal.rs | 109 +++ apps/labrinth/src/routes/internal/mod.rs | 4 +- apps/labrinth/src/routes/mod.rs | 4 + apps/labrinth/src/util/archon.rs | 75 ++ apps/labrinth/src/util/guards.rs | 15 + apps/labrinth/src/util/mod.rs | 1 + 25 files changed, 1604 insertions(+), 276 deletions(-) create mode 100644 apps/labrinth/.sqlx/query-139ba392ab53d975e63e2c328abad04831b4bed925bded054bb8a35d0680bed8.json create mode 100644 apps/labrinth/.sqlx/query-1dea22589b0440cfeaf98b6869bdaad852d58c61cf2a1affb01acc4984d42341.json create mode 100644 apps/labrinth/.sqlx/query-2db59a4a696bb287f8df30c0605c157de8cfe1b140c73869e0388605fa2389f3.json rename apps/labrinth/.sqlx/{query-cf020daa52a1316e5f60d197196b880b72c0b2a576e470d9fd7182558103d055.json => query-2e18682890f7ec5a618991c2a4c77ca9546970f314f902a5197eb2d189cf81f7.json} (93%) create mode 100644 apps/labrinth/.sqlx/query-4a02b388b4bd0141204fa6971ec289ad7c128c2dcae659b41f08b1a1b0af94bf.json create mode 100644 apps/labrinth/.sqlx/query-4d4315ae19dd87814c6a7f714366cb695ac1000bfee0a7a5b0d5f4db8ec3a8b1.json create mode 100644 apps/labrinth/.sqlx/query-58ccda393820a272d72a3e41eccc5db30ab6ad0bb346caf781efdb5aab524286.json create mode 100644 apps/labrinth/.sqlx/query-627411cb3c2cf313239c853dab76781f6c5ae3f3bd1813ef2bceba8b8291b380.json create mode 100644 apps/labrinth/.sqlx/query-9898e9962ba497ef8482ffa57d6590f7933e9f2465e9458fab005fe33d96ec7a.json create mode 100644 apps/labrinth/.sqlx/query-a6422c468cf5121b44ceb33f7cf90c3aebf59dfdbff277533e507ae3ef08c652.json rename apps/labrinth/.sqlx/{query-bfcbcadda1e323d56b6a21fc060c56bff2f38a54cf65dd1cc21f209240c7091b.json => query-fda7d5659efb2b6940a3247043945503c85e3f167216e0e2403e08095a3e32c9.json} (90%) create mode 100644 apps/labrinth/migrations/20250804221014_users-redeemals.sql create mode 100644 apps/labrinth/migrations/20250805001654_product-prices-public.sql create mode 100644 apps/labrinth/src/database/models/users_redeemals.rs create mode 100644 apps/labrinth/src/routes/internal/medal.rs create mode 100644 apps/labrinth/src/util/archon.rs diff --git a/apps/labrinth/.sqlx/query-139ba392ab53d975e63e2c328abad04831b4bed925bded054bb8a35d0680bed8.json b/apps/labrinth/.sqlx/query-139ba392ab53d975e63e2c328abad04831b4bed925bded054bb8a35d0680bed8.json new file mode 100644 index 00000000..0cc0a3d8 --- /dev/null +++ b/apps/labrinth/.sqlx/query-139ba392ab53d975e63e2c328abad04831b4bed925bded054bb8a35d0680bed8.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, metadata, unitary\n FROM products\n WHERE metadata ->> 'type' = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "metadata", + "type_info": "Jsonb" + }, + { + "ordinal": 2, + "name": "unitary", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "139ba392ab53d975e63e2c328abad04831b4bed925bded054bb8a35d0680bed8" +} diff --git a/apps/labrinth/.sqlx/query-1dea22589b0440cfeaf98b6869bdaad852d58c61cf2a1affb01acc4984d42341.json b/apps/labrinth/.sqlx/query-1dea22589b0440cfeaf98b6869bdaad852d58c61cf2a1affb01acc4984d42341.json new file mode 100644 index 00000000..37689395 --- /dev/null +++ b/apps/labrinth/.sqlx/query-1dea22589b0440cfeaf98b6869bdaad852d58c61cf2a1affb01acc4984d42341.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users_redeemals\n SET status = $1\n WHERE\n status = $2\n AND NOW() - last_attempt > INTERVAL '5 minutes'\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "1dea22589b0440cfeaf98b6869bdaad852d58c61cf2a1affb01acc4984d42341" +} diff --git a/apps/labrinth/.sqlx/query-2db59a4a696bb287f8df30c0605c157de8cfe1b140c73869e0388605fa2389f3.json b/apps/labrinth/.sqlx/query-2db59a4a696bb287f8df30c0605c157de8cfe1b140c73869e0388605fa2389f3.json new file mode 100644 index 00000000..acdeffdc --- /dev/null +++ b/apps/labrinth/.sqlx/query-2db59a4a696bb287f8df30c0605c157de8cfe1b140c73869e0388605fa2389f3.json @@ -0,0 +1,27 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO users_redeemals\n (user_id, offer, redeemed, status, last_attempt, n_attempts)\n VALUES ($1, $2, $3, $4, $5, $6)\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Timestamptz", + "Varchar", + "Timestamptz", + "Int4" + ] + }, + "nullable": [ + false + ] + }, + "hash": "2db59a4a696bb287f8df30c0605c157de8cfe1b140c73869e0388605fa2389f3" +} diff --git a/apps/labrinth/.sqlx/query-cf020daa52a1316e5f60d197196b880b72c0b2a576e470d9fd7182558103d055.json b/apps/labrinth/.sqlx/query-2e18682890f7ec5a618991c2a4c77ca9546970f314f902a5197eb2d189cf81f7.json similarity index 93% rename from apps/labrinth/.sqlx/query-cf020daa52a1316e5f60d197196b880b72c0b2a576e470d9fd7182558103d055.json rename to apps/labrinth/.sqlx/query-2e18682890f7ec5a618991c2a4c77ca9546970f314f902a5197eb2d189cf81f7.json index 47805a8b..223a79b2 100644 --- a/apps/labrinth/.sqlx/query-cf020daa52a1316e5f60d197196b880b72c0b2a576e470d9fd7182558103d055.json +++ b/apps/labrinth/.sqlx/query-2e18682890f7ec5a618991c2a4c77ca9546970f314f902a5197eb2d189cf81f7.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled' OR status = 'failed')", + "query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'expiring' OR status = 'cancelled' OR status = 'failed')", "describe": { "columns": [ { @@ -102,5 +102,5 @@ true ] }, - "hash": "cf020daa52a1316e5f60d197196b880b72c0b2a576e470d9fd7182558103d055" + "hash": "2e18682890f7ec5a618991c2a4c77ca9546970f314f902a5197eb2d189cf81f7" } diff --git a/apps/labrinth/.sqlx/query-4a02b388b4bd0141204fa6971ec289ad7c128c2dcae659b41f08b1a1b0af94bf.json b/apps/labrinth/.sqlx/query-4a02b388b4bd0141204fa6971ec289ad7c128c2dcae659b41f08b1a1b0af94bf.json new file mode 100644 index 00000000..3c86f848 --- /dev/null +++ b/apps/labrinth/.sqlx/query-4a02b388b4bd0141204fa6971ec289ad7c128c2dcae659b41f08b1a1b0af94bf.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users_redeemals\n SET\n status = $3,\n last_attempt = $4,\n n_attempts = $5\n WHERE id = $1 AND status = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Text", + "Varchar", + "Timestamptz", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "4a02b388b4bd0141204fa6971ec289ad7c128c2dcae659b41f08b1a1b0af94bf" +} diff --git a/apps/labrinth/.sqlx/query-4d4315ae19dd87814c6a7f714366cb695ac1000bfee0a7a5b0d5f4db8ec3a8b1.json b/apps/labrinth/.sqlx/query-4d4315ae19dd87814c6a7f714366cb695ac1000bfee0a7a5b0d5f4db8ec3a8b1.json new file mode 100644 index 00000000..0dc8ddca --- /dev/null +++ b/apps/labrinth/.sqlx/query-4d4315ae19dd87814c6a7f714366cb695ac1000bfee0a7a5b0d5f4db8ec3a8b1.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, product_id, prices, currency_code\n FROM products_prices\n WHERE product_id = ANY($1::bigint[]) AND public = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "product_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "prices", + "type_info": "Jsonb" + }, + { + "ordinal": 3, + "name": "currency_code", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8Array", + "Bool" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "4d4315ae19dd87814c6a7f714366cb695ac1000bfee0a7a5b0d5f4db8ec3a8b1" +} diff --git a/apps/labrinth/.sqlx/query-58ccda393820a272d72a3e41eccc5db30ab6ad0bb346caf781efdb5aab524286.json b/apps/labrinth/.sqlx/query-58ccda393820a272d72a3e41eccc5db30ab6ad0bb346caf781efdb5aab524286.json new file mode 100644 index 00000000..b5166fb6 --- /dev/null +++ b/apps/labrinth/.sqlx/query-58ccda393820a272d72a3e41eccc5db30ab6ad0bb346caf781efdb5aab524286.json @@ -0,0 +1,59 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT * FROM users_redeemals WHERE status = $1 LIMIT $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "offer", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "redeemed", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "last_attempt", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "n_attempts", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false + ] + }, + "hash": "58ccda393820a272d72a3e41eccc5db30ab6ad0bb346caf781efdb5aab524286" +} diff --git a/apps/labrinth/.sqlx/query-627411cb3c2cf313239c853dab76781f6c5ae3f3bd1813ef2bceba8b8291b380.json b/apps/labrinth/.sqlx/query-627411cb3c2cf313239c853dab76781f6c5ae3f3bd1813ef2bceba8b8291b380.json new file mode 100644 index 00000000..53065792 --- /dev/null +++ b/apps/labrinth/.sqlx/query-627411cb3c2cf313239c853dab76781f6c5ae3f3bd1813ef2bceba8b8291b380.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE users_redeemals\n SET\n offer = $2,\n status = $3,\n redeemed = $4,\n last_attempt = $5,\n n_attempts = $6\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Varchar", + "Varchar", + "Timestamptz", + "Timestamptz", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "627411cb3c2cf313239c853dab76781f6c5ae3f3bd1813ef2bceba8b8291b380" +} diff --git a/apps/labrinth/.sqlx/query-9898e9962ba497ef8482ffa57d6590f7933e9f2465e9458fab005fe33d96ec7a.json b/apps/labrinth/.sqlx/query-9898e9962ba497ef8482ffa57d6590f7933e9f2465e9458fab005fe33d96ec7a.json new file mode 100644 index 00000000..e0e9551e --- /dev/null +++ b/apps/labrinth/.sqlx/query-9898e9962ba497ef8482ffa57d6590f7933e9f2465e9458fab005fe33d96ec7a.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n EXISTS (\n SELECT\n 1\n FROM\n users_redeemals\n WHERE\n user_id = $1\n AND offer = $2\n ) AS \"exists!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "9898e9962ba497ef8482ffa57d6590f7933e9f2465e9458fab005fe33d96ec7a" +} diff --git a/apps/labrinth/.sqlx/query-a6422c468cf5121b44ceb33f7cf90c3aebf59dfdbff277533e507ae3ef08c652.json b/apps/labrinth/.sqlx/query-a6422c468cf5121b44ceb33f7cf90c3aebf59dfdbff277533e507ae3ef08c652.json new file mode 100644 index 00000000..4adb958c --- /dev/null +++ b/apps/labrinth/.sqlx/query-a6422c468cf5121b44ceb33f7cf90c3aebf59dfdbff277533e507ae3ef08c652.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n users.id,\n users_redeemals.status AS \"status: Option\"\n FROM\n users\n LEFT JOIN\n users_redeemals ON users_redeemals.user_id = users.id\n AND users_redeemals.offer = $2\n WHERE\n users.username = $1\n ORDER BY\n users_redeemals.redeemed DESC\n LIMIT 1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "status: Option", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "a6422c468cf5121b44ceb33f7cf90c3aebf59dfdbff277533e507ae3ef08c652" +} diff --git a/apps/labrinth/.sqlx/query-bfcbcadda1e323d56b6a21fc060c56bff2f38a54cf65dd1cc21f209240c7091b.json b/apps/labrinth/.sqlx/query-fda7d5659efb2b6940a3247043945503c85e3f167216e0e2403e08095a3e32c9.json similarity index 90% rename from apps/labrinth/.sqlx/query-bfcbcadda1e323d56b6a21fc060c56bff2f38a54cf65dd1cc21f209240c7091b.json rename to apps/labrinth/.sqlx/query-fda7d5659efb2b6940a3247043945503c85e3f167216e0e2403e08095a3e32c9.json index 6f4cba22..844fd278 100644 --- a/apps/labrinth/.sqlx/query-bfcbcadda1e323d56b6a21fc060c56bff2f38a54cf65dd1cc21f209240c7091b.json +++ b/apps/labrinth/.sqlx/query-fda7d5659efb2b6940a3247043945503c85e3f167216e0e2403e08095a3e32c9.json @@ -1,6 +1,6 @@ { "db_name": "PostgreSQL", - "query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'cancelled' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ", + "query": "\n SELECT\n id, user_id, price_id, amount, currency_code, status, due, last_attempt,\n charge_type, subscription_id,\n -- Workaround for https://github.com/launchbadge/sqlx/issues/3336\n subscription_interval AS \"subscription_interval?\",\n payment_platform,\n payment_platform_id AS \"payment_platform_id?\",\n parent_charge_id AS \"parent_charge_id?\",\n net AS \"net?\"\n FROM charges\n \n WHERE\n charge_type = $1 AND\n (\n (status = 'cancelled' AND due < NOW()) OR\n (status = 'expiring' AND due < NOW()) OR\n (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days')\n )\n ", "describe": { "columns": [ { @@ -102,5 +102,5 @@ true ] }, - "hash": "bfcbcadda1e323d56b6a21fc060c56bff2f38a54cf65dd1cc21f209240c7091b" + "hash": "fda7d5659efb2b6940a3247043945503c85e3f167216e0e2403e08095a3e32c9" } diff --git a/apps/labrinth/migrations/20250804221014_users-redeemals.sql b/apps/labrinth/migrations/20250804221014_users-redeemals.sql new file mode 100644 index 00000000..a9b3c66e --- /dev/null +++ b/apps/labrinth/migrations/20250804221014_users-redeemals.sql @@ -0,0 +1,9 @@ +CREATE TABLE users_redeemals ( + id SERIAL PRIMARY KEY, + user_id BIGINT NOT NULL REFERENCES users(id), + offer VARCHAR NOT NULL, + redeemed TIMESTAMP WITH TIME ZONE NOT NULL, + status VARCHAR NOT NULL, + last_attempt TIMESTAMP WITH TIME ZONE, + n_attempts INTEGER NOT NULL +); \ No newline at end of file diff --git a/apps/labrinth/migrations/20250805001654_product-prices-public.sql b/apps/labrinth/migrations/20250805001654_product-prices-public.sql new file mode 100644 index 00000000..adccd4f3 --- /dev/null +++ b/apps/labrinth/migrations/20250805001654_product-prices-public.sql @@ -0,0 +1,4 @@ +ALTER TABLE + products_prices +ADD COLUMN + public BOOLEAN NOT NULL DEFAULT true; diff --git a/apps/labrinth/src/database/models/charge_item.rs b/apps/labrinth/src/database/models/charge_item.rs index 2ca8b9ed..24cc9b0c 100644 --- a/apps/labrinth/src/database/models/charge_item.rs +++ b/apps/labrinth/src/database/models/charge_item.rs @@ -197,7 +197,7 @@ impl DBCharge { ) -> Result, DatabaseError> { let user_subscription_id = user_subscription_id.0; let res = select_charges_with_predicate!( - "WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled' OR status = 'failed')", + "WHERE subscription_id = $1 AND (status = 'open' OR status = 'expiring' OR status = 'cancelled' OR status = 'failed')", user_subscription_id ) .fetch_optional(exec) @@ -240,6 +240,7 @@ impl DBCharge { charge_type = $1 AND ( (status = 'cancelled' AND due < NOW()) OR + (status = 'expiring' AND due < NOW()) OR (status = 'failed' AND last_attempt < NOW() - INTERVAL '2 days') ) "#, diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index 6a051b43..4ef40cf1 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -25,6 +25,7 @@ pub mod team_item; pub mod thread_item; pub mod user_item; pub mod user_subscription_item; +pub mod users_redeemals; pub mod version_item; pub use collection_item::DBCollection; diff --git a/apps/labrinth/src/database/models/product_item.rs b/apps/labrinth/src/database/models/product_item.rs index 3879fe41..205cb1fb 100644 --- a/apps/labrinth/src/database/models/product_item.rs +++ b/apps/labrinth/src/database/models/product_item.rs @@ -57,6 +57,26 @@ impl DBProduct { Ok(Self::get_many(&[id], exec).await?.into_iter().next()) } + pub async fn get_by_type<'a, E>( + exec: E, + r#type: &str, + ) -> Result, DatabaseError> + where + E: sqlx::PgExecutor<'a>, + { + let maybe_row = select_products_with_predicate!( + "WHERE metadata ->> 'type' = $1", + r#type + ) + .fetch_all(exec) + .await?; + + maybe_row + .into_iter() + .map(|r| r.try_into().map_err(Into::into)) + .collect() + } + pub async fn get_many( ids: &[DBProductId], exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, @@ -100,10 +120,11 @@ pub struct QueryProductWithPrices { } impl QueryProductWithPrices { - pub async fn list<'a, E>( + /// Lists products with at least one public price. + pub async fn list_purchaseable<'a, E>( exec: E, redis: &RedisPool, - ) -> Result, DatabaseError> + ) -> Result, DatabaseError> where E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, { @@ -118,7 +139,51 @@ impl QueryProductWithPrices { } let all_products = product_item::DBProduct::get_all(exec).await?; - let prices = product_item::DBProductPrice::get_all_products_prices( + let prices = + product_item::DBProductPrice::get_all_public_products_prices( + &all_products.iter().map(|x| x.id).collect::>(), + exec, + ) + .await?; + + let products = all_products + .into_iter() + .filter_map(|x| { + Some(QueryProductWithPrices { + id: x.id, + metadata: x.metadata, + prices: prices + .remove(&x.id) + .map(|x| x.1)? + .into_iter() + .map(|x| DBProductPrice { + id: x.id, + product_id: x.product_id, + prices: x.prices, + currency_code: x.currency_code, + }) + .collect(), + unitary: x.unitary, + }) + }) + .collect::>(); + + redis + .set_serialized_to_json(PRODUCTS_NAMESPACE, "all", &products, None) + .await?; + + Ok(products) + } + + pub async fn list_by_product_type<'a, E>( + exec: E, + r#type: &str, + ) -> Result, DatabaseError> + where + E: sqlx::PgExecutor<'a> + Copy, + { + let all_products = DBProduct::get_by_type(exec, r#type).await?; + let prices = DBProductPrice::get_all_products_prices( &all_products.iter().map(|x| x.id).collect::>(), exec, ) @@ -126,29 +191,26 @@ impl QueryProductWithPrices { let products = all_products .into_iter() - .map(|x| QueryProductWithPrices { - id: x.id, - metadata: x.metadata, - prices: prices - .remove(&x.id) - .map(|x| x.1) - .unwrap_or_default() - .into_iter() - .map(|x| DBProductPrice { - id: x.id, - product_id: x.product_id, - prices: x.prices, - currency_code: x.currency_code, - }) - .collect(), - unitary: x.unitary, + .filter_map(|x| { + Some(QueryProductWithPrices { + id: x.id, + metadata: x.metadata, + prices: prices + .remove(&x.id) + .map(|x| x.1)? + .into_iter() + .map(|x| DBProductPrice { + id: x.id, + product_id: x.product_id, + prices: x.prices, + currency_code: x.currency_code, + }) + .collect(), + unitary: x.unitary, + }) }) .collect::>(); - redis - .set_serialized_to_json(PRODUCTS_NAMESPACE, "all", &products, None) - .await?; - Ok(products) } } @@ -169,7 +231,11 @@ struct ProductPriceQueryResult { } macro_rules! select_prices_with_predicate { - ($predicate:tt, $param:ident) => { + ($predicate:tt, $param1:ident) => { + select_prices_with_predicate!($predicate, $param1, ) + }; + + ($predicate:tt, $($param:ident,)+) => { sqlx::query_as!( ProductPriceQueryResult, r#" @@ -177,7 +243,7 @@ macro_rules! select_prices_with_predicate { FROM products_prices "# + $predicate, - $param + $($param),+ ) }; } @@ -231,33 +297,81 @@ impl DBProductPrice { Ok(res.remove(&product_id).map(|x| x.1).unwrap_or_default()) } + pub async fn get_all_public_product_prices( + product_id: DBProductId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let res = + Self::get_all_public_products_prices(&[product_id], exec).await?; + + Ok(res.remove(&product_id).map(|x| x.1).unwrap_or_default()) + } + + /// Gets all public prices for the given products. If a product has no public price, + /// it won't be included in the resulting map. + pub async fn get_all_public_products_prices( + product_ids: &[DBProductId], + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result>, DatabaseError> { + Self::get_all_products_prices_with_visibility( + product_ids, + Some(true), + exec, + ) + .await + } + pub async fn get_all_products_prices( product_ids: &[DBProductId], exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result>, DatabaseError> { + Self::get_all_products_prices_with_visibility(product_ids, None, exec) + .await + } + + async fn get_all_products_prices_with_visibility( + product_ids: &[DBProductId], + public_filter: Option, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, ) -> Result>, DatabaseError> { let ids = product_ids.iter().map(|id| id.0).collect_vec(); let ids_ref: &[i64] = &ids; use futures_util::TryStreamExt; - let prices = select_prices_with_predicate!( - "WHERE product_id = ANY($1::bigint[])", - ids_ref - ) - .fetch(exec) - .try_fold( - DashMap::new(), - |acc: DashMap>, x| { - if let Ok(item) = >::try_into(x) - { - acc.entry(item.product_id).or_default().push(item); - } - async move { Ok(acc) } - }, - ) - .await?; + let predicate = |acc: DashMap>, x| { + if let Ok(item) = >::try_into(x) + { + acc.entry(item.product_id).or_default().push(item); + } + + async move { Ok(acc) } + }; + + let prices = match public_filter { + None => { + select_prices_with_predicate!( + "WHERE product_id = ANY($1::bigint[])", + ids_ref, + ) + .fetch(exec) + .try_fold(DashMap::new(), predicate) + .await? + } + + Some(public) => { + select_prices_with_predicate!( + "WHERE product_id = ANY($1::bigint[]) AND public = $2", + ids_ref, + public, + ) + .fetch(exec) + .try_fold(DashMap::new(), predicate) + .await? + } + }; Ok(prices) } diff --git a/apps/labrinth/src/database/models/users_redeemals.rs b/apps/labrinth/src/database/models/users_redeemals.rs new file mode 100644 index 00000000..b72654d1 --- /dev/null +++ b/apps/labrinth/src/database/models/users_redeemals.rs @@ -0,0 +1,299 @@ +use crate::database::models::DBUserId; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{query, query_scalar}; +use std::fmt; + +#[derive( + Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum Offer { + #[default] + Medal, +} + +impl Offer { + pub fn as_str(&self) -> &'static str { + match self { + Offer::Medal => "medal", + } + } + + pub fn from_str_or_default(s: &str) -> Self { + match s { + "medal" => Offer::Medal, + _ => Offer::Medal, + } + } +} + +impl fmt::Display for Offer { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +#[derive( + Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, +)] +#[serde(rename_all = "snake_case")] +pub enum Status { + #[default] + Pending, + Processing, + Processed, +} + +impl Status { + pub fn as_str(&self) -> &'static str { + match self { + Status::Pending => "pending", + Status::Processing => "processing", + Status::Processed => "processed", + } + } + + pub fn from_str_or_default(s: &str) -> Self { + match s { + "pending" => Status::Pending, + "processing" => Status::Processing, + "processed" => Status::Processed, + _ => Status::default(), + } + } +} + +impl fmt::Display for Status { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.as_str()) + } +} + +#[derive(Debug)] +pub struct UserRedeemal { + pub id: i32, + pub user_id: DBUserId, + pub offer: Offer, + pub redeemed: DateTime, + pub last_attempt: Option>, + pub n_attempts: i32, + pub status: Status, +} + +impl UserRedeemal { + pub async fn get_pending<'a, E>( + exec: E, + limit: i64, + ) -> sqlx::Result> + where + E: sqlx::PgExecutor<'a>, + { + let redeemals = query!( + r#"SELECT * FROM users_redeemals WHERE status = $1 LIMIT $2"#, + Status::Pending.as_str(), + limit + ) + .fetch_all(exec) + .await? + .into_iter() + .map(|row| UserRedeemal { + id: row.id, + user_id: DBUserId(row.user_id), + offer: Offer::from_str_or_default(&row.offer), + redeemed: row.redeemed, + last_attempt: row.last_attempt, + n_attempts: row.n_attempts, + status: Status::from_str_or_default(&row.status), + }) + .collect(); + + Ok(redeemals) + } + + pub async fn update_stuck_5_minutes<'a, E>(exec: E) -> sqlx::Result<()> + where + E: sqlx::PgExecutor<'a>, + { + query!( + r#" + UPDATE users_redeemals + SET status = $1 + WHERE + status = $2 + AND NOW() - last_attempt > INTERVAL '5 minutes' + "#, + Status::Pending.as_str(), + Status::Processing.as_str(), + ) + .execute(exec) + .await?; + + Ok(()) + } + + pub async fn exists_by_user_and_offer<'a, E>( + exec: E, + user_id: DBUserId, + offer: Offer, + ) -> sqlx::Result + where + E: sqlx::PgExecutor<'a>, + { + query_scalar!( + r#"SELECT + EXISTS ( + SELECT + 1 + FROM + users_redeemals + WHERE + user_id = $1 + AND offer = $2 + ) AS "exists!" + "#, + user_id.0, + offer.as_str(), + ) + .fetch_one(exec) + .await + } + + pub async fn insert<'a, E>(&mut self, exec: E) -> sqlx::Result<()> + where + E: sqlx::PgExecutor<'a>, + { + let query = query_scalar!( + r#"INSERT INTO users_redeemals + (user_id, offer, redeemed, status, last_attempt, n_attempts) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id + "#, + self.user_id.0, + self.offer.as_str(), + self.redeemed, + self.status.as_str(), + self.last_attempt, + self.n_attempts, + ); + + let id = query.fetch_one(exec).await?; + + self.id = id; + + Ok(()) + } + + /// Updates `status`, `last_attempt`, and `n_attempts` only if `status` is currently pending. + /// Returns `true` if the status was updated, `false` otherwise. + pub async fn update_status_if_pending<'a, E>( + &self, + exec: E, + ) -> sqlx::Result + where + E: sqlx::PgExecutor<'a>, + { + let query = query!( + r#"UPDATE users_redeemals + SET + status = $3, + last_attempt = $4, + n_attempts = $5 + WHERE id = $1 AND status = $2 + "#, + self.id, + Status::Pending.as_str(), + self.status.as_str(), + self.last_attempt, + self.n_attempts, + ); + + let query_result = query.execute(exec).await?; + + Ok(query_result.rows_affected() > 0) + } + + pub async fn update<'a, E>(&self, exec: E) -> sqlx::Result<()> + where + E: sqlx::PgExecutor<'a>, + { + let query = query!( + r#"UPDATE users_redeemals + SET + offer = $2, + status = $3, + redeemed = $4, + last_attempt = $5, + n_attempts = $6 + WHERE id = $1 + "#, + self.id, + self.offer.as_str(), + self.status.as_str(), + self.redeemed, + self.last_attempt, + self.n_attempts, + ); + + query.execute(exec).await?; + + Ok(()) + } +} + +#[derive(Debug)] +pub struct RedeemalLookupFields { + pub user_id: DBUserId, + pub redeemal_status: Option, +} + +impl RedeemalLookupFields { + /// Returns the redeemal status of a user for an offer, while looking up the user + /// itself. **This expects a single redeemal per user/offer pair**. + /// + /// If the returned value is `Ok(None)`, the user doesn't exist. + /// + /// If the returned value is `Ok(Some(fields))`, but `redeemal_status` is `None`, + /// the user exists and has not redeemed the offer. + pub async fn redeemal_status_by_username_and_offer<'a, E>( + exec: E, + user_username: &str, + offer: Offer, + ) -> sqlx::Result> + where + E: sqlx::PgExecutor<'a>, + { + let maybe_row = query!( + r#" + SELECT + users.id, + users_redeemals.status AS "status: Option" + FROM + users + LEFT JOIN + users_redeemals ON users_redeemals.user_id = users.id + AND users_redeemals.offer = $2 + WHERE + users.username = $1 + ORDER BY + users_redeemals.redeemed DESC + LIMIT 1 + "#, + user_username, + offer.as_str(), + ) + .fetch_optional(exec) + .await?; + + // If no row was returned, the user doesn't exist. + // If a row NULL status was returned, the user exists but has no redeemed the offer. + + Ok(maybe_row.map(|row| RedeemalLookupFields { + user_id: DBUserId(row.id), + redeemal_status: row + .status + .as_deref() + .map(Status::from_str_or_default), + })) + } +} diff --git a/apps/labrinth/src/models/v3/billing.rs b/apps/labrinth/src/models/v3/billing.rs index 2757a7fc..cb9eb0f9 100644 --- a/apps/labrinth/src/models/v3/billing.rs +++ b/apps/labrinth/src/models/v3/billing.rs @@ -24,6 +24,13 @@ pub enum ProductMetadata { swap: u32, storage: u32, }, + Medal { + cpu: u32, + ram: u32, + swap: u32, + storage: u32, + region: String, + }, } #[derive(Serialize, Deserialize)] @@ -48,6 +55,7 @@ pub enum Price { #[derive(Serialize, Deserialize, Hash, Eq, PartialEq, Debug, Copy, Clone)] #[serde(rename_all = "kebab-case")] pub enum PriceDuration { + FiveDays, Monthly, Quarterly, Yearly, @@ -56,6 +64,7 @@ pub enum PriceDuration { impl PriceDuration { pub fn duration(&self) -> chrono::Duration { match self { + PriceDuration::FiveDays => chrono::Duration::days(5), PriceDuration::Monthly => chrono::Duration::days(30), PriceDuration::Quarterly => chrono::Duration::days(90), PriceDuration::Yearly => chrono::Duration::days(365), @@ -64,6 +73,7 @@ impl PriceDuration { pub fn from_string(string: &str) -> PriceDuration { match string { + "five-days" => PriceDuration::FiveDays, "monthly" => PriceDuration::Monthly, "quarterly" => PriceDuration::Quarterly, "yearly" => PriceDuration::Yearly, @@ -76,6 +86,7 @@ impl PriceDuration { PriceDuration::Monthly => "monthly", PriceDuration::Quarterly => "quarterly", PriceDuration::Yearly => "yearly", + PriceDuration::FiveDays => "five-days", } } @@ -84,6 +95,7 @@ impl PriceDuration { PriceDuration::Monthly, PriceDuration::Quarterly, PriceDuration::Yearly, + PriceDuration::FiveDays, ] .into_iter() } @@ -146,6 +158,7 @@ impl SubscriptionStatus { #[serde(tag = "type", rename_all = "kebab-case")] pub enum SubscriptionMetadata { Pyro { id: String, region: Option }, + Medal { id: String }, } #[derive(Serialize, Deserialize)] @@ -201,12 +214,16 @@ impl ChargeType { #[derive(Serialize, Deserialize, Eq, PartialEq, Copy, Clone, Debug)] #[serde(rename_all = "kebab-case")] pub enum ChargeStatus { - // Open charges are for the next billing interval + /// Open charges are for the next billing interval Open, Processing, Succeeded, Failed, Cancelled, + /// Expiring charges are charges that aren't expected to be processed + /// but can be promoted to a full charge, like for trials/freebies. When + /// due, the underlying subscription is unprovisioned. + Expiring, } impl ChargeStatus { @@ -217,6 +234,7 @@ impl ChargeStatus { "failed" => ChargeStatus::Failed, "open" => ChargeStatus::Open, "cancelled" => ChargeStatus::Cancelled, + "expiring" => ChargeStatus::Expiring, _ => ChargeStatus::Failed, } } @@ -228,6 +246,7 @@ impl ChargeStatus { ChargeStatus::Failed => "failed", ChargeStatus::Open => "open", ChargeStatus::Cancelled => "cancelled", + ChargeStatus::Expiring => "expiring", } } } @@ -235,12 +254,14 @@ impl ChargeStatus { #[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)] pub enum PaymentPlatform { Stripe, + None, } impl PaymentPlatform { pub fn from_string(string: &str) -> PaymentPlatform { match string { "stripe" => PaymentPlatform::Stripe, + "none" => PaymentPlatform::None, _ => PaymentPlatform::Stripe, } } @@ -248,6 +269,7 @@ impl PaymentPlatform { pub fn as_str(&self) -> &'static str { match self { PaymentPlatform::Stripe => "stripe", + PaymentPlatform::None => "none", } } } diff --git a/apps/labrinth/src/routes/internal/billing.rs b/apps/labrinth/src/routes/internal/billing.rs index c27a507f..1f6e6550 100644 --- a/apps/labrinth/src/routes/internal/billing.rs +++ b/apps/labrinth/src/routes/internal/billing.rs @@ -1,5 +1,8 @@ use crate::auth::{get_user_from_headers, send_email}; use crate::database::models::charge_item::DBCharge; +use crate::database::models::user_item::DBUser; +use crate::database::models::user_subscription_item::DBUserSubscription; +use crate::database::models::users_redeemals::{self, UserRedeemal}; use crate::database::models::{ generate_charge_id, generate_user_subscription_id, product_item, user_subscription_item, @@ -14,6 +17,7 @@ use crate::models::pats::Scopes; use crate::models::users::Badges; use crate::queue::session::AuthQueue; use crate::routes::ApiError; +use crate::util::archon::{ArchonClient, CreateServerRequest, Specs}; use actix_web::{HttpRequest, HttpResponse, delete, get, patch, post, web}; use ariadne::ids::base62_impl::{parse_base62, to_base62}; use chrono::{Duration, Utc}; @@ -59,8 +63,10 @@ pub async fn products( pool: web::Data, redis: web::Data, ) -> Result { - let products = - product_item::QueryProductWithPrices::list(&**pool, &redis).await?; + let products = product_item::QueryProductWithPrices::list_purchaseable( + &**pool, &redis, + ) + .await?; let products = products .into_iter() @@ -182,7 +188,9 @@ pub async fn refund_charge( ChargeStatus::Open | ChargeStatus::Processing | ChargeStatus::Succeeded => Some(x.amount), - ChargeStatus::Failed | ChargeStatus::Cancelled => None, + ChargeStatus::Failed + | ChargeStatus::Cancelled + | ChargeStatus::Expiring => None, }) .sum::(); @@ -256,6 +264,12 @@ pub async fn refund_charge( )); } } + PaymentPlatform::None => { + return Err(ApiError::InvalidInput( + "This charge was not processed via a payment platform." + .to_owned(), + )); + } } }; @@ -311,6 +325,8 @@ pub struct SubscriptionEdit { pub interval: Option, pub payment_method: Option, pub cancelled: Option, + /// Only supported when changing the product as well. + pub region: Option, pub product: Option, } @@ -370,6 +386,7 @@ pub async fn edit_subscription( })?; if let Some(cancelled) = &edit_subscription.cancelled { + // Notably, cannot cancel/uncancel expiring charges. if !matches!( open_charge.status, ChargeStatus::Open @@ -395,13 +412,17 @@ pub async fn edit_subscription( if let Some(interval) = &edit_subscription.interval && let Price::Recurring { intervals } = ¤t_price.prices { - if let Some(price) = intervals.get(interval) { - open_charge.subscription_interval = Some(*interval); - open_charge.amount = *price as i64; - } else { - return Err(ApiError::InvalidInput( - "Interval is not valid for this subscription!".to_string(), - )); + // For expiring charges, the interval is handled in the Product branch. + if open_charge.status != ChargeStatus::Expiring { + if let Some(price) = intervals.get(interval) { + open_charge.subscription_interval = Some(*interval); + open_charge.amount = *price as i64; + } else { + return Err(ApiError::InvalidInput( + "Interval is not valid for this subscription!" + .to_string(), + )); + } } } @@ -428,48 +449,22 @@ pub async fn edit_subscription( )); } - let interval = open_charge.due - Utc::now(); - let duration = PriceDuration::Monthly; + // If the charge is an expiring charge, we need to create a payment + // intent as if the user was subscribing to the product, as opposed + // to a proration. + if open_charge.status == ChargeStatus::Expiring { + let Some(new_region) = + edit_subscription.region.as_ref().map(String::to_owned) + else { + return Err(ApiError::InvalidInput( + "You need to specify a region when promoting an expiring charge.".to_owned(), + )); + }; - let current_amount = match ¤t_price.prices { - Price::OneTime { price } => *price, - Price::Recurring { intervals } => *intervals.get(&duration).ok_or_else(|| { - ApiError::InvalidInput( - "Could not find a valid price for the user's duration".to_string(), - ) - })?, - }; + // We need a new interval when promoting the charge. + let interval = edit_subscription.interval + .ok_or_else(|| ApiError::InvalidInput("You need to specify an interval when promoting an expiring charge.".to_owned()))?; - let amount = match &product_price.prices { - Price::OneTime { price } => *price, - Price::Recurring { intervals } => *intervals.get(&duration).ok_or_else(|| { - ApiError::InvalidInput( - "Could not find a valid price for the user's duration".to_string(), - ) - })?, - }; - - let complete = Decimal::from(interval.num_seconds()) - / Decimal::from(duration.duration().num_seconds()); - let proration = (Decimal::from(amount - current_amount) * complete) - .floor() - .to_i32() - .ok_or_else(|| { - ApiError::InvalidInput( - "Could not convert proration to i32".to_string(), - ) - })?; - - // First branch: Plan downgrade, update future charge - // Second branch: For small transactions (under 30 cents), we make a loss on the - // proration due to fees. In these situations, just give it to them for free, because - // their next charge will be in a day or two anyway. - if current_amount > amount || proration < 30 { - open_charge.price_id = product_price.id; - open_charge.amount = amount as i64; - - None - } else { let charge_id = generate_charge_id(&mut transaction).await?; let customer_id = get_or_create_customer( @@ -482,6 +477,15 @@ pub async fn edit_subscription( ) .await?; + let new_price_value = match product_price.prices { + Price::OneTime { ref price } => *price, + Price::Recurring { ref intervals } => { + *intervals + .get(&interval) + .ok_or_else(|| ApiError::InvalidInput("Could not find a valid price for the specified duration".to_owned()))? + } + }; + let currency = Currency::from_str( ¤t_price.currency_code.to_lowercase(), ) @@ -490,7 +494,7 @@ pub async fn edit_subscription( })?; let mut intent = - CreatePaymentIntent::new(proration as i64, currency); + CreatePaymentIntent::new(new_price_value as i64, currency); let mut metadata = HashMap::new(); metadata.insert( @@ -511,16 +515,13 @@ pub async fn edit_subscription( ); metadata.insert( "modrinth_subscription_interval".to_string(), - open_charge - .subscription_interval - .unwrap_or(PriceDuration::Monthly) - .as_str() - .to_string(), + interval.as_str().to_string(), ); metadata.insert( "modrinth_charge_type".to_string(), - ChargeType::Proration.as_str().to_string(), + ChargeType::Subscription.as_str().to_string(), ); + metadata.insert("modrinth_new_region".to_string(), new_region); intent.customer = Some(customer_id); intent.metadata = Some(metadata); @@ -544,7 +545,145 @@ pub async fn edit_subscription( stripe::PaymentIntent::create(&stripe_client, intent) .await?; - Some((proration, 0, intent)) + // We do NOT update the open charge here. It will be patched to be the next + // charge of the subscription in the stripe webhook. + // + // We also shouldn't delete it, because if the payment fails, the expiring + // charge will be gone and the preview subscription will never be unprovisioned. + + Some((new_price_value, 0, intent)) + } else { + // The charge is not an expiring charge, need to prorate. + + let interval = open_charge.due - Utc::now(); + let duration = PriceDuration::Monthly; + + let current_amount = match ¤t_price.prices { + Price::OneTime { price } => *price, + Price::Recurring { intervals } => *intervals.get(&duration).ok_or_else(|| { + ApiError::InvalidInput( + "Could not find a valid price for the user's duration".to_string(), + ) + })?, + }; + + let amount = match &product_price.prices { + Price::OneTime { price } => *price, + Price::Recurring { intervals } => *intervals.get(&duration).ok_or_else(|| { + ApiError::InvalidInput( + "Could not find a valid price for the user's duration".to_string(), + ) + })?, + }; + + let complete = Decimal::from(interval.num_seconds()) + / Decimal::from(duration.duration().num_seconds()); + let proration = (Decimal::from(amount - current_amount) + * complete) + .floor() + .to_i32() + .ok_or_else(|| { + ApiError::InvalidInput( + "Could not convert proration to i32".to_string(), + ) + })?; + + // First condition: Plan downgrade, update future charge + // Second condition: For small transactions (under 30 cents), we make a loss on the + // proration due to fees. In these situations, just give it to them for free, because + // their next charge will be in a day or two anyway. + if current_amount > amount || proration < 30 { + open_charge.price_id = product_price.id; + open_charge.amount = amount as i64; + + None + } else { + let charge_id = + generate_charge_id(&mut transaction).await?; + + let customer_id = get_or_create_customer( + user.id, + user.stripe_customer_id.as_deref(), + user.email.as_deref(), + &stripe_client, + &pool, + &redis, + ) + .await?; + + let currency = Currency::from_str( + ¤t_price.currency_code.to_lowercase(), + ) + .map_err(|_| { + ApiError::InvalidInput( + "Invalid currency code".to_string(), + ) + })?; + + let mut intent = + CreatePaymentIntent::new(proration as i64, currency); + + let mut metadata = HashMap::new(); + metadata.insert( + "modrinth_user_id".to_string(), + to_base62(user.id.0), + ); + metadata.insert( + "modrinth_charge_id".to_string(), + to_base62(charge_id.0 as u64), + ); + metadata.insert( + "modrinth_subscription_id".to_string(), + to_base62(subscription.id.0 as u64), + ); + metadata.insert( + "modrinth_price_id".to_string(), + to_base62(product_price.id.0 as u64), + ); + metadata.insert( + "modrinth_subscription_interval".to_string(), + open_charge + .subscription_interval + .unwrap_or(PriceDuration::Monthly) + .as_str() + .to_string(), + ); + metadata.insert( + "modrinth_charge_type".to_string(), + ChargeType::Proration.as_str().to_string(), + ); + if let Some(region) = &edit_subscription.region { + metadata.insert( + "modrinth_new_region".to_string(), + region.to_owned(), + ); + } + + intent.customer = Some(customer_id); + intent.metadata = Some(metadata); + intent.receipt_email = user.email.as_deref(); + intent.setup_future_usage = + Some(PaymentIntentSetupFutureUsage::OffSession); + + if let Some(payment_method) = + &edit_subscription.payment_method + { + let Ok(payment_method_id) = + PaymentMethodId::from_str(payment_method) + else { + return Err(ApiError::InvalidInput( + "Invalid payment method id".to_string(), + )); + }; + intent.payment_method = Some(payment_method_id); + } + + let intent = + stripe::PaymentIntent::create(&stripe_client, intent) + .await?; + + Some((proration, 0, intent)) + } } } else { None @@ -947,14 +1086,17 @@ pub async fn active_servers( let server_ids = servers .into_iter() .filter_map(|x| { - x.metadata.as_ref().map(|metadata| match metadata { - SubscriptionMetadata::Pyro { id, region } => ActiveServer { - user_id: x.user_id.into(), - server_id: id.clone(), - price_id: x.price_id.into(), - interval: x.interval, - region: region.clone(), - }, + x.metadata.as_ref().and_then(|metadata| match metadata { + SubscriptionMetadata::Pyro { id, region } => { + Some(ActiveServer { + user_id: x.user_id.into(), + server_id: id.clone(), + price_id: x.price_id.into(), + interval: x.interval, + region: region.clone(), + }) + } + SubscriptionMetadata::Medal { .. } => None, }) }) .collect::>(); @@ -1186,7 +1328,7 @@ pub async fn initiate_payment( })?; let mut product_prices = - product_item::DBProductPrice::get_all_product_prices( + product_item::DBProductPrice::get_all_public_product_prices( product.id, &**pool, ) .await?; @@ -1422,6 +1564,7 @@ pub async fn stripe_webhook( pub user_subscription_item: Option, pub payment_metadata: Option, + pub new_region: Option, } #[allow(clippy::too_many_arguments)] @@ -1474,38 +1617,42 @@ pub async fn stripe_webhook( break 'metadata; }; - let (charge, price, product, subscription) = if let Some( - mut charge, - ) = - crate::database::models::charge_item::DBCharge::get( - charge_id, pool, - ) - .await? - { - let Some(price) = product_item::DBProductPrice::get( - charge.price_id, - pool, - ) - .await? - else { - break 'metadata; - }; + let new_region = + metadata.get("modrinth_new_region").map(String::to_owned); - let Some(product) = - product_item::DBProduct::get(price.product_id, pool) - .await? - else { - break 'metadata; - }; + let (charge, price, product, subscription, new_region) = + if let Some(mut charge) = + crate::database::models::charge_item::DBCharge::get( + charge_id, pool, + ) + .await? + { + let Some(price) = product_item::DBProductPrice::get( + charge.price_id, + pool, + ) + .await? + else { + break 'metadata; + }; - charge.status = charge_status; - charge.last_attempt = Some(Utc::now()); - charge.payment_platform_id = - Some(payment_intent_id.to_string()); - charge.upsert(transaction).await?; + let Some(product) = product_item::DBProduct::get( + price.product_id, + pool, + ) + .await? + else { + break 'metadata; + }; - if let Some(subscription_id) = charge.subscription_id { - let Some(mut subscription) = + charge.status = charge_status; + charge.last_attempt = Some(Utc::now()); + charge.payment_platform_id = + Some(payment_intent_id.to_string()); + charge.upsert(transaction).await?; + + if let Some(subscription_id) = charge.subscription_id { + let Some(mut subscription) = user_subscription_item::DBUserSubscription::get( subscription_id, pool, @@ -1515,69 +1662,79 @@ pub async fn stripe_webhook( break 'metadata; }; - match charge.type_ { - ChargeType::OneTime | ChargeType::Subscription => { - if let Some(interval) = - charge.subscription_interval - { - subscription.interval = interval; + match charge.type_ { + ChargeType::OneTime + | ChargeType::Subscription => { + if let Some(interval) = + charge.subscription_interval + { + subscription.interval = interval; + } + } + ChargeType::Proration => { + subscription.price_id = charge.price_id; + } + ChargeType::Refund => { + return Err(ApiError::InvalidInput( + "Invalid charge type: Refund" + .to_string(), + )); } } - ChargeType::Proration => { - subscription.price_id = charge.price_id; - } - ChargeType::Refund => { - return Err(ApiError::InvalidInput( - "Invalid charge type: Refund".to_string(), - )); - } - } - subscription.upsert(transaction).await?; + subscription.upsert(transaction).await?; - (charge, price, product, Some(subscription)) - } else { - (charge, price, product, None) - } - } else { - let Some(price_id) = metadata - .get("modrinth_price_id") - .and_then(|x| parse_base62(x).ok()) - .map(|x| { - crate::database::models::ids::DBProductPriceId( - x as i64, + ( + charge, + price, + product, + Some(subscription), + new_region, ) - }) - else { - break 'metadata; - }; + } else { + (charge, price, product, None, new_region) + } + } else { + let Some(price_id) = metadata + .get("modrinth_price_id") + .and_then(|x| parse_base62(x).ok()) + .map(|x| { + crate::database::models::ids::DBProductPriceId( + x as i64, + ) + }) + else { + break 'metadata; + }; - let Some(price) = - product_item::DBProductPrice::get(price_id, pool) - .await? - else { - break 'metadata; - }; + let Some(price) = + product_item::DBProductPrice::get(price_id, pool) + .await? + else { + break 'metadata; + }; - let Some(product) = - product_item::DBProduct::get(price.product_id, pool) - .await? - else { - break 'metadata; - }; + let Some(product) = product_item::DBProduct::get( + price.product_id, + pool, + ) + .await? + else { + break 'metadata; + }; - let subscription = match &price.prices { - Price::OneTime { .. } => None, - Price::Recurring { intervals } => { - let Some(interval) = metadata - .get("modrinth_subscription_interval") - .map(|x| PriceDuration::from_string(x)) - else { - break 'metadata; - }; + let subscription = match &price.prices { + Price::OneTime { .. } => None, + Price::Recurring { intervals } => { + let Some(interval) = metadata + .get("modrinth_subscription_interval") + .map(|x| PriceDuration::from_string(x)) + else { + break 'metadata; + }; - if intervals.get(&interval).is_some() { - let Some(subscription_id) = metadata + if intervals.get(&interval).is_some() { + let Some(subscription_id) = metadata .get("modrinth_subscription_id") .and_then(|x| parse_base62(x).ok()) .map(|x| { @@ -1586,7 +1743,7 @@ pub async fn stripe_webhook( break 'metadata; }; - let subscription = if let Some(mut subscription) = user_subscription_item::DBUserSubscription::get(subscription_id, pool).await? { + let subscription = if let Some(mut subscription) = user_subscription_item::DBUserSubscription::get(subscription_id, pool).await? { subscription.status = SubscriptionStatus::Unprovisioned; subscription.price_id = price_id; subscription.interval = interval; @@ -1604,46 +1761,50 @@ pub async fn stripe_webhook( } }; - if charge_status != ChargeStatus::Failed { - subscription.upsert(transaction).await?; + if charge_status != ChargeStatus::Failed { + subscription + .upsert(transaction) + .await?; + } + + Some(subscription) + } else { + break 'metadata; } - - Some(subscription) - } else { - break 'metadata; } + }; + + let charge = DBCharge { + id: charge_id, + user_id, + price_id, + amount, + currency_code: currency, + status: charge_status, + due: Utc::now(), + last_attempt: Some(Utc::now()), + type_: charge_type, + subscription_id: subscription + .as_ref() + .map(|x| x.id), + subscription_interval: subscription + .as_ref() + .map(|x| x.interval), + payment_platform: PaymentPlatform::Stripe, + payment_platform_id: Some( + payment_intent_id.to_string(), + ), + parent_charge_id: None, + net: None, + }; + + if charge_status != ChargeStatus::Failed { + charge.upsert(transaction).await?; } + + (charge, price, product, subscription, new_region) }; - let charge = DBCharge { - id: charge_id, - user_id, - price_id, - amount, - currency_code: currency, - status: charge_status, - due: Utc::now(), - last_attempt: Some(Utc::now()), - type_: charge_type, - subscription_id: subscription.as_ref().map(|x| x.id), - subscription_interval: subscription - .as_ref() - .map(|x| x.interval), - payment_platform: PaymentPlatform::Stripe, - payment_platform_id: Some( - payment_intent_id.to_string(), - ), - parent_charge_id: None, - net: None, - }; - - if charge_status != ChargeStatus::Failed { - charge.upsert(transaction).await?; - } - - (charge, price, product, subscription) - }; - return Ok(PaymentIntentMetadata { user_item: user, product_price_item: price, @@ -1651,6 +1812,7 @@ pub async fn stripe_webhook( charge_item: charge, user_subscription_item: subscription, payment_metadata, + new_region, }); } @@ -1701,6 +1863,13 @@ pub async fn stripe_webhook( // Provision subscription match metadata.product_item.metadata { + // A payment shouldn't be processed for Medal subscriptions. + ProductMetadata::Medal { .. } => { + warn!( + "A payment processed for a free subscription" + ); + } + ProductMetadata::Midas => { let badges = metadata.user_item.badges | Badges::MIDAS; @@ -1724,16 +1893,54 @@ pub async fn stripe_webhook( swap, storage, } => { - if let Some(ref subscription) = + if let Some(ref mut subscription) = metadata.user_subscription_item { let client = reqwest::Client::new(); - if let Some(SubscriptionMetadata::Pyro { - id, - region: _, - }) = &subscription.metadata + if let Some( + subscription_metadata @ ( + SubscriptionMetadata::Pyro { id, region: _ } + | SubscriptionMetadata::Medal { id } + ), + ) = &subscription.metadata { + let region = match subscription_metadata { + SubscriptionMetadata::Pyro { region, .. } => region.to_owned(), + SubscriptionMetadata::Medal { .. } => { + let region = metadata.new_region.clone(); + + if region.is_none() { + return Err(ApiError::InvalidInput( + "We attempted to promote a subscription with type=medal, which requires specifying \ + a new region to move the server to. However, no new region was present in the payment \ + intent metadata.".to_owned() + )); + } + + region + } + }; + + #[derive(Serialize)] + struct ReallocateBody<'a> { + memory_mb: u32, + cpu: u32, + swap_mb: u32, + storage_mb: u32, + region: Option<&'a str>, + force_move: Option, + } + + let body = ReallocateBody { + memory_mb: ram, + cpu, + swap_mb: swap, + storage_mb: storage, + force_move: region.is_some().then_some(true), + region: region.as_deref(), + }; + client .post(format!( "{}/modrinth/v0/servers/{}/unsuspend", @@ -1755,15 +1962,15 @@ pub async fn stripe_webhook( "X-Master-Key", dotenvy::var("PYRO_API_KEY")?, ) - .json(&serde_json::json!({ - "memory_mb": ram, - "cpu": cpu, - "swap_mb": swap, - "storage_mb": storage, - })) + .json(&body) .send() .await? .error_for_status()?; + + // As the subscription has been promoted, this is now a Pyro subscription. + // Ensure the metadata is properly updated. + subscription.metadata = Some(SubscriptionMetadata::Pyro { id: id.to_string(), region }); + } else { let (server_name, server_region, source) = if let Some( @@ -1830,6 +2037,7 @@ pub async fn stripe_webhook( "region": server_region, "source": source, "payment_interval": metadata.charge_item.subscription_interval.map(|x| match x { + PriceDuration::FiveDays => 1, PriceDuration::Monthly => 1, PriceDuration::Quarterly => 3, PriceDuration::Yearly => 12, @@ -1876,10 +2084,32 @@ pub async fn stripe_webhook( } }; + // If the next open charge is actually an expiring charge, + // this means the subscription was promoted from a temporary + // free subscription to a paid subscription. + // + // In this case, we need to modify this expiring charge to be the + // next charge of the subscription, turn it into a normal open charge. + // + // Otherwise, if there *is* an open charge, the subscription was upgraded + // and the just-processed payment was the proration charge. In this case, + // the existing open charge must be updated to reflect the new product's price. + // + // If there are no open charges, the just-processed payment was a recurring + // or initial subscription charge, and we need to create the next charge. if let Some(mut charge) = open_charge { - charge.price_id = metadata.product_price_item.id; - charge.amount = new_price as i64; - + if charge.status == ChargeStatus::Expiring { + charge.status = ChargeStatus::Open; + charge.due = Utc::now() + + subscription.interval.duration(); + charge.payment_platform = + PaymentPlatform::Stripe; + charge.last_attempt = None; + } else { + charge.price_id = + metadata.product_price_item.id; + charge.amount = new_price as i64; + } charge.upsert(&mut transaction).await?; } else if metadata.charge_item.status != ChargeStatus::Cancelled @@ -2100,7 +2330,11 @@ pub async fn index_subscriptions(pool: PgPool, redis: RedisPool) { let mut transaction = pool.begin().await?; let mut clear_cache_users = Vec::new(); - // If an active subscription has a canceled charge OR a failed charge more than two days ago, it should be cancelled + // If an active subscription has: + // - A canceled charge due now + // - An expiring charge due now + // - A failed charge more than two days ago + // It should be unprovisioned. let all_charges = DBCharge::get_unprovision(&pool).await?; let mut all_subscriptions = @@ -2196,33 +2430,37 @@ pub async fn index_subscriptions(pool: PgPool, redis: RedisPool) { true } - ProductMetadata::Pyro { .. } => { - if let Some(SubscriptionMetadata::Pyro { id, region: _ }) = - &subscription.metadata - { - let res = reqwest::Client::new() - .post(format!( - "{}/modrinth/v0/servers/{}/suspend", - dotenvy::var("ARCHON_URL")?, - id - )) - .header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?) - .json(&serde_json::json!({ - "reason": if charge.status == ChargeStatus::Cancelled { - "cancelled" - } else { - "paymentfailed" - } - })) - .send() - .await; - if let Err(e) = res { - warn!("Error suspending pyro server: {:?}", e); - false - } else { - true + ProductMetadata::Pyro { .. } + | ProductMetadata::Medal { .. } => 'server: { + let server_id = match &subscription.metadata { + Some(SubscriptionMetadata::Pyro { id, region: _ }) => { + id } + Some(SubscriptionMetadata::Medal { id }) => id, + _ => break 'server true, + }; + + let res = reqwest::Client::new() + .post(format!( + "{}/modrinth/v0/servers/{}/suspend", + dotenvy::var("ARCHON_URL")?, + server_id + )) + .header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?) + .json(&serde_json::json!({ + "reason": if charge.status == ChargeStatus::Cancelled || charge.status == ChargeStatus::Expiring { + "cancelled" + } else { + "paymentfailed" + } + })) + .send() + .await; + + if let Err(e) = res { + warn!("Error suspending pyro server: {:?}", e); + false } else { true } @@ -2247,6 +2485,20 @@ pub async fn index_subscriptions(pool: PgPool, redis: RedisPool) { .await?; transaction.commit().await?; + // If an offer redeemal has been processing for over 5 minutes, it should be set pending. + UserRedeemal::update_stuck_5_minutes(&pool).await?; + + // If an offer redeemal is pending, try processing it. + // Try processing it. + let pending_redeemals = UserRedeemal::get_pending(&pool, 100).await?; + for redeemal in pending_redeemals { + if let Err(error) = + try_process_user_redeemal(&pool, &redis, redeemal).await + { + warn!(%error, "Failed to process a redeemal.") + } + } + Ok::<(), ApiError>(()) }; @@ -2257,6 +2509,161 @@ pub async fn index_subscriptions(pool: PgPool, redis: RedisPool) { info!("Done indexing subscriptions"); } +/// Attempts to process a user redeemal. +/// +/// Returns `Ok` if the entry has been succesfully processed, or will not be processed. +pub async fn try_process_user_redeemal( + pool: &PgPool, + redis: &RedisPool, + mut user_redeemal: UserRedeemal, +) -> Result<(), ApiError> { + // Immediately update redeemal row + user_redeemal.last_attempt = Some(Utc::now()); + user_redeemal.n_attempts += 1; + user_redeemal.status = users_redeemals::Status::Processing; + let updated = user_redeemal.update_status_if_pending(pool).await?; + + if !updated { + return Ok(()); + } + + let user_id = user_redeemal.user_id; + + // Find the Medal product's price & metadata + + let mut medal_products = + product_item::QueryProductWithPrices::list_by_product_type( + pool, "medal", + ) + .await?; + + let Some(product_item::QueryProductWithPrices { + id: _product_id, + metadata, + mut prices, + unitary: _, + }) = medal_products.pop() + else { + return Err(ApiError::Conflict( + "Missing Medal subscription product".to_owned(), + )); + }; + + let ProductMetadata::Medal { + cpu, + ram, + swap, + storage, + region, + } = metadata + else { + return Err(ApiError::Conflict( + "Missing or incorrect metadata for Medal subscription".to_owned(), + )); + }; + + let Some(medal_price) = prices.pop() else { + return Err(ApiError::Conflict( + "Missing price for Medal subscription".to_owned(), + )); + }; + + let (price_duration, price_amount) = match medal_price.prices { + Price::OneTime { price: _ } => { + return Err(ApiError::Conflict( + "Unexpected metadata for Medal subscription price".to_owned(), + )); + } + + Price::Recurring { intervals } => { + let Some((price_duration, price_amount)) = + intervals.into_iter().next() + else { + return Err(ApiError::Conflict( + "Missing price interval for Medal subscription".to_owned(), + )); + }; + + (price_duration, price_amount) + } + }; + + let price_id = medal_price.id; + + // Get the user's username + + let user = DBUser::get_id(user_id, pool, redis) + .await? + .ok_or(ApiError::NotFound)?; + + // Send the provision request to Archon. On failure, the redeemal will be "stuck" processing, + // and moved back to pending by `index_subscriptions`. + + let archon_client = ArchonClient::from_env()?; + let server_id = archon_client + .create_server(&CreateServerRequest { + user_id: to_base62(user_id.0 as u64), + name: format!("{}'s Medal server", user.username), + specs: Specs { + memory_mb: ram, + cpu, + swap_mb: swap, + storage_mb: storage, + }, + source: crate::util::archon::Empty::default(), + region, + tags: vec!["medal".to_owned()], + }) + .await?; + + let mut txn = pool.begin().await?; + + // Build a subscription using this price ID. + let subscription = DBUserSubscription { + id: generate_user_subscription_id(&mut txn).await?, + user_id, + price_id, + interval: PriceDuration::FiveDays, + created: Utc::now(), + status: SubscriptionStatus::Provisioned, + metadata: Some(SubscriptionMetadata::Medal { + id: server_id.to_string(), + }), + }; + + subscription.upsert(&mut txn).await?; + + // Insert an expiring charge, `index_subscriptions` will unprovision the + // subscription when expired. + DBCharge { + id: generate_charge_id(&mut txn).await?, + user_id, + price_id, + amount: price_amount.into(), + currency_code: medal_price.currency_code, + status: ChargeStatus::Expiring, + due: Utc::now() + price_duration.duration(), + last_attempt: None, + type_: ChargeType::Subscription, + subscription_id: Some(subscription.id), + subscription_interval: Some(subscription.interval), + payment_platform: PaymentPlatform::None, + payment_platform_id: None, + parent_charge_id: None, + net: None, + } + .upsert(&mut txn) + .await?; + + // Update `users_redeemal`, mark subscription as redeemed. + user_redeemal.status = users_redeemals::Status::Processed; + user_redeemal.update(&mut *txn).await?; + + txn.commit().await?; + + Ok(()) +} + pub async fn index_billing( stripe_client: stripe::Client, pool: PgPool, diff --git a/apps/labrinth/src/routes/internal/medal.rs b/apps/labrinth/src/routes/internal/medal.rs new file mode 100644 index 00000000..4bc29e39 --- /dev/null +++ b/apps/labrinth/src/routes/internal/medal.rs @@ -0,0 +1,109 @@ +use actix_web::{HttpResponse, post, web}; +use ariadne::ids::UserId; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use tracing::warn; + +use crate::database::models::users_redeemals::{ + Offer, RedeemalLookupFields, Status, UserRedeemal, +}; +use crate::database::redis::RedisPool; +use crate::routes::ApiError; +use crate::routes::internal::billing::try_process_user_redeemal; +use crate::util::guards::medal_key_guard; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(web::scope("medal").service(verify).service(redeem)); +} + +#[derive(Deserialize)] +struct MedalQuery { + username: String, +} + +#[post("verify", guard = "medal_key_guard")] +pub async fn verify( + pool: web::Data, + web::Query(MedalQuery { username }): web::Query, +) -> Result { + let maybe_fields = + RedeemalLookupFields::redeemal_status_by_username_and_offer( + &**pool, + &username, + Offer::Medal, + ) + .await?; + + #[derive(Serialize)] + struct VerifyResponse { + user_id: UserId, + redeemed: bool, + } + + match maybe_fields { + None => Err(ApiError::NotFound), + Some(fields) => Ok(HttpResponse::Ok().json(VerifyResponse { + user_id: fields.user_id.into(), + redeemed: fields.redeemal_status.is_some(), + })), + } +} + +#[post("redeem", guard = "medal_key_guard")] +pub async fn redeem( + pool: web::Data, + redis: web::Data, + web::Query(MedalQuery { username }): web::Query, +) -> Result { + // Check the offer hasn't been redeemed yet, then insert into the table. + // In a transaction to avoid double inserts. + + let mut txn = pool.begin().await?; + + let maybe_fields = + RedeemalLookupFields::redeemal_status_by_username_and_offer( + &mut *txn, + &username, + Offer::Medal, + ) + .await?; + + let user_id = match maybe_fields { + None => return Err(ApiError::NotFound), + Some(fields) => { + if fields.redeemal_status.is_some() { + return Err(ApiError::Conflict( + "User already redeemed this offer".to_string(), + )); + } + + fields.user_id + } + }; + + // Link user to offer redeemal. + let mut redeemal = UserRedeemal { + id: 0, + user_id, + offer: Offer::Medal, + redeemed: Utc::now(), + status: Status::Pending, + last_attempt: None, + n_attempts: 0, + }; + + redeemal.insert(&mut *txn).await?; + + txn.commit().await?; + + // Immediately try to process the redeemal + if let Err(error) = try_process_user_redeemal(&pool, &redis, redeemal).await + { + warn!(%error, "Medal redeemal processing failed"); + + Ok(HttpResponse::Accepted().finish()) + } else { + Ok(HttpResponse::Created().finish()) + } +} diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs index a658a9c7..3330ab13 100644 --- a/apps/labrinth/src/routes/internal/mod.rs +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -2,6 +2,7 @@ pub(crate) mod admin; pub mod billing; pub mod flows; pub mod gdpr; +pub mod medal; pub mod moderation; pub mod pats; pub mod session; @@ -24,6 +25,7 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { .configure(moderation::config) .configure(billing::config) .configure(gdpr::config) - .configure(statuses::config), + .configure(statuses::config) + .configure(medal::config), ); } diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs index caa143f2..c637c79a 100644 --- a/apps/labrinth/src/routes/mod.rs +++ b/apps/labrinth/src/routes/mod.rs @@ -137,6 +137,8 @@ pub enum ApiError { Io(#[from] std::io::Error), #[error("Resource not found")] NotFound, + #[error("Conflict: {0}")] + Conflict(String), #[error( "You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining." )] @@ -172,6 +174,7 @@ impl ApiError { ApiError::Clickhouse(..) => "clickhouse_error", ApiError::Reroute(..) => "reroute_error", ApiError::NotFound => "not_found", + ApiError::Conflict(..) => "conflict", ApiError::Zip(..) => "zip_error", ApiError::Io(..) => "io_error", ApiError::RateLimitError(..) => "ratelimit_error", @@ -208,6 +211,7 @@ impl actix_web::ResponseError for ApiError { ApiError::Mail(..) => StatusCode::INTERNAL_SERVER_ERROR, ApiError::Reroute(..) => StatusCode::INTERNAL_SERVER_ERROR, ApiError::NotFound => StatusCode::NOT_FOUND, + ApiError::Conflict(..) => StatusCode::CONFLICT, ApiError::Zip(..) => StatusCode::BAD_REQUEST, ApiError::Io(..) => StatusCode::BAD_REQUEST, ApiError::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS, diff --git a/apps/labrinth/src/util/archon.rs b/apps/labrinth/src/util/archon.rs new file mode 100644 index 00000000..471faa7b --- /dev/null +++ b/apps/labrinth/src/util/archon.rs @@ -0,0 +1,75 @@ +use reqwest::header::HeaderName; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +use crate::routes::ApiError; + +const X_MASTER_KEY: HeaderName = HeaderName::from_static("x-master-key"); + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct Empty {} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Specs { + pub memory_mb: u32, + pub cpu: u32, + pub swap_mb: u32, + pub storage_mb: u32, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateServerRequest { + pub user_id: String, + pub name: String, + pub specs: Specs, + // Must be included because archon doesn't accept null values, only + // an empty struct, as a source. + pub source: Empty, + pub region: String, + pub tags: Vec, +} + +#[derive(Clone)] +pub struct ArchonClient { + client: reqwest::Client, + base_url: String, + pyro_api_key: String, +} + +impl ArchonClient { + /// Builds an Archon client from environment variables. Returns `None` if the + /// required environment variables are not set. + pub fn from_env() -> Result { + let client = reqwest::Client::new(); + + let base_url = + dotenvy::var("ARCHON_URL")?.trim_end_matches('/').to_owned(); + + Ok(Self { + client, + base_url, + pyro_api_key: dotenvy::var("PYRO_API_KEY")?, + }) + } + + pub async fn create_server( + &self, + request: &CreateServerRequest, + ) -> Result { + #[derive(Deserialize)] + struct CreateServerResponse { + uuid: Uuid, + } + + let response = self + .client + .post(format!("{}/modrinth/v0/servers/create", self.base_url)) + .header(X_MASTER_KEY, &self.pyro_api_key) + .json(request) + .send() + .await? + .error_for_status()?; + + Ok(response.json::().await?.uuid) + } +} diff --git a/apps/labrinth/src/util/guards.rs b/apps/labrinth/src/util/guards.rs index e6401fa4..d1fa513d 100644 --- a/apps/labrinth/src/util/guards.rs +++ b/apps/labrinth/src/util/guards.rs @@ -1,6 +1,8 @@ use actix_web::guard::GuardContext; pub const ADMIN_KEY_HEADER: &str = "Modrinth-Admin"; +pub const MEDAL_KEY_HEADER: &str = "X-Medal-Access-Key"; + pub fn admin_key_guard(ctx: &GuardContext) -> bool { let admin_key = std::env::var("LABRINTH_ADMIN_KEY").expect( "No admin key provided, this should have been caught by check_env_vars", @@ -10,3 +12,16 @@ pub fn admin_key_guard(ctx: &GuardContext) -> bool { .get(ADMIN_KEY_HEADER) .is_some_and(|it| it.as_bytes() == admin_key.as_bytes()) } + +pub fn medal_key_guard(ctx: &GuardContext) -> bool { + let maybe_medal_key = dotenvy::var("LABRINTH_MEDAL_KEY").ok(); + + match maybe_medal_key { + None => false, + Some(medal_key) => ctx + .head() + .headers() + .get(MEDAL_KEY_HEADER) + .is_some_and(|it| it.as_bytes() == medal_key.as_bytes()), + } +} diff --git a/apps/labrinth/src/util/mod.rs b/apps/labrinth/src/util/mod.rs index 94eefffd..fa9b16ff 100644 --- a/apps/labrinth/src/util/mod.rs +++ b/apps/labrinth/src/util/mod.rs @@ -1,4 +1,5 @@ pub mod actix; +pub mod archon; pub mod bitflag; pub mod captcha; pub mod cors; From b279c430695769d2e679e357249df379f3113c2c Mon Sep 17 00:00:00 2001 From: IMB11 Date: Wed, 13 Aug 2025 09:45:13 +0100 Subject: [PATCH 03/21] Author Validation Improvements (#4025) * feat: set up typed nag (validators) system * feat: start on frontend impl * fix: shouldShow issues * feat: continue work * feat: re add submitting/re-submit nags * feat: start work implementing validation checks using new nag system * fix: links page + add more validations * feat: tags validations * fix: lint issues * fix: lint * fix: issues * feat: start on i18nifying nags * feat: impl intl * fix: minecraft title clause update * fix: locale issues * refactor: inline i18n * fix: summary char min * fix: issues * Rephrase a few core nags * Modify character limit numbers * Remove redundant sentanceEnders check to reduce false positive. * Description nag rephrasing and tweaks * Tweak links nags adding project type checking for source publication check, make description nag tonally consistent. * fix: description nag * bump source publication nag to warn until additional files can be checked. * refactor link checking helper functions, prevent misuse of dsc links, prevent link shortener usage, check if source required licensed projects have additional files, bump this check back to required. * Correct plugin project type checking * fix: lint issues * update links.ts * feat: key + sort nags by type * Tweak core and description nag titles, change image accessability nag logic. * feat: update readme * updates to tags checking and rest of the nag titles * fix locale * fix: formatjs * fix tags warning, and link shorteners and misused discord warnings to link settings page, reword some warnings. * correct vocabulary for resolutions tags warning and sort tags list in resolution tags nag * lint fix * fix method typo * Add nag for summary formatting. * Check for link shorteners in donation links * add Gallery requirement nag for shaders and most resource packs * update index.json --------- Signed-off-by: IMB11 Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com> Co-authored-by: coolbot100s <76798835+coolbot100s@users.noreply.github.com> --- .../src/components/ui/ProjectMemberHeader.vue | 851 +++++++++--------- apps/frontend/src/locales/en-US/index.json | 72 ++ .../[type]/[id]/settings/description.vue | 18 +- .../src/pages/[type]/[id]/settings/index.vue | 26 +- .../src/pages/[type]/[id]/settings/links.vue | 104 ++- .../src/pages/[type]/[id]/settings/tags.vue | 325 ++++--- package.json | 1 + packages/moderation/README.md | 129 ++- packages/moderation/data/nags.ts | 7 + packages/moderation/data/nags/core.ts | 292 ++++++ packages/moderation/data/nags/description.ts | 381 ++++++++ packages/moderation/data/nags/index.ts | 4 + packages/moderation/data/nags/links.ts | 281 ++++++ packages/moderation/data/nags/tags.ts | 160 ++++ packages/moderation/index.ts | 3 + packages/moderation/locales/en-US/index.json | 215 +++++ packages/moderation/package.json | 7 +- packages/moderation/types/nags.ts | 96 ++ packages/utils/types.ts | 17 +- pnpm-lock.yaml | 48 +- 20 files changed, 2430 insertions(+), 607 deletions(-) create mode 100644 packages/moderation/data/nags.ts create mode 100644 packages/moderation/data/nags/core.ts create mode 100644 packages/moderation/data/nags/description.ts create mode 100644 packages/moderation/data/nags/index.ts create mode 100644 packages/moderation/data/nags/links.ts create mode 100644 packages/moderation/data/nags/tags.ts create mode 100644 packages/moderation/locales/en-US/index.json create mode 100644 packages/moderation/types/nags.ts diff --git a/apps/frontend/src/components/ui/ProjectMemberHeader.vue b/apps/frontend/src/components/ui/ProjectMemberHeader.vue index 9bef2085..352af56a 100644 --- a/apps/frontend/src/components/ui/ProjectMemberHeader.vue +++ b/apps/frontend/src/components/ui/ProjectMemberHeader.vue @@ -1,510 +1,481 @@ - diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json index 3a5bdd37..aa88ae7a 100644 --- a/apps/frontend/src/locales/en-US/index.json +++ b/apps/frontend/src/locales/en-US/index.json @@ -554,6 +554,78 @@ "profile.user-id": { "message": "User ID: {id}" }, + "project-member-header.accept": { + "message": "Accept" + }, + "project-member-header.action": { + "message": "Action" + }, + "project-member-header.decline": { + "message": "Decline" + }, + "project-member-header.error": { + "message": "Error" + }, + "project-member-header.error-decline": { + "message": "Failed to decline team invitation" + }, + "project-member-header.error-join": { + "message": "Failed to accept team invitation" + }, + "project-member-header.invitation-no-role": { + "message": "You've been invited to join this project. Please accept or decline the invitation." + }, + "project-member-header.invitation-title": { + "message": "Invitation to join project" + }, + "project-member-header.invitation-with-role": { + "message": "You've been invited be a member of this project with the role of '{role}'." + }, + "project-member-header.key-title": { + "message": "Status Key" + }, + "project-member-header.publishing-checklist": { + "message": "Publishing checklist" + }, + "project-member-header.required": { + "message": "Required" + }, + "project-member-header.resubmit-for-review": { + "message": "Resubmit for review" + }, + "project-member-header.resubmit-for-review-desc": { + "message": "Your project has been {status} by Modrinth's staff. In most cases, you can resubmit for review after addressing the staff's message." + }, + "project-member-header.show-key": { + "message": "Toggle key" + }, + "project-member-header.submit-checklist-tooltip": { + "message": "You must complete the required steps in the publishing checklist!" + }, + "project-member-header.submit-for-review": { + "message": "Submit for review" + }, + "project-member-header.submit-for-review-desc": { + "message": "Your project is only viewable by members of the project. It must be reviewed by moderators in order to be published." + }, + "project-member-header.success": { + "message": "Success" + }, + "project-member-header.success-decline": { + "message": "You have declined the team invitation" + }, + "project-member-header.success-join": { + "message": "You have joined the project team" + }, + "project-member-header.suggestion": { + "message": "Suggestion" + }, + "project-member-header.visit-moderation-page": { + "message": "Visit moderation page" + }, + "project-member-header.warning": { + "message": "Warning" + }, "project-type.collection.plural": { "message": "Collections" }, diff --git a/apps/frontend/src/pages/[type]/[id]/settings/description.vue b/apps/frontend/src/pages/[type]/[id]/settings/description.vue index 174d4bc9..94aa51ad 100644 --- a/apps/frontend/src/pages/[type]/[id]/settings/description.vue +++ b/apps/frontend/src/pages/[type]/[id]/settings/description.vue @@ -22,6 +22,10 @@ " :on-image-upload="onUploadHandler" /> +
+ + {{ descriptionWarning }} +
- @@ -73,106 +75,117 @@
- + diff --git a/packages/ui/src/providers/index.ts b/packages/ui/src/providers/index.ts new file mode 100644 index 00000000..c8859edc --- /dev/null +++ b/packages/ui/src/providers/index.ts @@ -0,0 +1,81 @@ +/** + * MIT License + * + * Copyright (c) 2023 UnoVue + * + * Permission is hereby granted, free of charge, to any person obtaining a copy + * of this software and associated documentation files (the "Software"), to deal + * in the Software without restriction, including without limitation the rights + * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + * copies of the Software, and to permit persons to whom the Software is + * furnished to do so, subject to the following conditions: + * + * The above copyright notice and this permission notice shall be included in all + * copies or substantial portions of the Software. + * + * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + * SOFTWARE. + * + * @source https://github.com/unovue/reka-ui/blob/53b4734734f8ebef9a344b1e62db291177c59bfe/packages/core/src/shared/createContext.ts + */ + +import type { InjectionKey } from 'vue' +import { inject, provide } from 'vue' + +/** + * @param providerComponentName - The name(s) of the component(s) providing the context. + * + * There are situations where context can come from multiple components. In such cases, you might need to give an array of component names to provide your context, instead of just a single string. + * + * @param contextName The description for injection key symbol. + */ +export function createContext( + providerComponentName: string | string[], + contextName?: string, +) { + const symbolDescription = + typeof providerComponentName === 'string' && !contextName + ? `${providerComponentName}Context` + : contextName + + const injectionKey: InjectionKey = Symbol(symbolDescription) + + /** + * @param fallback The context value to return if the injection fails. + * + * @throws When context injection failed and no fallback is specified. + * This happens when the component injecting the context is not a child of the root component providing the context. + */ + const injectContext = ( + fallback?: T, + ): T extends null ? ContextValue | null : ContextValue => { + const context = inject(injectionKey, fallback) + if (context) return context + + if (context === null) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return context as any + + throw new Error( + `Injection \`${injectionKey.toString()}\` not found. Component must be used within ${ + Array.isArray(providerComponentName) + ? `one of the following components: ${providerComponentName.join(', ')}` + : `\`${providerComponentName}\`` + }`, + ) + } + + const provideContext = (contextValue: ContextValue) => { + provide(injectionKey, contextValue) + return contextValue + } + + return [injectContext, provideContext] as const +} + +export * from './web-notifications' diff --git a/packages/ui/src/providers/web-notifications.ts b/packages/ui/src/providers/web-notifications.ts new file mode 100644 index 00000000..c56507b0 --- /dev/null +++ b/packages/ui/src/providers/web-notifications.ts @@ -0,0 +1,133 @@ +import { createContext } from '.' + +export interface WebNotification { + id: string | number + title?: string + text?: string + type?: 'error' | 'warning' | 'success' | 'info' + errorCode?: string + count?: number + timer?: NodeJS.Timeout +} + +export type NotificationPanelLocation = 'left' | 'right' + +export abstract class AbstractWebNotificationManager { + protected readonly AUTO_DISMISS_DELAY_MS = 30 * 1000 + + abstract getNotifications(): WebNotification[] + abstract getNotificationLocation(): NotificationPanelLocation + abstract setNotificationLocation(location: NotificationPanelLocation): void + + protected abstract addNotificationToStorage(notification: WebNotification): void + protected abstract removeNotificationFromStorage(id: string | number): void + protected abstract removeNotificationFromStorageByIndex(index: number): void + protected abstract clearAllNotificationsFromStorage(): void + + addNotification = (notification: Partial): WebNotification => { + const existingNotif = this.findExistingNotification(notification) + + if (existingNotif) { + this.refreshNotificationTimer(existingNotif) + existingNotif.count = (existingNotif.count || 0) + 1 + return existingNotif + } + + const newNotification = this.createNotification(notification) + this.setNotificationTimer(newNotification) + this.addNotificationToStorage(newNotification) + return newNotification + } + + /** + * @deprecated You should use `addNotification` instead to provide a more human-readable error message to the user. + */ + handleError = (error: Error): void => { + this.addNotification({ + title: 'An error occurred', + text: error.message ?? error, + type: 'error', + }) + } + + removeNotification = (id: string | number): WebNotification | undefined => { + const notifications = this.getNotifications() + const notification = notifications.find((n) => n.id === id) + + if (notification) { + this.clearNotificationTimer(notification) + this.removeNotificationFromStorage(id) + } + + return notification + } + + removeNotificationByIndex = (index: number): WebNotification | null => { + const notifications = this.getNotifications() + + if (index >= 0 && index < notifications.length) { + const notification = notifications[index] + this.clearNotificationTimer(notification) + this.removeNotificationFromStorageByIndex(index) + + return notification + } + + return null + } + + clearAllNotifications = (): void => { + const notifications = this.getNotifications() + notifications.forEach((notification) => { + this.clearNotificationTimer(notification) + }) + this.clearAllNotificationsFromStorage() + } + + setNotificationTimer = (notification: WebNotification): void => { + if (!notification) return + + this.clearNotificationTimer(notification) + + notification.timer = setTimeout(() => { + this.removeNotification(notification.id) + }, this.AUTO_DISMISS_DELAY_MS) + } + + stopNotificationTimer = (notification: WebNotification): void => { + this.clearNotificationTimer(notification) + } + + private refreshNotificationTimer(notification: WebNotification): void { + this.setNotificationTimer(notification) + } + + private clearNotificationTimer(notification: WebNotification): void { + if (notification.timer) { + clearTimeout(notification.timer) + notification.timer = undefined + } + } + + private findExistingNotification( + notification: Partial, + ): WebNotification | undefined { + return this.getNotifications().find( + (existing) => + existing.text === notification.text && + existing.title === notification.title && + existing.type === notification.type, + ) + } + + private createNotification(notification: Partial): WebNotification { + return { + ...notification, + id: new Date().getTime(), + count: 1, + } as WebNotification + } +} + +export const [injectNotificationManager, provideNotificationManager] = + createContext('root', 'notificationManager') diff --git a/packages/utils/types.ts b/packages/utils/types.ts index bc3f33d9..ede87cab 100644 --- a/packages/utils/types.ts +++ b/packages/utils/types.ts @@ -49,6 +49,76 @@ export interface GalleryImage { description?: string } +export interface ProjectV3 { + id: ModrinthId + slug?: string + project_types: string[] + games: string[] + team_id: ModrinthId + organization?: ModrinthId + name: string + summary: string + description: string + + published: string + updated: string + approved?: string + queued?: string + + status: ProjectStatus + requested_status?: ProjectStatus + + /** @deprecated moved to threads system */ + moderator_message?: { + message: string + body?: string + } + + license: { + id: string + name: string + url?: string + } + + downloads: number + followers: number + + categories: string[] + additional_categories: string[] + loaders: string[] + + versions: ModrinthId[] + icon_url?: string + + link_urls: Record< + string, + { + platform: string + donation: boolean + url: string + } + > + + gallery: { + url: string + raw_url: string + featured: boolean + name?: string + description?: string + created: string + ordering: number + }[] + + color?: number + thread_id: ModrinthId + monetization_status: MonetizationStatus + side_types_migration_review_status: 'reviewed' | 'pending' + + [key: string]: unknown +} + +export type SideTypesMigrationReviewStatus = 'reviewed' | 'pending' + export interface Project { id: ModrinthId project_type: ProjectType From 5ffcc48d75a94f93449e96b7d7ee65f2ef07eca8 Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Wed, 13 Aug 2025 16:28:44 -0700 Subject: [PATCH 06/21] Implement a more robust IPC system between the launcher and client (#4159) * Implement a more robust IPC system between the launcher and client * Clippy fix and cargo fmt * Switch to cached JsonReader with LENIENT parsing to avoid race conditions * Make RPC send messages in lines * Try to bind to either IPv4 or IPv6 and communicate version * Move message handling into a separate function to avoid too much code in a macro --- .../src/api/oauth_utils/auth_code_reply.rs | 20 +- packages/app-lib/build.rs | 1 - packages/app-lib/java/build.gradle.kts | 1 + .../com/modrinth/theseus/MinecraftLaunch.java | 48 +--- .../com/modrinth/theseus/rpc/RpcHandlers.java | 46 ++++ .../theseus/rpc/RpcMethodException.java | 9 + .../com/modrinth/theseus/rpc/TheseusRpc.java | 183 +++++++++++++ packages/app-lib/src/api/mod.rs | 5 +- packages/app-lib/src/error.rs | 3 + packages/app-lib/src/launcher/args.rs | 7 + packages/app-lib/src/launcher/mod.rs | 19 +- packages/app-lib/src/state/process.rs | 19 +- packages/app-lib/src/util/mod.rs | 2 + packages/app-lib/src/util/network.rs | 17 ++ packages/app-lib/src/util/rpc.rs | 258 ++++++++++++++++++ 15 files changed, 568 insertions(+), 70 deletions(-) create mode 100644 packages/app-lib/java/src/main/java/com/modrinth/theseus/rpc/RpcHandlers.java create mode 100644 packages/app-lib/java/src/main/java/com/modrinth/theseus/rpc/RpcMethodException.java create mode 100644 packages/app-lib/java/src/main/java/com/modrinth/theseus/rpc/TheseusRpc.java create mode 100644 packages/app-lib/src/util/network.rs create mode 100644 packages/app-lib/src/util/rpc.rs diff --git a/apps/app/src/api/oauth_utils/auth_code_reply.rs b/apps/app/src/api/oauth_utils/auth_code_reply.rs index 4e4a5292..fedffcb0 100644 --- a/apps/app/src/api/oauth_utils/auth_code_reply.rs +++ b/apps/app/src/api/oauth_utils/auth_code_reply.rs @@ -11,7 +11,7 @@ //! [RFC 8252]: https://datatracker.ietf.org/doc/html/rfc8252 use std::{ - net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}, + net::SocketAddr, sync::{LazyLock, Mutex}, time::Duration, }; @@ -19,10 +19,8 @@ use std::{ use hyper::body::Incoming; use hyper_util::rt::{TokioIo, TokioTimer}; use theseus::ErrorKind; -use tokio::{ - net::TcpListener, - sync::{broadcast, oneshot}, -}; +use theseus::prelude::tcp_listen_any_loopback; +use tokio::sync::{broadcast, oneshot}; static SERVER_SHUTDOWN: LazyLock> = LazyLock::new(|| broadcast::channel(1024).0); @@ -35,17 +33,7 @@ static SERVER_SHUTDOWN: LazyLock> = pub async fn listen( listen_socket_tx: oneshot::Sender>, ) -> Result, theseus::Error> { - // IPv4 is tried first for the best compatibility and performance with most systems. - // IPv6 is also tried in case IPv4 is not available. Resolving "localhost" is avoided - // to prevent failures deriving from improper name resolution setup. Any available - // ephemeral port is used to prevent conflicts with other services. This is all as per - // RFC 8252's recommendations - const ANY_LOOPBACK_SOCKET: &[SocketAddr] = &[ - SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0), - SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0), - ]; - - let listener = match TcpListener::bind(ANY_LOOPBACK_SOCKET).await { + let listener = match tcp_listen_any_loopback().await { Ok(listener) => { listen_socket_tx .send(listener.local_addr().map_err(|e| { diff --git a/packages/app-lib/build.rs b/packages/app-lib/build.rs index 48da4b45..10ed29b9 100644 --- a/packages/app-lib/build.rs +++ b/packages/app-lib/build.rs @@ -53,7 +53,6 @@ fn build_java_jars() { .arg("build") .arg("--no-daemon") .arg("--console=rich") - .arg("--info") .current_dir(dunce::canonicalize("java").unwrap()) .status() .expect("Failed to wait on Gradle build"); diff --git a/packages/app-lib/java/build.gradle.kts b/packages/app-lib/java/build.gradle.kts index a671dd6f..98c95c8c 100644 --- a/packages/app-lib/java/build.gradle.kts +++ b/packages/app-lib/java/build.gradle.kts @@ -11,6 +11,7 @@ repositories { dependencies { implementation("org.ow2.asm:asm:9.8") implementation("org.ow2.asm:asm-tree:9.8") + implementation("com.google.code.gson:gson:2.13.1") testImplementation(libs.junit.jupiter) testRuntimeOnly("org.junit.platform:junit-platform-launcher") diff --git a/packages/app-lib/java/src/main/java/com/modrinth/theseus/MinecraftLaunch.java b/packages/app-lib/java/src/main/java/com/modrinth/theseus/MinecraftLaunch.java index 9d61a0c0..b474ba02 100644 --- a/packages/app-lib/java/src/main/java/com/modrinth/theseus/MinecraftLaunch.java +++ b/packages/app-lib/java/src/main/java/com/modrinth/theseus/MinecraftLaunch.java @@ -1,11 +1,13 @@ package com.modrinth.theseus; -import java.io.ByteArrayOutputStream; +import com.modrinth.theseus.rpc.RpcHandlers; +import com.modrinth.theseus.rpc.TheseusRpc; import java.io.IOException; import java.lang.reflect.AccessibleObject; import java.lang.reflect.Method; import java.lang.reflect.Modifier; import java.util.Arrays; +import java.util.concurrent.CompletableFuture; public final class MinecraftLaunch { public static void main(String[] args) throws IOException, ReflectiveOperationException { @@ -13,45 +15,19 @@ public final class MinecraftLaunch { final String[] gameArgs = Arrays.copyOfRange(args, 1, args.length); System.setProperty("modrinth.process.args", String.join("\u001f", gameArgs)); - parseInput(); + final CompletableFuture waitForLaunch = new CompletableFuture<>(); + TheseusRpc.connectAndStart( + System.getProperty("modrinth.internal.ipc.host"), + Integer.getInteger("modrinth.internal.ipc.port"), + new RpcHandlers() + .handler("set_system_property", String.class, String.class, System::setProperty) + .handler("launch", () -> waitForLaunch.complete(null))); + + waitForLaunch.join(); relaunch(mainClass, gameArgs); } - private static void parseInput() throws IOException { - final ByteArrayOutputStream line = new ByteArrayOutputStream(); - while (true) { - final int b = System.in.read(); - if (b < 0) { - throw new IllegalStateException("Stdin terminated while parsing"); - } - if (b != '\n') { - line.write(b); - continue; - } - if (handleLine(line.toString("UTF-8"))) { - break; - } - line.reset(); - } - } - - private static boolean handleLine(String line) { - final String[] parts = line.split("\t", 2); - switch (parts[0]) { - case "property": { - final String[] keyValue = parts[1].split("\t", 2); - System.setProperty(keyValue[0], keyValue[1]); - return false; - } - case "launch": - return true; - } - - System.err.println("Unknown input line " + line); - return false; - } - private static void relaunch(String mainClassName, String[] args) throws ReflectiveOperationException { final int javaVersion = getJavaVersion(); final Class mainClass = Class.forName(mainClassName); diff --git a/packages/app-lib/java/src/main/java/com/modrinth/theseus/rpc/RpcHandlers.java b/packages/app-lib/java/src/main/java/com/modrinth/theseus/rpc/RpcHandlers.java new file mode 100644 index 00000000..257148ef --- /dev/null +++ b/packages/app-lib/java/src/main/java/com/modrinth/theseus/rpc/RpcHandlers.java @@ -0,0 +1,46 @@ +package com.modrinth.theseus.rpc; + +import com.google.gson.JsonElement; +import com.google.gson.JsonNull; +import java.util.HashMap; +import java.util.Map; +import java.util.function.BiConsumer; +import java.util.function.Function; + +public class RpcHandlers { + private final Map> handlers = new HashMap<>(); + private boolean frozen; + + public RpcHandlers handler(String functionName, Runnable handler) { + return addHandler(functionName, args -> { + handler.run(); + return JsonNull.INSTANCE; + }); + } + + public RpcHandlers handler( + String functionName, Class arg1Type, Class arg2Type, BiConsumer handler) { + return addHandler(functionName, args -> { + if (args.length != 2) { + throw new IllegalArgumentException(functionName + " expected 2 arguments"); + } + final A arg1 = TheseusRpc.GSON.fromJson(args[0], arg1Type); + final B arg2 = TheseusRpc.GSON.fromJson(args[1], arg2Type); + handler.accept(arg1, arg2); + return JsonNull.INSTANCE; + }); + } + + private RpcHandlers addHandler(String functionName, Function handler) { + if (frozen) { + throw new IllegalStateException("Cannot add handler to frozen RpcHandlers instance"); + } + handlers.put(functionName, handler); + return this; + } + + Map> build() { + frozen = true; + return handlers; + } +} diff --git a/packages/app-lib/java/src/main/java/com/modrinth/theseus/rpc/RpcMethodException.java b/packages/app-lib/java/src/main/java/com/modrinth/theseus/rpc/RpcMethodException.java new file mode 100644 index 00000000..f9ab75a3 --- /dev/null +++ b/packages/app-lib/java/src/main/java/com/modrinth/theseus/rpc/RpcMethodException.java @@ -0,0 +1,9 @@ +package com.modrinth.theseus.rpc; + +public class RpcMethodException extends RuntimeException { + private static final long serialVersionUID = 1922360184188807964L; + + public RpcMethodException(String message) { + super(message); + } +} diff --git a/packages/app-lib/java/src/main/java/com/modrinth/theseus/rpc/TheseusRpc.java b/packages/app-lib/java/src/main/java/com/modrinth/theseus/rpc/TheseusRpc.java new file mode 100644 index 00000000..ff460ff8 --- /dev/null +++ b/packages/app-lib/java/src/main/java/com/modrinth/theseus/rpc/TheseusRpc.java @@ -0,0 +1,183 @@ +package com.modrinth.theseus.rpc; + +import com.google.gson.*; +import com.google.gson.reflect.TypeToken; +import java.io.*; +import java.net.Socket; +import java.nio.charset.StandardCharsets; +import java.util.Map; +import java.util.UUID; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.LinkedBlockingQueue; +import java.util.concurrent.atomic.AtomicReference; +import java.util.function.Function; + +public final class TheseusRpc { + static final Gson GSON = new GsonBuilder() + .setStrictness(Strictness.STRICT) + .setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES) + .disableHtmlEscaping() + .create(); + private static final TypeToken MESSAGE_TYPE = TypeToken.get(RpcMessage.class); + + private static final AtomicReference RPC = new AtomicReference<>(); + + private final BlockingQueue mainThreadQueue = new LinkedBlockingQueue<>(); + private final Map> awaitingResponse = new ConcurrentHashMap<>(); + private final Map> handlers; + private final Socket socket; + + private TheseusRpc(Socket socket, RpcHandlers handlers) { + this.socket = socket; + this.handlers = handlers.build(); + } + + public static void connectAndStart(String host, int port, RpcHandlers handlers) throws IOException { + if (RPC.get() != null) { + throw new IllegalStateException("Can only connect to RPC once"); + } + + final Socket socket = new Socket(host, port); + final TheseusRpc rpc = new TheseusRpc(socket, handlers); + final Thread mainThread = new Thread(rpc::mainThread, "Theseus RPC Main"); + final Thread readThread = new Thread(rpc::readThread, "Theseus RPC Read"); + mainThread.setDaemon(true); + readThread.setDaemon(true); + mainThread.start(); + readThread.start(); + RPC.set(rpc); + } + + public static TheseusRpc getRpc() { + final TheseusRpc rpc = RPC.get(); + if (rpc == null) { + throw new IllegalStateException("Called getRpc before RPC initialized"); + } + return rpc; + } + + public CompletableFuture callMethod(TypeToken returnType, String method, Object... args) { + final JsonElement[] jsonArgs = new JsonElement[args.length]; + for (int i = 0; i < args.length; i++) { + jsonArgs[i] = GSON.toJsonTree(args[i]); + } + + final RpcMessage message = new RpcMessage(method, jsonArgs); + final ResponseWaiter responseWaiter = new ResponseWaiter<>(returnType); + awaitingResponse.put(message.id, responseWaiter); + mainThreadQueue.add(message); + return responseWaiter.future; + } + + private void mainThread() { + try { + final Writer writer = new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8); + while (true) { + final RpcMessage message = mainThreadQueue.take(); + final RpcMessage toSend; + if (message.isForSending) { + toSend = message; + } else { + final Function handler = handlers.get(message.method); + if (handler == null) { + System.err.println("Unknown theseus RPC method " + message.method); + continue; + } + RpcMessage response; + try { + response = new RpcMessage(message.id, handler.apply(message.args)); + } catch (Exception e) { + response = new RpcMessage(message.id, e.toString()); + } + toSend = response; + } + GSON.toJson(toSend, writer); + writer.write('\n'); + writer.flush(); + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } catch (InterruptedException ignored) { + } + } + + private void readThread() { + try { + final BufferedReader reader = + new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8)); + while (true) { + final RpcMessage message = GSON.fromJson(reader.readLine(), MESSAGE_TYPE); + if (message.method == null) { + final ResponseWaiter waiter = awaitingResponse.get(message.id); + if (waiter != null) { + handleResponse(waiter, message); + } + } else { + mainThreadQueue.put(message); + } + } + } catch (IOException e) { + throw new UncheckedIOException(e); + } catch (InterruptedException ignored) { + } + } + + private void handleResponse(ResponseWaiter waiter, RpcMessage message) { + if (message.error != null) { + waiter.future.completeExceptionally(new RpcMethodException(message.error)); + return; + } + try { + waiter.future.complete(GSON.fromJson(message.response, waiter.type)); + } catch (JsonSyntaxException e) { + waiter.future.completeExceptionally(e); + } + } + + private static class RpcMessage { + final UUID id; + final String method; // Optional + final JsonElement[] args; // Optional + final JsonElement response; // Optional + final String error; // Optional + final transient boolean isForSending; + + RpcMessage(String method, JsonElement[] args) { + id = UUID.randomUUID(); + this.method = method; + this.args = args; + response = null; + error = null; + isForSending = true; + } + + RpcMessage(UUID id, JsonElement response) { + this.id = id; + method = null; + args = null; + this.response = response; + error = null; + isForSending = true; + } + + RpcMessage(UUID id, String error) { + this.id = id; + method = null; + args = null; + response = null; + this.error = error; + isForSending = true; + } + } + + private static class ResponseWaiter { + final TypeToken type; + final CompletableFuture future = new CompletableFuture<>(); + + ResponseWaiter(TypeToken type) { + this.type = type; + } + } +} diff --git a/packages/app-lib/src/api/mod.rs b/packages/app-lib/src/api/mod.rs index b173d035..020afbe4 100644 --- a/packages/app-lib/src/api/mod.rs +++ b/packages/app-lib/src/api/mod.rs @@ -35,6 +35,9 @@ pub mod prelude { jre, metadata, minecraft_auth, mr_auth, pack, process, profile::{self, Profile, create}, settings, - util::io::{IOError, canonicalize}, + util::{ + io::{IOError, canonicalize}, + network::tcp_listen_any_loopback, + }, }; } diff --git a/packages/app-lib/src/error.rs b/packages/app-lib/src/error.rs index 75c144f5..773d55da 100644 --- a/packages/app-lib/src/error.rs +++ b/packages/app-lib/src/error.rs @@ -151,6 +151,9 @@ pub enum ErrorKind { "A skin texture must have a dimension of either 64x64 or 64x32 pixels" )] InvalidSkinTexture, + + #[error("RPC error: {0}")] + RpcError(String), } #[derive(Debug)] diff --git a/packages/app-lib/src/launcher/args.rs b/packages/app-lib/src/launcher/args.rs index 350d67c0..e2093f61 100644 --- a/packages/app-lib/src/launcher/args.rs +++ b/packages/app-lib/src/launcher/args.rs @@ -16,6 +16,7 @@ use daedalus::{ use dunce::canonicalize; use hashlink::LinkedHashSet; use std::io::{BufRead, BufReader}; +use std::net::SocketAddr; use std::{collections::HashMap, path::Path}; use uuid::Uuid; @@ -124,6 +125,7 @@ pub fn get_jvm_arguments( quick_play_type: &QuickPlayType, quick_play_version: QuickPlayVersion, log_config: Option<&LoggingConfiguration>, + ipc_addr: SocketAddr, ) -> crate::Result> { let mut parsed_arguments = Vec::new(); @@ -181,6 +183,11 @@ pub fn get_jvm_arguments( .to_string_lossy() )); + parsed_arguments + .push(format!("-Dmodrinth.internal.ipc.host={}", ipc_addr.ip())); + parsed_arguments + .push(format!("-Dmodrinth.internal.ipc.port={}", ipc_addr.port())); + parsed_arguments.push(format!( "-Dmodrinth.internal.quickPlay.serverVersion={}", serde_json::to_value(quick_play_version.server)? diff --git a/packages/app-lib/src/launcher/mod.rs b/packages/app-lib/src/launcher/mod.rs index 64eb1d90..1b7a7d7e 100644 --- a/packages/app-lib/src/launcher/mod.rs +++ b/packages/app-lib/src/launcher/mod.rs @@ -12,6 +12,7 @@ use crate::state::{ Credentials, JavaVersion, ProcessMetadata, ProfileInstallStage, }; use crate::util::io; +use crate::util::rpc::RpcServerBuilder; use crate::{State, get_resource_file, process, state as st}; use chrono::Utc; use daedalus as d; @@ -22,7 +23,6 @@ use serde::Deserialize; use st::Profile; use std::fmt::Write; use std::path::PathBuf; -use tokio::io::AsyncWriteExt; use tokio::process::Command; mod args; @@ -608,6 +608,8 @@ pub async fn launch_minecraft( let (main_class_keep_alive, main_class_path) = get_resource_file!(env "JAVA_JARS_DIR" / "theseus.jar")?; + let rpc_server = RpcServerBuilder::new().launch().await?; + command.args( args::get_jvm_arguments( args.get(&d::minecraft::ArgumentType::Jvm) @@ -633,6 +635,7 @@ pub async fn launch_minecraft( .logging .as_ref() .and_then(|x| x.get(&LoggingSide::Client)), + rpc_server.address(), )? .into_iter(), ); @@ -767,7 +770,8 @@ pub async fn launch_minecraft( state.directories.profile_logs_dir(&profile.path), version_info.logging.is_some(), main_class_keep_alive, - async |process: &ProcessMetadata, stdin| { + rpc_server, + async |process: &ProcessMetadata, rpc_server| { let process_start_time = process.start_time.to_rfc3339(); let profile_created_time = profile.created.to_rfc3339(); let profile_modified_time = profile.modified.to_rfc3339(); @@ -790,14 +794,11 @@ pub async fn launch_minecraft( let Some(value) = value else { continue; }; - stdin.write_all(b"property\t").await?; - stdin.write_all(key.as_bytes()).await?; - stdin.write_u8(b'\t').await?; - stdin.write_all(value.as_bytes()).await?; - stdin.write_u8(b'\n').await?; + rpc_server + .call_method_2::<()>("set_system_property", key, value) + .await?; } - stdin.write_all(b"launch\n").await?; - stdin.flush().await?; + rpc_server.call_method::<()>("launch").await?; Ok(()) }, ) diff --git a/packages/app-lib/src/state/process.rs b/packages/app-lib/src/state/process.rs index faf1c9b4..4cff0a33 100644 --- a/packages/app-lib/src/state/process.rs +++ b/packages/app-lib/src/state/process.rs @@ -2,6 +2,7 @@ use crate::event::emit::{emit_process, emit_profile}; use crate::event::{ProcessPayloadType, ProfilePayloadType}; use crate::profile; use crate::util::io::IOError; +use crate::util::rpc::RpcServer; use chrono::{DateTime, NaiveDateTime, TimeZone, Utc}; use dashmap::DashMap; use quick_xml::Reader; @@ -15,7 +16,7 @@ use std::path::{Path, PathBuf}; use std::process::ExitStatus; use tempfile::TempDir; use tokio::io::{AsyncBufReadExt, BufReader}; -use tokio::process::{Child, ChildStdin, Command}; +use tokio::process::{Child, Command}; use uuid::Uuid; const LAUNCHER_LOG_PATH: &str = "launcher_log.txt"; @@ -46,9 +47,10 @@ impl ProcessManager { logs_folder: PathBuf, xml_logging: bool, main_class_keep_alive: TempDir, + rpc_server: RpcServer, post_process_init: impl AsyncFnOnce( &ProcessMetadata, - &mut ChildStdin, + &RpcServer, ) -> crate::Result<()>, ) -> crate::Result { mc_command.stdout(std::process::Stdio::piped()); @@ -67,14 +69,12 @@ impl ProcessManager { profile_path: profile_path.to_string(), }, child: mc_proc, + rpc_server, _main_class_keep_alive: main_class_keep_alive, }; - if let Err(e) = post_process_init( - &process.metadata, - &mut process.child.stdin.as_mut().unwrap(), - ) - .await + if let Err(e) = + post_process_init(&process.metadata, &process.rpc_server).await { tracing::error!("Failed to run post-process init: {e}"); let _ = process.child.kill().await; @@ -165,6 +165,10 @@ impl ProcessManager { self.processes.get(&id).map(|x| x.metadata.clone()) } + pub fn get_rpc(&self, id: Uuid) -> Option { + self.processes.get(&id).map(|x| x.rpc_server.clone()) + } + pub fn get_all(&self) -> Vec { self.processes .iter() @@ -215,6 +219,7 @@ struct Process { metadata: ProcessMetadata, child: Child, _main_class_keep_alive: TempDir, + rpc_server: RpcServer, } #[derive(Debug, Default)] diff --git a/packages/app-lib/src/util/mod.rs b/packages/app-lib/src/util/mod.rs index 67c5ede1..7656b4a0 100644 --- a/packages/app-lib/src/util/mod.rs +++ b/packages/app-lib/src/util/mod.rs @@ -2,6 +2,8 @@ pub mod fetch; pub mod io; pub mod jre; +pub mod network; pub mod platform; pub mod protocol_version; +pub mod rpc; pub mod server_ping; diff --git a/packages/app-lib/src/util/network.rs b/packages/app-lib/src/util/network.rs new file mode 100644 index 00000000..2837516c --- /dev/null +++ b/packages/app-lib/src/util/network.rs @@ -0,0 +1,17 @@ +use std::io; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; +use tokio::net::TcpListener; + +pub async fn tcp_listen_any_loopback() -> io::Result { + // IPv4 is tried first for the best compatibility and performance with most systems. + // IPv6 is also tried in case IPv4 is not available. Resolving "localhost" is avoided + // to prevent failures deriving from improper name resolution setup. Any available + // ephemeral port is used to prevent conflicts with other services. This is all as per + // RFC 8252's recommendations + const ANY_LOOPBACK_SOCKET: &[SocketAddr] = &[ + SocketAddr::new(IpAddr::V4(Ipv4Addr::LOCALHOST), 0), + SocketAddr::new(IpAddr::V6(Ipv6Addr::LOCALHOST), 0), + ]; + + TcpListener::bind(ANY_LOOPBACK_SOCKET).await +} diff --git a/packages/app-lib/src/util/rpc.rs b/packages/app-lib/src/util/rpc.rs new file mode 100644 index 00000000..d6902bd8 --- /dev/null +++ b/packages/app-lib/src/util/rpc.rs @@ -0,0 +1,258 @@ +use crate::prelude::tcp_listen_any_loopback; +use crate::{ErrorKind, Result}; +use futures::{SinkExt, StreamExt}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::collections::HashMap; +use std::net::SocketAddr; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use tokio::net::TcpListener; +use tokio::sync::{mpsc, oneshot}; +use tokio::task::AbortHandle; +use tokio_util::codec::{Decoder, LinesCodec, LinesCodecError}; +use uuid::Uuid; + +type HandlerFuture = Pin>>>; +type HandlerMethod = Box) -> HandlerFuture>; +type HandlerMap = HashMap<&'static str, HandlerMethod>; +type WaitingResponsesMap = + Arc>>>>; + +pub struct RpcServerBuilder { + handlers: HandlerMap, +} + +impl RpcServerBuilder { + pub fn new() -> Self { + Self { + handlers: HashMap::new(), + } + } + + // We'll use this function in the future. Please remove this #[allow] when we do. + #[allow(dead_code)] + pub fn handler( + mut self, + function_name: &'static str, + handler: HandlerMethod, + ) -> Self { + self.handlers.insert(function_name, Box::new(handler)); + self + } + + pub async fn launch(self) -> Result { + let socket = tcp_listen_any_loopback().await?; + let address = socket.local_addr()?; + let (message_sender, message_receiver) = mpsc::unbounded_channel(); + let waiting_responses = Arc::new(Mutex::new(HashMap::new())); + + let join_handle = { + let waiting_responses = waiting_responses.clone(); + tokio::spawn(async move { + let mut server = RunningRpcServer { + message_receiver, + handlers: self.handlers, + waiting_responses: waiting_responses.clone(), + }; + if let Err(e) = server.run(socket).await { + tracing::error!("Failed to run RPC server: {e}"); + } + waiting_responses.lock().unwrap().clear(); + }) + }; + Ok(RpcServer { + address, + message_sender, + waiting_responses, + abort_handle: join_handle.abort_handle(), + }) + } +} + +#[derive(Debug, Clone)] +pub struct RpcServer { + address: SocketAddr, + message_sender: mpsc::UnboundedSender, + waiting_responses: WaitingResponsesMap, + abort_handle: AbortHandle, +} + +impl RpcServer { + pub fn address(&self) -> SocketAddr { + self.address + } + + pub async fn call_method( + &self, + method: &str, + ) -> Result { + self.call_method_any(method, vec![]).await + } + + pub async fn call_method_2( + &self, + method: &str, + arg1: impl Serialize, + arg2: impl Serialize, + ) -> Result { + self.call_method_any( + method, + vec![serde_json::to_value(arg1)?, serde_json::to_value(arg2)?], + ) + .await + } + + async fn call_method_any( + &self, + method: &str, + args: Vec, + ) -> Result { + if self.message_sender.is_closed() { + return Err(ErrorKind::RpcError( + "RPC connection closed".to_string(), + ) + .into()); + } + + let id = Uuid::new_v4(); + let (send, recv) = oneshot::channel(); + self.waiting_responses.lock().unwrap().insert(id, send); + + let message = RpcMessage { + id, + body: RpcMessageBody::Call { + method: method.to_owned(), + args, + }, + }; + if self.message_sender.send(message).is_err() { + self.waiting_responses.lock().unwrap().remove(&id); + return Err(ErrorKind::RpcError( + "RPC connection closed while sending".to_string(), + ) + .into()); + } + + tracing::debug!("Waiting on result for {id}"); + let Ok(result) = recv.await else { + self.waiting_responses.lock().unwrap().remove(&id); + return Err(ErrorKind::RpcError( + "RPC connection closed while waiting for response".to_string(), + ) + .into()); + }; + result.and_then(|x| Ok(serde_json::from_value(x)?)) + } +} + +impl Drop for RpcServer { + fn drop(&mut self) { + self.abort_handle.abort(); + } +} + +struct RunningRpcServer { + message_receiver: mpsc::UnboundedReceiver, + handlers: HandlerMap, + waiting_responses: WaitingResponsesMap, +} + +impl RunningRpcServer { + async fn run(&mut self, listener: TcpListener) -> Result<()> { + let (socket, _) = listener.accept().await?; + drop(listener); + + let mut socket = LinesCodec::new().framed(socket); + loop { + let to_send = tokio::select! { + message = self.message_receiver.recv() => { + if message.is_none() { + break; + } + message + }, + message = socket.next() => { + let message: RpcMessage = match message { + None => break, + Some(Ok(message)) => serde_json::from_str(&message)?, + Some(Err(LinesCodecError::Io(e))) => Err(e)?, + Some(Err(LinesCodecError::MaxLineLengthExceeded)) => unreachable!(), + }; + self.handle_message(message).await? + }, + }; + if let Some(message) = to_send { + let json = serde_json::to_string(&message)?; + match socket.send(json).await { + Ok(()) => {} + Err(LinesCodecError::Io(e)) => Err(e)?, + Err(LinesCodecError::MaxLineLengthExceeded) => { + unreachable!() + } + }; + } + } + Ok(()) + } + + async fn handle_message( + &self, + message: RpcMessage, + ) -> Result> { + if let RpcMessageBody::Call { method, args } = message.body { + let response = match self.handlers.get(method.as_str()) { + Some(handler) => match handler(args).await { + Ok(result) => RpcMessageBody::Respond { response: result }, + Err(e) => RpcMessageBody::Error { + error: e.to_string(), + }, + }, + None => RpcMessageBody::Error { + error: format!("Unknown theseus RPC method {method}"), + }, + }; + Ok(Some(RpcMessage { + id: message.id, + body: response, + })) + } else if let Some(sender) = + self.waiting_responses.lock().unwrap().remove(&message.id) + { + let _ = sender.send(match message.body { + RpcMessageBody::Respond { response } => Ok(response), + RpcMessageBody::Error { error } => { + Err(ErrorKind::RpcError(error).into()) + } + _ => unreachable!(), + }); + Ok(None) + } else { + Ok(None) + } + } +} + +#[derive(Debug, Serialize, Deserialize)] +struct RpcMessage { + id: Uuid, + #[serde(flatten)] + body: RpcMessageBody, +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(untagged)] +enum RpcMessageBody { + Call { + method: String, + args: Vec, + }, + Respond { + #[serde(default, skip_serializing_if = "Value::is_null")] + response: Value, + }, + Error { + error: String, + }, +} From 0bc65024433dacc679a1f1190134516f238db1a0 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Alejandro=20Gonz=C3=A1lez?= <7822554+AlexTMjugador@users.noreply.github.com> Date: Thu, 14 Aug 2025 03:16:36 +0200 Subject: [PATCH 07/21] App surveys (#3605) * feat: surveys * make assigned and dismissed users fields optional * fix: set required CSP sources for Tally forms to show up * make only attempt on windows, temp bypass requirements * fix: lint issues * Add prompt for survey prior to popup * lint * hide ads when survey is open --------- Signed-off-by: Cal H. Co-authored-by: Prospector Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com> Co-authored-by: Cal H. Co-authored-by: IMB11 --- apps/app-frontend/index.html | 1 + apps/app-frontend/src/App.vue | 164 ++++++++++++++++++++++++- apps/app/tauri.conf.json | 4 +- packages/assets/generated-icons.ts | 2 + packages/assets/icons/notepad-text.svg | 1 + 5 files changed, 169 insertions(+), 3 deletions(-) create mode 100644 packages/assets/icons/notepad-text.svg diff --git a/apps/app-frontend/index.html b/apps/app-frontend/index.html index 891f575f..e5549f28 100644 --- a/apps/app-frontend/index.html +++ b/apps/app-frontend/index.html @@ -11,6 +11,7 @@
+ diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index 6d2451c3..cb1ea25e 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -18,12 +18,13 @@ import RunningAppBar from '@/components/ui/RunningAppBar.vue' import SplashScreen from '@/components/ui/SplashScreen.vue' import URLConfirmModal from '@/components/ui/URLConfirmModal.vue' import { useCheckDisableMouseover } from '@/composables/macCssFix.js' -import { hide_ads_window, init_ads_window } from '@/helpers/ads.js' +import { hide_ads_window, init_ads_window, show_ads_window } from '@/helpers/ads.js' import { debugAnalytics, initAnalytics, optOutAnalytics, trackEvent } from '@/helpers/analytics' import { get_user } from '@/helpers/cache.js' import { command_listener, warning_listener } from '@/helpers/events.js' import { useFetch } from '@/helpers/fetch.js' import { cancelLogin, get as getCreds, login, logout } from '@/helpers/mr_auth.js' +import { list } from '@/helpers/profile.js' import { get } from '@/helpers/settings.ts' import { get_opening_command, initialize_state } from '@/helpers/state' import { getOS, isDev, restartApp } from '@/helpers/utils.js' @@ -43,6 +44,7 @@ import { MaximizeIcon, MinimizeIcon, NewspaperIcon, + NotepadTextIcon, PlusIcon, RestoreIcon, RightArrowIcon, @@ -67,6 +69,7 @@ import { openUrl } from '@tauri-apps/plugin-opener' import { type } from '@tauri-apps/plugin-os' import { check } from '@tauri-apps/plugin-updater' import { saveWindowState, StateFlags } from '@tauri-apps/plugin-window-state' +import { $fetch } from 'ofetch' import { computed, onMounted, onUnmounted, provide, ref, watch } from 'vue' import { RouterView, useRoute, useRouter } from 'vue-router' import { create_profile_and_install_from_file } from './helpers/pack' @@ -81,6 +84,7 @@ provideNotificationManager(notificationManager) const { handleError, addNotification } = notificationManager const news = ref([]) +const availableSurvey = ref(false) const urlModal = ref(null) @@ -225,6 +229,12 @@ async function setupApp() { } catch (error) { console.warn('Failed to generate skin previews in app setup.', error) } + + if (osType === 'windows') { + await processPendingSurveys() + } else { + console.info('Skipping user surveys on non-Windows platforms') + } } const stateFailed = ref(false) @@ -412,6 +422,116 @@ function handleAuxClick(e) { e.target.dispatchEvent(event) } } + +function cleanupOldSurveyDisplayData() { + const threeWeeksAgo = new Date() + threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21) + + for (let i = 0; i < localStorage.length; i++) { + const key = localStorage.key(i) + + if (key.startsWith('survey-') && key.endsWith('-display')) { + const dateValue = new Date(localStorage.getItem(key)) + if (dateValue < threeWeeksAgo) { + localStorage.removeItem(key) + } + } + } +} + +async function openSurvey() { + if (!availableSurvey.value) { + console.error('No survey to open') + return + } + + const creds = await getCreds().catch(handleError) + const userId = creds?.user_id + + const formId = availableSurvey.value.tally_id + + const popupOptions = { + layout: 'modal', + width: 700, + autoClose: 2000, + hideTitle: true, + hiddenFields: { + user_id: userId, + }, + onOpen: () => console.info('Opened user survey'), + onClose: () => { + console.info('Closed user survey') + show_ads_window() + }, + onSubmit: () => console.info('Active user survey submitted'), + } + + try { + hide_ads_window() + if (window.Tally?.openPopup) { + console.info(`Opening Tally popup for user survey (form ID: ${formId})`) + dismissSurvey() + window.Tally.openPopup(formId, popupOptions) + } else { + console.warn('Tally script not yet loaded') + show_ads_window() + } + } catch (e) { + console.error('Error opening Tally popup:', e) + show_ads_window() + } + + console.info(`Found user survey to show with tally_id: ${formId}`) + window.Tally.openPopup(formId, popupOptions) +} + +function dismissSurvey() { + localStorage.setItem(`survey-${availableSurvey.value.id}-display`, new Date()) + availableSurvey.value = undefined +} + +async function processPendingSurveys() { + function isWithinLastTwoWeeks(date) { + const twoWeeksAgo = new Date() + twoWeeksAgo.setDate(twoWeeksAgo.getDate() - 14) + return date >= twoWeeksAgo + } + + cleanupOldSurveyDisplayData() + + const creds = await getCreds().catch(handleError) + const userId = creds?.user_id + + const instances = await list().catch(handleError) + const isActivePlayer = + instances.findIndex( + (instance) => + isWithinLastTwoWeeks(instance.last_played) && !isWithinLastTwoWeeks(instance.created), + ) >= 0 + + let surveys = [] + try { + surveys = await $fetch('https://api.modrinth.com/v2/surveys') + } catch (e) { + console.error('Error fetching surveys:', e) + } + + const surveyToShow = surveys.find( + (survey) => + !!( + localStorage.getItem(`survey-${survey.id}-display`) === null && + survey.type === 'tally_app' && + ((survey.condition === 'active_player' && isActivePlayer) || + (survey.assigned_users?.includes(userId) && !survey.dismissed_users?.includes(userId))) + ), + ) + + if (surveyToShow) { + availableSurvey.value = surveyToShow + } else { + console.info('No user survey to show') + } +} - - + +
+ + + diff --git a/apps/app-frontend/package.json b/apps/app-frontend/package.json index d760df6d..4cc577cd 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -1,64 +1,63 @@ { - "name": "@modrinth/app-frontend", - "private": true, - "version": "1.0.0-local", - "type": "module", - "scripts": { - "dev": "vite", - "build": "vue-tsc --noEmit && vite build", - "tsc:check": "vue-tsc --noEmit", - "lint": "eslint . && prettier --check .", - "fix": "eslint . --fix && prettier --write .", - "intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace", - "test": "vue-tsc --noEmit" - }, - "dependencies": { - "@geometrically/minecraft-motd-parser": "^1.1.4", - "@modrinth/assets": "workspace:*", - "@modrinth/ui": "workspace:*", - "@modrinth/utils": "workspace:*", - "@sentry/vue": "^8.27.0", - "@tauri-apps/api": "^2.5.0", - "@tauri-apps/plugin-dialog": "^2.2.1", - "@tauri-apps/plugin-http": "^2.5.0", - "@tauri-apps/plugin-opener": "^2.2.6", - "@tauri-apps/plugin-os": "^2.2.1", - "@tauri-apps/plugin-updater": "^2.7.1", - "@tauri-apps/plugin-window-state": "^2.2.2", - "@types/three": "^0.172.0", - "@vintl/vintl": "^4.4.1", - "@vueuse/core": "^11.1.0", - "dayjs": "^1.11.10", - "floating-vue": "^5.2.2", - "ofetch": "^1.3.4", - "pinia": "^2.1.7", - "posthog-js": "^1.158.2", - "three": "^0.172.0", - "vite-svg-loader": "^5.1.0", - "vue": "^3.5.13", - "vue-multiselect": "3.0.0", - "vue-router": "4.3.0", - "vue-virtual-scroller": "v2.0.0-beta.8" - }, - "devDependencies": { - "@eslint/compat": "^1.1.1", - "@formatjs/cli": "^6.2.12", - "@nuxt/eslint-config": "^0.5.6", - "@taijased/vue-render-tracker": "^1.0.7", - "@vitejs/plugin-vue": "^5.0.4", - "autoprefixer": "^10.4.19", - "eslint": "^9.9.1", - "eslint-config-custom": "workspace:*", - "eslint-plugin-turbo": "^2.5.4", - "postcss": "^8.4.39", - "prettier": "^3.2.5", - "sass": "^1.74.1", - "tailwindcss": "^3.4.4", - "tsconfig": "workspace:*", - "typescript": "^5.5.4", - "vite": "^5.4.6", - "vue-tsc": "^2.1.6" - }, - "packageManager": "pnpm@9.4.0", - "web-types": "../../web-types.json" + "name": "@modrinth/app-frontend", + "private": true, + "version": "1.0.0-local", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vue-tsc --noEmit && vite build", + "tsc:check": "vue-tsc --noEmit", + "lint": "eslint . && prettier --check .", + "fix": "eslint . --fix && prettier --write .", + "intl:extract": "formatjs extract \"src/**/*.{vue,ts,tsx,js,jsx,mts,cts,mjs,cjs}\" --ignore \"**/*.d.ts\" --ignore node_modules --out-file src/locales/en-US/index.json --format crowdin --preserve-whitespace", + "test": "vue-tsc --noEmit" + }, + "dependencies": { + "@geometrically/minecraft-motd-parser": "^1.1.4", + "@modrinth/assets": "workspace:*", + "@modrinth/ui": "workspace:*", + "@modrinth/utils": "workspace:*", + "@sentry/vue": "^8.27.0", + "@tauri-apps/api": "^2.5.0", + "@tauri-apps/plugin-dialog": "^2.2.1", + "@tauri-apps/plugin-http": "^2.5.0", + "@tauri-apps/plugin-opener": "^2.2.6", + "@tauri-apps/plugin-os": "^2.2.1", + "@tauri-apps/plugin-updater": "^2.7.1", + "@tauri-apps/plugin-window-state": "^2.2.2", + "@types/three": "^0.172.0", + "@vintl/vintl": "^4.4.1", + "@vueuse/core": "^11.1.0", + "dayjs": "^1.11.10", + "floating-vue": "^5.2.2", + "ofetch": "^1.3.4", + "pinia": "^2.1.7", + "posthog-js": "^1.158.2", + "three": "^0.172.0", + "vite-svg-loader": "^5.1.0", + "vue": "^3.5.13", + "vue-multiselect": "3.0.0", + "vue-router": "4.3.0", + "vue-virtual-scroller": "v2.0.0-beta.8" + }, + "devDependencies": { + "@modrinth/tooling-config": "workspace:*", + "@eslint/compat": "^1.1.1", + "@formatjs/cli": "^6.2.12", + "@nuxt/eslint-config": "^0.5.6", + "@taijased/vue-render-tracker": "^1.0.7", + "@vitejs/plugin-vue": "^5.0.4", + "autoprefixer": "^10.4.19", + "eslint": "^9.9.1", + "eslint-plugin-turbo": "^2.5.4", + "postcss": "^8.4.39", + "prettier": "^3.2.5", + "sass": "^1.74.1", + "tailwindcss": "^3.4.4", + "typescript": "^5.5.4", + "vite": "^5.4.6", + "vue-tsc": "^2.1.6" + }, + "packageManager": "pnpm@9.4.0", + "web-types": "../../web-types.json" } diff --git a/apps/app-frontend/postcss.config.js b/apps/app-frontend/postcss.config.js index 2e7af2b7..1a526247 100644 --- a/apps/app-frontend/postcss.config.js +++ b/apps/app-frontend/postcss.config.js @@ -1,6 +1,6 @@ export default { - plugins: { - tailwindcss: {}, - autoprefixer: {}, - }, + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, } diff --git a/apps/app-frontend/src/App.vue b/apps/app-frontend/src/App.vue index cb1ea25e..11cc6600 100644 --- a/apps/app-frontend/src/App.vue +++ b/apps/app-frontend/src/App.vue @@ -1,4 +1,46 @@ diff --git a/apps/app-frontend/src/assets/external/index.js b/apps/app-frontend/src/assets/external/index.js index c20eb57c..479cf4fd 100644 --- a/apps/app-frontend/src/assets/external/index.js +++ b/apps/app-frontend/src/assets/external/index.js @@ -1,18 +1,18 @@ +export { default as ATLauncherIcon } from './atlauncher.svg' export { default as BuyMeACoffeeIcon } from './bmac.svg' export { default as DiscordIcon } from './discord.svg' +export { default as GDLauncherIcon } from './gdlauncher.png' +export { default as GithubIcon } from './github.svg' +export { default as GitLabIcon } from './gitlab.svg' +export { default as GoogleIcon } from './google.svg' export { default as KoFiIcon } from './kofi.svg' +export { default as MastodonIcon } from './mastodon.svg' +export { default as MicrosoftIcon } from './microsoft.svg' +export { default as MultiMCIcon } from './multimc.webp' +export { default as OpenCollectiveIcon } from './opencollective.svg' export { default as PatreonIcon } from './patreon.svg' export { default as PaypalIcon } from './paypal.svg' -export { default as OpenCollectiveIcon } from './opencollective.svg' -export { default as TwitterIcon } from './twitter.svg' -export { default as GithubIcon } from './github.svg' -export { default as MastodonIcon } from './mastodon.svg' -export { default as RedditIcon } from './reddit.svg' -export { default as GoogleIcon } from './google.svg' -export { default as MicrosoftIcon } from './microsoft.svg' -export { default as SteamIcon } from './steam.svg' -export { default as GitLabIcon } from './gitlab.svg' -export { default as ATLauncherIcon } from './atlauncher.svg' -export { default as GDLauncherIcon } from './gdlauncher.png' -export { default as MultiMCIcon } from './multimc.webp' export { default as PrismIcon } from './prism.svg' +export { default as RedditIcon } from './reddit.svg' +export { default as SteamIcon } from './steam.svg' +export { default as TwitterIcon } from './twitter.svg' diff --git a/apps/app-frontend/src/assets/icons/index.js b/apps/app-frontend/src/assets/icons/index.js index 4a5266b2..256b61af 100644 --- a/apps/app-frontend/src/assets/icons/index.js +++ b/apps/app-frontend/src/assets/icons/index.js @@ -1,9 +1,9 @@ -export { default as SwapIcon } from './arrow-left-right.svg' -export { default as ToggleIcon } from './toggle.svg' -export { default as PackageIcon } from './package.svg' -export { default as VersionIcon } from './milestone.svg' -export { default as TextInputIcon } from './text-cursor-input.svg' export { default as AddProjectImage } from './add-project.svg' -export { default as NewInstanceImage } from './new-instance.svg' +export { default as SwapIcon } from './arrow-left-right.svg' export { default as MenuIcon } from './menu.svg' export { default as ChatIcon } from './messages-square.svg' +export { default as VersionIcon } from './milestone.svg' +export { default as NewInstanceImage } from './new-instance.svg' +export { default as PackageIcon } from './package.svg' +export { default as TextInputIcon } from './text-cursor-input.svg' +export { default as ToggleIcon } from './toggle.svg' diff --git a/apps/app-frontend/src/assets/stylesheets/global.scss b/apps/app-frontend/src/assets/stylesheets/global.scss index 7ab08fd5..2969d3cb 100644 --- a/apps/app-frontend/src/assets/stylesheets/global.scss +++ b/apps/app-frontend/src/assets/stylesheets/global.scss @@ -3,158 +3,158 @@ @tailwind utilities; @font-face { - font-family: 'bundled-minecraft-font-mrapp'; - font-style: normal; - font-display: swap; - font-weight: 400; - src: url('https://cdn-raw.modrinth.com/fonts/minecraft/regular.otf') format('opentype'); + font-family: 'bundled-minecraft-font-mrapp'; + font-style: normal; + font-display: swap; + font-weight: 400; + src: url('https://cdn-raw.modrinth.com/fonts/minecraft/regular.otf') format('opentype'); } @font-face { - font-family: 'bundled-minecraft-font-mrapp'; - font-style: italic; - font-display: swap; - font-weight: 400; - src: url('https://cdn-raw.modrinth.com/fonts/minecraft/italic.otf') format('opentype'); + font-family: 'bundled-minecraft-font-mrapp'; + font-style: italic; + font-display: swap; + font-weight: 400; + src: url('https://cdn-raw.modrinth.com/fonts/minecraft/italic.otf') format('opentype'); } @font-face { - font-family: 'bundled-minecraft-font-mrapp'; - font-style: normal; - font-display: swap; - font-weight: 600; - src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold.otf') format('opentype'); + font-family: 'bundled-minecraft-font-mrapp'; + font-style: normal; + font-display: swap; + font-weight: 600; + src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold.otf') format('opentype'); } @font-face { - font-family: 'bundled-minecraft-font-mrapp'; - font-style: italic; - font-display: swap; - font-weight: 600; - src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold-italic.otf') format('opentype'); + font-family: 'bundled-minecraft-font-mrapp'; + font-style: italic; + font-display: swap; + font-weight: 600; + src: url('https://cdn-raw.modrinth.com/fonts/minecraft/bold-italic.otf') format('opentype'); } .font-minecraft { - font-family: 'bundled-minecraft-font-mrapp', monospace; + font-family: 'bundled-minecraft-font-mrapp', monospace; } :root { - font-family: var(--font-standard, sans-serif), sans-serif; - color-scheme: dark; - --view-width: calc(100% - 5rem); - --expanded-view-width: calc(100% - 13rem); + font-family: var(--font-standard, sans-serif), sans-serif; + color-scheme: dark; + --view-width: calc(100% - 5rem); + --expanded-view-width: calc(100% - 13rem); } body { - position: fixed; - width: 100%; - height: 100%; - overflow: hidden; + position: fixed; + width: 100%; + height: 100%; + overflow: hidden; } * { - box-sizing: border-box; + box-sizing: border-box; } .card-divider { - background-color: var(--color-button-bg); - border: none; - color: var(--color-button-bg); - height: 1px; - margin: var(--gap-sm) 0; + background-color: var(--color-button-bg); + border: none; + color: var(--color-button-bg); + height: 1px; + margin: var(--gap-sm) 0; } .no-wrap { - white-space: nowrap; + white-space: nowrap; } .no-select { - -webkit-user-select: none; - -ms-user-select: none; - user-select: none; + -webkit-user-select: none; + -ms-user-select: none; + user-select: none; } a { - color: var(--color-link); - text-decoration: none; + color: var(--color-link); + text-decoration: none; - &:hover { - text-decoration: none; - } + &:hover { + text-decoration: none; + } } input { - border: none !important; + border: none !important; } .badge { - display: flex; - border-radius: var(--radius-md); - white-space: nowrap; - align-items: center; - background-color: var(--color-bg); - padding-block: var(--gap-sm); - padding-inline: var(--gap-lg); - width: min-content; + display: flex; + border-radius: var(--radius-md); + white-space: nowrap; + align-items: center; + background-color: var(--color-bg); + padding-block: var(--gap-sm); + padding-inline: var(--gap-lg); + width: min-content; - svg { - width: 1.1rem; - height: 1.1rem; - margin-right: 0.5rem; - } + svg { + width: 1.1rem; + height: 1.1rem; + margin-right: 0.5rem; + } - &.featured { - background-color: var(--color-brand-highlight); - color: var(--color-contrast); - } + &.featured { + background-color: var(--color-brand-highlight); + color: var(--color-contrast); + } } * { - scrollbar-width: auto; - scrollbar-color: var(--color-scrollbar) var(--color-bg); + scrollbar-width: auto; + scrollbar-color: var(--color-scrollbar) var(--color-bg); } /* Chrome, Edge, and Safari */ *::-webkit-scrollbar { - width: 16px; - border: 3px solid transparent; - opacity: 0.5; - transition: opacity 0.2s ease-in-out; + width: 16px; + border: 3px solid transparent; + opacity: 0.5; + transition: opacity 0.2s ease-in-out; } *::-webkit-scrollbar:hover { - opacity: 1; + opacity: 1; } *::-webkit-scrollbar-track { - background: transparent; + background: transparent; } *::-webkit-scrollbar-thumb { - background-color: var(--color-scrollbar); - border-radius: var(--radius-lg); - border: 5px solid transparent; - background-clip: content-box; + background-color: var(--color-scrollbar); + border-radius: var(--radius-lg); + border: 5px solid transparent; + background-clip: content-box; } .highlighted { - box-shadow: 0 0 1rem var(--color-brand) !important; + box-shadow: 0 0 1rem var(--color-brand) !important; } .gecko { - background-color: var(--color-raised-bg); - box-shadow: none !important; + background-color: var(--color-raised-bg); + box-shadow: none !important; } img { - user-select: none; - -webkit-user-select: none; - -moz-user-select: none; - -ms-user-select: none; + user-select: none; + -webkit-user-select: none; + -moz-user-select: none; + -ms-user-select: none; } .card-shadow { - box-shadow: var(--shadow-card); + box-shadow: var(--shadow-card); } @import '@modrinth/assets/omorphia.scss'; diff --git a/apps/app-frontend/src/assets/stylesheets/macFix.css b/apps/app-frontend/src/assets/stylesheets/macFix.css index b56737a9..901ce864 100644 --- a/apps/app-frontend/src/assets/stylesheets/macFix.css +++ b/apps/app-frontend/src/assets/stylesheets/macFix.css @@ -1,3 +1,3 @@ img { - pointer-events: none !important; + pointer-events: none !important; } diff --git a/apps/app-frontend/src/components/GridDisplay.vue b/apps/app-frontend/src/components/GridDisplay.vue index 896c55b9..046244f6 100644 --- a/apps/app-frontend/src/components/GridDisplay.vue +++ b/apps/app-frontend/src/components/GridDisplay.vue @@ -1,37 +1,38 @@ diff --git a/apps/app-frontend/src/components/LoadingIndicatorBar.vue b/apps/app-frontend/src/components/LoadingIndicatorBar.vue index 3c1dd088..7cbee496 100644 --- a/apps/app-frontend/src/components/LoadingIndicatorBar.vue +++ b/apps/app-frontend/src/components/LoadingIndicatorBar.vue @@ -1,29 +1,30 @@ diff --git a/apps/app-frontend/src/components/RowDisplay.vue b/apps/app-frontend/src/components/RowDisplay.vue index f0a3edbd..b0114653 100644 --- a/apps/app-frontend/src/components/RowDisplay.vue +++ b/apps/app-frontend/src/components/RowDisplay.vue @@ -1,4 +1,21 @@ diff --git a/apps/app-frontend/src/components/ui/AccountsCard.vue b/apps/app-frontend/src/components/ui/AccountsCard.vue index f26d8c6f..73d10598 100644 --- a/apps/app-frontend/src/components/ui/AccountsCard.vue +++ b/apps/app-frontend/src/components/ui/AccountsCard.vue @@ -1,102 +1,103 @@ diff --git a/apps/app-frontend/src/components/ui/AddContentButton.vue b/apps/app-frontend/src/components/ui/AddContentButton.vue index d3447bf0..193b6e80 100644 --- a/apps/app-frontend/src/components/ui/AddContentButton.vue +++ b/apps/app-frontend/src/components/ui/AddContentButton.vue @@ -1,61 +1,62 @@ diff --git a/apps/app-frontend/src/components/ui/Breadcrumbs.vue b/apps/app-frontend/src/components/ui/Breadcrumbs.vue index d87c502f..ee12e17e 100644 --- a/apps/app-frontend/src/components/ui/Breadcrumbs.vue +++ b/apps/app-frontend/src/components/ui/Breadcrumbs.vue @@ -1,63 +1,64 @@ diff --git a/apps/app-frontend/src/components/ui/ContextMenu.vue b/apps/app-frontend/src/components/ui/ContextMenu.vue index a0ca9417..bddcbfb8 100644 --- a/apps/app-frontend/src/components/ui/ContextMenu.vue +++ b/apps/app-frontend/src/components/ui/ContextMenu.vue @@ -1,26 +1,26 @@ diff --git a/apps/app-frontend/src/components/ui/ErrorModal.vue b/apps/app-frontend/src/components/ui/ErrorModal.vue index bdd1acf7..17a9dffc 100644 --- a/apps/app-frontend/src/components/ui/ErrorModal.vue +++ b/apps/app-frontend/src/components/ui/ErrorModal.vue @@ -1,4 +1,16 @@ diff --git a/apps/app-frontend/src/components/ui/ExportModal.vue b/apps/app-frontend/src/components/ui/ExportModal.vue index 3957a5ad..d29633af 100644 --- a/apps/app-frontend/src/components/ui/ExportModal.vue +++ b/apps/app-frontend/src/components/ui/ExportModal.vue @@ -1,26 +1,27 @@ diff --git a/apps/frontend/src/pages/servers/manage/[id]/options/startup.vue b/apps/frontend/src/pages/servers/manage/[id]/options/startup.vue index dcbaf417..34ee921e 100644 --- a/apps/frontend/src/pages/servers/manage/[id]/options/startup.vue +++ b/apps/frontend/src/pages/servers/manage/[id]/options/startup.vue @@ -1,234 +1,235 @@