From cc34e69524da49313292eb04502b5b05e0158f4f Mon Sep 17 00:00:00 2001 From: Josiah Glosson Date: Thu, 19 Jun 2025 14:46:12 -0500 Subject: [PATCH] Initial shared instances backend (#3800) * Create base shared instance migration and initial routes * Fix build * Add version uploads * Add permissions field for shared instance users * Actually use permissions field * Add "public" flag to shared instances that allow GETing them without authorization * Add the ability to get and list shared instance versions * Add the ability to delete shared instance versions * Fix build after merge * Secured file hosting (#3784) * Remove Backblaze-specific file-hosting backend * Added S3_USES_PATH_STYLE_BUCKETS * Remove unused file_id parameter from delete_file_version * Add support for separate public and private buckets in labrinth::file_hosting * Rename delete_file_version to delete_file * Add (untested) get_url_for_private_file * Remove url field from shared instance routes * Remove url field from shared instance routes * Use private bucket for shared instance versions * Make S3 environment variables fully separate between public and private buckets * Change file host expiry for shared instances to 180 seconds * Fix lint * Merge shared instance migrations into a single migration * Replace shared instance owners with Ghost instead of deleting the instance --- .../src/content/docs/contributing/labrinth.md | 7 +- apps/labrinth/.env.local | 20 +- ...a2b8edb55d93ada8f2243448865163f555d8d.json | 16 + ...a4bb38acc54e4ea5de020fffef7457000fa6e.json | 46 ++ ...e2b57e917e9507942d65f40c1b733209cabf0.json | 46 ++ ...6a9158a3ea08f8720f6df5b4902cd8094d3bb.json | 14 + ...ab13ae43d180b8c86cb2a6fab0253dd4eba55.json | 15 + ...8fa4c3641eb2221e522bf50abad4f5e977599.json | 15 + ...4e07e5ae80b5dfca6dc041a03c0c7a30a5cf1.json | 17 + ...79446dd964da4efa49c5464cebde57860f741.json | 15 + ...9d227d95f2a8f2eb9852833e14c723903988b.json | 22 + ...8d84842152b82c9a0032d1db587d7099b8550.json | 46 ++ ...9405e28c265bef6121b457c4f39cacf00683f.json | 15 + ...1b8c709645ef29f3b5fb6e8e07fc212b36798.json | 34 + ...47be2a18275f33f8991d910f693fbcc1ff731.json | 46 ++ ...eb2231578aff18c93d02ead97c3c07f0b27ea.json | 23 + ...0bd34c3893e8b55bbd126a988137ec7bf1ff9.json | 14 + ...e6826f46c27ab3a21520e9e169fd1491521c4.json | 22 + ...ec405531e4270be85087122245991ec88473e.json | 18 + ...ed48aaedab5db381f4efc389b852d9020a0e6.json | 14 + .../20250519184051_shared-instances.sql | 43 ++ apps/labrinth/src/auth/oauth/errors.rs | 8 +- apps/labrinth/src/auth/oauth/uris.rs | 2 +- apps/labrinth/src/auth/validate.rs | 34 + apps/labrinth/src/database/models/ids.rs | 84 ++- apps/labrinth/src/database/models/mod.rs | 1 + .../database/models/shared_instance_item.rs | 335 ++++++++++ .../labrinth/src/database/models/user_item.rs | 12 + apps/labrinth/src/file_hosting/backblaze.rs | 108 ---- .../file_hosting/backblaze/authorization.rs | 81 --- .../src/file_hosting/backblaze/delete.rs | 38 -- .../src/file_hosting/backblaze/upload.rs | 47 -- apps/labrinth/src/file_hosting/mock.rs | 45 +- apps/labrinth/src/file_hosting/mod.rs | 32 +- apps/labrinth/src/file_hosting/s3_host.rs | 134 ++-- apps/labrinth/src/lib.rs | 34 +- apps/labrinth/src/main.rs | 47 +- apps/labrinth/src/models/mod.rs | 1 + apps/labrinth/src/models/v3/ids.rs | 2 + apps/labrinth/src/models/v3/mod.rs | 1 + apps/labrinth/src/models/v3/pats.rs | 18 + .../src/models/v3/shared_instances.rs | 89 +++ apps/labrinth/src/routes/internal/flows.rs | 3 +- apps/labrinth/src/routes/v3/collections.rs | 9 +- apps/labrinth/src/routes/v3/images.rs | 7 +- apps/labrinth/src/routes/v3/mod.rs | 4 + apps/labrinth/src/routes/v3/oauth_clients.rs | 13 +- apps/labrinth/src/routes/v3/organizations.rs | 9 +- .../src/routes/v3/project_creation.rs | 32 +- apps/labrinth/src/routes/v3/projects.rs | 13 +- apps/labrinth/src/routes/v3/reports.rs | 12 +- .../v3/shared_instance_version_creation.rs | 200 ++++++ .../src/routes/v3/shared_instances.rs | 612 ++++++++++++++++++ apps/labrinth/src/routes/v3/threads.rs | 9 +- apps/labrinth/src/routes/v3/users.rs | 11 +- .../src/routes/v3/version_creation.rs | 8 +- apps/labrinth/src/util/env.rs | 2 +- apps/labrinth/src/util/ext.rs | 4 +- apps/labrinth/src/util/img.rs | 15 +- apps/labrinth/src/util/routes.rs | 27 +- packages/ariadne/src/ids.rs | 1 - 61 files changed, 2161 insertions(+), 491 deletions(-) create mode 100644 apps/labrinth/.sqlx/query-09ebec1a568edf1959f20b33d8ba2b8edb55d93ada8f2243448865163f555d8d.json create mode 100644 apps/labrinth/.sqlx/query-1ebe19b7b4f10039065967a0b1ca4bb38acc54e4ea5de020fffef7457000fa6e.json create mode 100644 apps/labrinth/.sqlx/query-265c4d6f33714c8a5cf3137c429e2b57e917e9507942d65f40c1b733209cabf0.json create mode 100644 apps/labrinth/.sqlx/query-47130ef29ce5914528e5424fe516a9158a3ea08f8720f6df5b4902cd8094d3bb.json create mode 100644 apps/labrinth/.sqlx/query-47ec9f179f1c52213bd32b37621ab13ae43d180b8c86cb2a6fab0253dd4eba55.json create mode 100644 apps/labrinth/.sqlx/query-6b166d129b0ee028898620054a58fa4c3641eb2221e522bf50abad4f5e977599.json create mode 100644 apps/labrinth/.sqlx/query-6f72c853e139f23322fe6f1f02e4e07e5ae80b5dfca6dc041a03c0c7a30a5cf1.json create mode 100644 apps/labrinth/.sqlx/query-72ae0e8debd06067894a2f7bea279446dd964da4efa49c5464cebde57860f741.json create mode 100644 apps/labrinth/.sqlx/query-7c445073f61e30723416a9690aa9d227d95f2a8f2eb9852833e14c723903988b.json create mode 100644 apps/labrinth/.sqlx/query-9c6e18cb19251e54b3b96446ab88d84842152b82c9a0032d1db587d7099b8550.json create mode 100644 apps/labrinth/.sqlx/query-9ccaf8ea52b1b6f0880d34cdb4a9405e28c265bef6121b457c4f39cacf00683f.json create mode 100644 apps/labrinth/.sqlx/query-aec58041cf5e5e68501652336581b8c709645ef29f3b5fb6e8e07fc212b36798.json create mode 100644 apps/labrinth/.sqlx/query-b93253bbc35b24974d13bc8ee0447be2a18275f33f8991d910f693fbcc1ff731.json create mode 100644 apps/labrinth/.sqlx/query-c3869a595693757ccf81085d0c8eb2231578aff18c93d02ead97c3c07f0b27ea.json create mode 100644 apps/labrinth/.sqlx/query-cef730c02bb67b0536d35e5aaca0bd34c3893e8b55bbd126a988137ec7bf1ff9.json create mode 100644 apps/labrinth/.sqlx/query-d8558a8039ade3b383db4f0e095e6826f46c27ab3a21520e9e169fd1491521c4.json create mode 100644 apps/labrinth/.sqlx/query-d8a1d710f86b3df4d99c2d2ec26ec405531e4270be85087122245991ec88473e.json create mode 100644 apps/labrinth/.sqlx/query-f6388b5026e25191840d1a157a9ed48aaedab5db381f4efc389b852d9020a0e6.json create mode 100644 apps/labrinth/migrations/20250519184051_shared-instances.sql create mode 100644 apps/labrinth/src/database/models/shared_instance_item.rs delete mode 100644 apps/labrinth/src/file_hosting/backblaze.rs delete mode 100644 apps/labrinth/src/file_hosting/backblaze/authorization.rs delete mode 100644 apps/labrinth/src/file_hosting/backblaze/delete.rs delete mode 100644 apps/labrinth/src/file_hosting/backblaze/upload.rs create mode 100644 apps/labrinth/src/models/v3/shared_instances.rs create mode 100644 apps/labrinth/src/routes/v3/shared_instance_version_creation.rs create mode 100644 apps/labrinth/src/routes/v3/shared_instances.rs diff --git a/apps/docs/src/content/docs/contributing/labrinth.md b/apps/docs/src/content/docs/contributing/labrinth.md index 155e854cb..f29c71255 100644 --- a/apps/docs/src/content/docs/contributing/labrinth.md +++ b/apps/docs/src/content/docs/contributing/labrinth.md @@ -85,11 +85,10 @@ During development, you might notice that changes made directly to entities in t #### CDN options -`STORAGE_BACKEND`: Controls what storage backend is used. This can be either `local`, `backblaze`, or `s3`, but defaults to `local` +`STORAGE_BACKEND`: Controls what storage backend is used. This can be either `local` or `s3`, but defaults to `local` -The Backblaze and S3 configuration options are fairly self-explanatory in name, so here's simply their names: -`BACKBLAZE_KEY_ID`, `BACKBLAZE_KEY`, `BACKBLAZE_BUCKET_ID` -`S3_ACCESS_TOKEN`, `S3_SECRET`, `S3_URL`, `S3_REGION`, `S3_BUCKET_NAME` +The S3 configuration options are fairly self-explanatory in name, so here's simply their names: +`S3_ACCESS_TOKEN`, `S3_SECRET`, `S3_URL`, `S3_REGION`, `S3_PUBLIC_BUCKET_NAME`, `S3_PRIVATE_BUCKET_NAME`, `S3_USES_PATH_STYLE_BUCKETS` #### Search, OAuth, and miscellaneous options diff --git a/apps/labrinth/.env.local b/apps/labrinth/.env.local index 880ac9385..c66948fe1 100644 --- a/apps/labrinth/.env.local +++ b/apps/labrinth/.env.local @@ -28,15 +28,19 @@ CLOUDFLARE_INTEGRATION=false STORAGE_BACKEND=local MOCK_FILE_PATH=/tmp/modrinth -BACKBLAZE_KEY_ID=none -BACKBLAZE_KEY=none -BACKBLAZE_BUCKET_ID=none +S3_PUBLIC_BUCKET_NAME=none +S3_PUBLIC_USES_PATH_STYLE_BUCKET=false +S3_PUBLIC_REGION=none +S3_PUBLIC_URL=none +S3_PUBLIC_ACCESS_TOKEN=none +S3_PUBLIC_SECRET=none -S3_ACCESS_TOKEN=none -S3_SECRET=none -S3_URL=none -S3_REGION=none -S3_BUCKET_NAME=none +S3_PRIVATE_BUCKET_NAME=none +S3_PRIVATE_USES_PATH_STYLE_BUCKET=false +S3_PRIVATE_REGION=none +S3_PRIVATE_URL=none +S3_PRIVATE_ACCESS_TOKEN=none +S3_PRIVATE_SECRET=none # 1 hour LOCAL_INDEX_INTERVAL=3600 diff --git a/apps/labrinth/.sqlx/query-09ebec1a568edf1959f20b33d8ba2b8edb55d93ada8f2243448865163f555d8d.json b/apps/labrinth/.sqlx/query-09ebec1a568edf1959f20b33d8ba2b8edb55d93ada8f2243448865163f555d8d.json new file mode 100644 index 000000000..03bf9d951 --- /dev/null +++ b/apps/labrinth/.sqlx/query-09ebec1a568edf1959f20b33d8ba2b8edb55d93ada8f2243448865163f555d8d.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO shared_instance_users (user_id, shared_instance_id, permissions)\n VALUES ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "09ebec1a568edf1959f20b33d8ba2b8edb55d93ada8f2243448865163f555d8d" +} diff --git a/apps/labrinth/.sqlx/query-1ebe19b7b4f10039065967a0b1ca4bb38acc54e4ea5de020fffef7457000fa6e.json b/apps/labrinth/.sqlx/query-1ebe19b7b4f10039065967a0b1ca4bb38acc54e4ea5de020fffef7457000fa6e.json new file mode 100644 index 000000000..c584ce964 --- /dev/null +++ b/apps/labrinth/.sqlx/query-1ebe19b7b4f10039065967a0b1ca4bb38acc54e4ea5de020fffef7457000fa6e.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, title, owner_id, public, current_version_id\n FROM shared_instances\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "title", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "owner_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "public", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "current_version_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + true + ] + }, + "hash": "1ebe19b7b4f10039065967a0b1ca4bb38acc54e4ea5de020fffef7457000fa6e" +} diff --git a/apps/labrinth/.sqlx/query-265c4d6f33714c8a5cf3137c429e2b57e917e9507942d65f40c1b733209cabf0.json b/apps/labrinth/.sqlx/query-265c4d6f33714c8a5cf3137c429e2b57e917e9507942d65f40c1b733209cabf0.json new file mode 100644 index 000000000..7902fc25e --- /dev/null +++ b/apps/labrinth/.sqlx/query-265c4d6f33714c8a5cf3137c429e2b57e917e9507942d65f40c1b733209cabf0.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, shared_instance_id, size, sha512, created\n FROM shared_instance_versions\n WHERE shared_instance_id = $1\n ORDER BY created DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "shared_instance_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "size", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "sha512", + "type_info": "Bytea" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "265c4d6f33714c8a5cf3137c429e2b57e917e9507942d65f40c1b733209cabf0" +} diff --git a/apps/labrinth/.sqlx/query-47130ef29ce5914528e5424fe516a9158a3ea08f8720f6df5b4902cd8094d3bb.json b/apps/labrinth/.sqlx/query-47130ef29ce5914528e5424fe516a9158a3ea08f8720f6df5b4902cd8094d3bb.json new file mode 100644 index 000000000..24e1d953b --- /dev/null +++ b/apps/labrinth/.sqlx/query-47130ef29ce5914528e5424fe516a9158a3ea08f8720f6df5b4902cd8094d3bb.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM shared_instance_versions\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "47130ef29ce5914528e5424fe516a9158a3ea08f8720f6df5b4902cd8094d3bb" +} diff --git a/apps/labrinth/.sqlx/query-47ec9f179f1c52213bd32b37621ab13ae43d180b8c86cb2a6fab0253dd4eba55.json b/apps/labrinth/.sqlx/query-47ec9f179f1c52213bd32b37621ab13ae43d180b8c86cb2a6fab0253dd4eba55.json new file mode 100644 index 000000000..2d0db8068 --- /dev/null +++ b/apps/labrinth/.sqlx/query-47ec9f179f1c52213bd32b37621ab13ae43d180b8c86cb2a6fab0253dd4eba55.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE shared_instances SET current_version_id = $1 WHERE id = $2", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "47ec9f179f1c52213bd32b37621ab13ae43d180b8c86cb2a6fab0253dd4eba55" +} diff --git a/apps/labrinth/.sqlx/query-6b166d129b0ee028898620054a58fa4c3641eb2221e522bf50abad4f5e977599.json b/apps/labrinth/.sqlx/query-6b166d129b0ee028898620054a58fa4c3641eb2221e522bf50abad4f5e977599.json new file mode 100644 index 000000000..910adbc36 --- /dev/null +++ b/apps/labrinth/.sqlx/query-6b166d129b0ee028898620054a58fa4c3641eb2221e522bf50abad4f5e977599.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE shared_instances\n SET public = $1\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bool", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "6b166d129b0ee028898620054a58fa4c3641eb2221e522bf50abad4f5e977599" +} diff --git a/apps/labrinth/.sqlx/query-6f72c853e139f23322fe6f1f02e4e07e5ae80b5dfca6dc041a03c0c7a30a5cf1.json b/apps/labrinth/.sqlx/query-6f72c853e139f23322fe6f1f02e4e07e5ae80b5dfca6dc041a03c0c7a30a5cf1.json new file mode 100644 index 000000000..35b064008 --- /dev/null +++ b/apps/labrinth/.sqlx/query-6f72c853e139f23322fe6f1f02e4e07e5ae80b5dfca6dc041a03c0c7a30a5cf1.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO shared_instances (id, title, owner_id, current_version_id)\n VALUES ($1, $2, $3, $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "6f72c853e139f23322fe6f1f02e4e07e5ae80b5dfca6dc041a03c0c7a30a5cf1" +} diff --git a/apps/labrinth/.sqlx/query-72ae0e8debd06067894a2f7bea279446dd964da4efa49c5464cebde57860f741.json b/apps/labrinth/.sqlx/query-72ae0e8debd06067894a2f7bea279446dd964da4efa49c5464cebde57860f741.json new file mode 100644 index 000000000..e0787e3bb --- /dev/null +++ b/apps/labrinth/.sqlx/query-72ae0e8debd06067894a2f7bea279446dd964da4efa49c5464cebde57860f741.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE shared_instances\n SET owner_id = $1\n WHERE owner_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "72ae0e8debd06067894a2f7bea279446dd964da4efa49c5464cebde57860f741" +} diff --git a/apps/labrinth/.sqlx/query-7c445073f61e30723416a9690aa9d227d95f2a8f2eb9852833e14c723903988b.json b/apps/labrinth/.sqlx/query-7c445073f61e30723416a9690aa9d227d95f2a8f2eb9852833e14c723903988b.json new file mode 100644 index 000000000..7897b45d3 --- /dev/null +++ b/apps/labrinth/.sqlx/query-7c445073f61e30723416a9690aa9d227d95f2a8f2eb9852833e14c723903988b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM shared_instance_versions WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "7c445073f61e30723416a9690aa9d227d95f2a8f2eb9852833e14c723903988b" +} diff --git a/apps/labrinth/.sqlx/query-9c6e18cb19251e54b3b96446ab88d84842152b82c9a0032d1db587d7099b8550.json b/apps/labrinth/.sqlx/query-9c6e18cb19251e54b3b96446ab88d84842152b82c9a0032d1db587d7099b8550.json new file mode 100644 index 000000000..92c3b45f8 --- /dev/null +++ b/apps/labrinth/.sqlx/query-9c6e18cb19251e54b3b96446ab88d84842152b82c9a0032d1db587d7099b8550.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n -- See https://github.com/launchbadge/sqlx/issues/1266 for why we need all the \"as\"\n SELECT\n id as \"id!\",\n title as \"title!\",\n public as \"public!\",\n owner_id as \"owner_id!\",\n current_version_id\n FROM shared_instances\n WHERE owner_id = $1\n UNION\n SELECT\n id as \"id!\",\n title as \"title!\",\n public as \"public!\",\n owner_id as \"owner_id!\",\n current_version_id\n FROM shared_instances\n JOIN shared_instance_users ON id = shared_instance_id\n WHERE user_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "title!", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "public!", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "owner_id!", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "current_version_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null, + null, + null, + null, + null + ] + }, + "hash": "9c6e18cb19251e54b3b96446ab88d84842152b82c9a0032d1db587d7099b8550" +} diff --git a/apps/labrinth/.sqlx/query-9ccaf8ea52b1b6f0880d34cdb4a9405e28c265bef6121b457c4f39cacf00683f.json b/apps/labrinth/.sqlx/query-9ccaf8ea52b1b6f0880d34cdb4a9405e28c265bef6121b457c4f39cacf00683f.json new file mode 100644 index 000000000..1a5097375 --- /dev/null +++ b/apps/labrinth/.sqlx/query-9ccaf8ea52b1b6f0880d34cdb4a9405e28c265bef6121b457c4f39cacf00683f.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE shared_instances\n SET title = $1\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "9ccaf8ea52b1b6f0880d34cdb4a9405e28c265bef6121b457c4f39cacf00683f" +} diff --git a/apps/labrinth/.sqlx/query-aec58041cf5e5e68501652336581b8c709645ef29f3b5fb6e8e07fc212b36798.json b/apps/labrinth/.sqlx/query-aec58041cf5e5e68501652336581b8c709645ef29f3b5fb6e8e07fc212b36798.json new file mode 100644 index 000000000..2c517f367 --- /dev/null +++ b/apps/labrinth/.sqlx/query-aec58041cf5e5e68501652336581b8c709645ef29f3b5fb6e8e07fc212b36798.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT shared_instance_id, user_id, permissions\n FROM shared_instance_users\n WHERE shared_instance_id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "shared_instance_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "permissions", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "aec58041cf5e5e68501652336581b8c709645ef29f3b5fb6e8e07fc212b36798" +} diff --git a/apps/labrinth/.sqlx/query-b93253bbc35b24974d13bc8ee0447be2a18275f33f8991d910f693fbcc1ff731.json b/apps/labrinth/.sqlx/query-b93253bbc35b24974d13bc8ee0447be2a18275f33f8991d910f693fbcc1ff731.json new file mode 100644 index 000000000..a99713026 --- /dev/null +++ b/apps/labrinth/.sqlx/query-b93253bbc35b24974d13bc8ee0447be2a18275f33f8991d910f693fbcc1ff731.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, shared_instance_id, size, sha512, created\n FROM shared_instance_versions\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "shared_instance_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "size", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "sha512", + "type_info": "Bytea" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "b93253bbc35b24974d13bc8ee0447be2a18275f33f8991d910f693fbcc1ff731" +} diff --git a/apps/labrinth/.sqlx/query-c3869a595693757ccf81085d0c8eb2231578aff18c93d02ead97c3c07f0b27ea.json b/apps/labrinth/.sqlx/query-c3869a595693757ccf81085d0c8eb2231578aff18c93d02ead97c3c07f0b27ea.json new file mode 100644 index 000000000..b710ec2d4 --- /dev/null +++ b/apps/labrinth/.sqlx/query-c3869a595693757ccf81085d0c8eb2231578aff18c93d02ead97c3c07f0b27ea.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT permissions\n FROM shared_instance_users\n WHERE shared_instance_id = $1 AND user_id = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "permissions", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "c3869a595693757ccf81085d0c8eb2231578aff18c93d02ead97c3c07f0b27ea" +} diff --git a/apps/labrinth/.sqlx/query-cef730c02bb67b0536d35e5aaca0bd34c3893e8b55bbd126a988137ec7bf1ff9.json b/apps/labrinth/.sqlx/query-cef730c02bb67b0536d35e5aaca0bd34c3893e8b55bbd126a988137ec7bf1ff9.json new file mode 100644 index 000000000..d48180d91 --- /dev/null +++ b/apps/labrinth/.sqlx/query-cef730c02bb67b0536d35e5aaca0bd34c3893e8b55bbd126a988137ec7bf1ff9.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM shared_instances\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "cef730c02bb67b0536d35e5aaca0bd34c3893e8b55bbd126a988137ec7bf1ff9" +} diff --git a/apps/labrinth/.sqlx/query-d8558a8039ade3b383db4f0e095e6826f46c27ab3a21520e9e169fd1491521c4.json b/apps/labrinth/.sqlx/query-d8558a8039ade3b383db4f0e095e6826f46c27ab3a21520e9e169fd1491521c4.json new file mode 100644 index 000000000..81eef9932 --- /dev/null +++ b/apps/labrinth/.sqlx/query-d8558a8039ade3b383db4f0e095e6826f46c27ab3a21520e9e169fd1491521c4.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM shared_instances WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "d8558a8039ade3b383db4f0e095e6826f46c27ab3a21520e9e169fd1491521c4" +} diff --git a/apps/labrinth/.sqlx/query-d8a1d710f86b3df4d99c2d2ec26ec405531e4270be85087122245991ec88473e.json b/apps/labrinth/.sqlx/query-d8a1d710f86b3df4d99c2d2ec26ec405531e4270be85087122245991ec88473e.json new file mode 100644 index 000000000..0e0bb268f --- /dev/null +++ b/apps/labrinth/.sqlx/query-d8a1d710f86b3df4d99c2d2ec26ec405531e4270be85087122245991ec88473e.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO shared_instance_versions (id, shared_instance_id, size, sha512, created)\n VALUES ($1, $2, $3, $4, $5)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Bytea", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "d8a1d710f86b3df4d99c2d2ec26ec405531e4270be85087122245991ec88473e" +} diff --git a/apps/labrinth/.sqlx/query-f6388b5026e25191840d1a157a9ed48aaedab5db381f4efc389b852d9020a0e6.json b/apps/labrinth/.sqlx/query-f6388b5026e25191840d1a157a9ed48aaedab5db381f4efc389b852d9020a0e6.json new file mode 100644 index 000000000..c8514e0a3 --- /dev/null +++ b/apps/labrinth/.sqlx/query-f6388b5026e25191840d1a157a9ed48aaedab5db381f4efc389b852d9020a0e6.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE shared_instances\n SET current_version_id = (\n SELECT id FROM shared_instance_versions\n WHERE shared_instance_id = $1\n ORDER BY created DESC\n LIMIT 1\n )\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "f6388b5026e25191840d1a157a9ed48aaedab5db381f4efc389b852d9020a0e6" +} diff --git a/apps/labrinth/migrations/20250519184051_shared-instances.sql b/apps/labrinth/migrations/20250519184051_shared-instances.sql new file mode 100644 index 000000000..8f785403c --- /dev/null +++ b/apps/labrinth/migrations/20250519184051_shared-instances.sql @@ -0,0 +1,43 @@ +CREATE TABLE shared_instances ( + id BIGINT PRIMARY KEY, + title VARCHAR(255) NOT NULL, + owner_id BIGINT NOT NULL REFERENCES users, + current_version_id BIGINT NULL, + public BOOLEAN NOT NULL DEFAULT FALSE +); +CREATE INDEX shared_instances_owner_id ON shared_instances(owner_id); + +CREATE TABLE shared_instance_users ( + user_id BIGINT NOT NULL REFERENCES users ON DELETE CASCADE, + shared_instance_id BIGINT NOT NULL REFERENCES shared_instances ON DELETE CASCADE, + permissions BIGINT NOT NULL DEFAULT 0, + + PRIMARY KEY (user_id, shared_instance_id) +); + +CREATE TABLE shared_instance_invited_users ( + id BIGINT PRIMARY KEY, + shared_instance_id BIGINT NOT NULL REFERENCES shared_instances ON DELETE CASCADE, + invited_user_id BIGINT NULL REFERENCES users ON DELETE CASCADE +); +CREATE INDEX shared_instance_invited_users_shared_instance_id ON shared_instance_invited_users(shared_instance_id); +CREATE INDEX shared_instance_invited_users_invited_user_id ON shared_instance_invited_users(invited_user_id); + +CREATE TABLE shared_instance_invite_links ( + id BIGINT PRIMARY KEY, + shared_instance_id BIGINT NOT NULL REFERENCES shared_instances ON DELETE CASCADE, + expiration timestamptz NULL, + remaining_uses BIGINT CHECK ( remaining_uses >= 0 ) NULL +); +CREATE INDEX shared_instance_invite_links_shared_instance_id ON shared_instance_invite_links(shared_instance_id); + +CREATE TABLE shared_instance_versions ( + id BIGINT PRIMARY KEY, + shared_instance_id BIGINT NOT NULL REFERENCES shared_instances ON DELETE CASCADE, + size BIGINT NOT NULL, + sha512 bytea NOT NULL, + created timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +ALTER TABLE shared_instances +ADD FOREIGN KEY (current_version_id) REFERENCES shared_instance_versions(id) ON DELETE SET NULL; diff --git a/apps/labrinth/src/auth/oauth/errors.rs b/apps/labrinth/src/auth/oauth/errors.rs index ad23bf74c..06656a52e 100644 --- a/apps/labrinth/src/auth/oauth/errors.rs +++ b/apps/labrinth/src/auth/oauth/errors.rs @@ -9,7 +9,7 @@ use ariadne::ids::DecodingError; #[error("{}", .error_type)] pub struct OAuthError { #[source] - pub error_type: OAuthErrorType, + pub error_type: Box, pub state: Option, pub valid_redirect_uri: Option, @@ -32,7 +32,7 @@ impl OAuthError { /// See: IETF RFC 6749 4.1.2.1 (https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1) pub fn error(error_type: impl Into) -> Self { Self { - error_type: error_type.into(), + error_type: Box::new(error_type.into()), valid_redirect_uri: None, state: None, } @@ -48,7 +48,7 @@ impl OAuthError { valid_redirect_uri: &ValidatedRedirectUri, ) -> Self { Self { - error_type: err.into(), + error_type: Box::new(err.into()), state: state.clone(), valid_redirect_uri: Some(valid_redirect_uri.clone()), } @@ -57,7 +57,7 @@ impl OAuthError { impl actix_web::ResponseError for OAuthError { fn status_code(&self) -> StatusCode { - match self.error_type { + match *self.error_type { OAuthErrorType::AuthenticationError(_) | OAuthErrorType::FailedScopeParse(_) | OAuthErrorType::ScopesTooBroad diff --git a/apps/labrinth/src/auth/oauth/uris.rs b/apps/labrinth/src/auth/oauth/uris.rs index a3600dc7e..83b712946 100644 --- a/apps/labrinth/src/auth/oauth/uris.rs +++ b/apps/labrinth/src/auth/oauth/uris.rs @@ -101,7 +101,7 @@ mod tests { ); assert!(validated.is_err_and(|e| matches!( - e.error_type, + *e.error_type, OAuthErrorType::RedirectUriNotConfigured(_) ))); } diff --git a/apps/labrinth/src/auth/validate.rs b/apps/labrinth/src/auth/validate.rs index 21d085839..806eaa126 100644 --- a/apps/labrinth/src/auth/validate.rs +++ b/apps/labrinth/src/auth/validate.rs @@ -10,6 +10,40 @@ use actix_web::HttpRequest; use actix_web::http::header::{AUTHORIZATION, HeaderValue}; use chrono::Utc; +pub async fn get_maybe_user_from_headers<'a, E>( + req: &HttpRequest, + executor: E, + redis: &RedisPool, + session_queue: &AuthQueue, + required_scopes: Scopes, +) -> Result, AuthenticationError> +where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, +{ + if !req.headers().contains_key(AUTHORIZATION) { + return Ok(None); + } + + // Fetch DB user record and minos user from headers + let Some((scopes, db_user)) = get_user_record_from_bearer_token( + req, + None, + executor, + redis, + session_queue, + ) + .await? + else { + return Ok(None); + }; + + if !scopes.contains(required_scopes) { + return Ok(None); + } + + Ok(Some((scopes, User::from_full(db_user)))) +} + pub async fn get_user_from_headers<'a, E>( req: &HttpRequest, executor: E, diff --git a/apps/labrinth/src/database/models/ids.rs b/apps/labrinth/src/database/models/ids.rs index a7efc85ca..795862cef 100644 --- a/apps/labrinth/src/database/models/ids.rs +++ b/apps/labrinth/src/database/models/ids.rs @@ -3,8 +3,9 @@ use crate::models::ids::{ ChargeId, CollectionId, FileId, ImageId, NotificationId, OAuthAccessTokenId, OAuthClientAuthorizationId, OAuthClientId, OAuthRedirectUriId, OrganizationId, PatId, PayoutId, ProductId, - ProductPriceId, ProjectId, ReportId, SessionId, TeamId, TeamMemberId, - ThreadId, ThreadMessageId, UserSubscriptionId, VersionId, + ProductPriceId, ProjectId, ReportId, SessionId, SharedInstanceId, + SharedInstanceVersionId, TeamId, TeamMemberId, ThreadId, ThreadMessageId, + UserSubscriptionId, VersionId, }; use ariadne::ids::base62_impl::to_base62; use ariadne::ids::{UserId, random_base62_rng, random_base62_rng_range}; @@ -88,39 +89,50 @@ macro_rules! generate_bulk_ids { }; } +macro_rules! impl_db_id_interface { + ($id_struct:ident, $db_id_struct:ident, $(, generator: $generator_function:ident @ $db_table:expr, $(bulk_generator: $bulk_generator_function:ident,)?)?) => { + #[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, PartialEq, Eq, Hash)] + #[sqlx(transparent)] + pub struct $db_id_struct(pub i64); + + impl From<$id_struct> for $db_id_struct { + fn from(id: $id_struct) -> Self { + Self(id.0 as i64) + } + } + + impl From<$db_id_struct> for $id_struct { + fn from(id: $db_id_struct) -> Self { + Self(id.0 as u64) + } + } + + $( + generate_ids!( + $generator_function, + $db_id_struct, + "SELECT EXISTS(SELECT 1 FROM " + $db_table + " WHERE id=$1)" + ); + + $( + generate_bulk_ids!( + $bulk_generator_function, + $db_id_struct, + "SELECT EXISTS(SELECT 1 FROM " + $db_table + " WHERE id = ANY($1))" + ); + )? + )? + }; +} + macro_rules! db_id_interface { ($id_struct:ident $(, generator: $generator_function:ident @ $db_table:expr, $(bulk_generator: $bulk_generator_function:ident,)?)?) => { paste! { - #[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, PartialEq, Eq, Hash)] - #[sqlx(transparent)] - pub struct [< DB $id_struct >](pub i64); - - impl From<$id_struct> for [< DB $id_struct >] { - fn from(id: $id_struct) -> Self { - Self(id.0 as i64) - } - } - impl From<[< DB $id_struct >]> for $id_struct { - fn from(id: [< DB $id_struct >]) -> Self { - Self(id.0 as u64) - } - } - - $( - generate_ids!( - $generator_function, - [< DB $id_struct >], - "SELECT EXISTS(SELECT 1 FROM " + $db_table + " WHERE id=$1)" - ); - - $( - generate_bulk_ids!( - $bulk_generator_function, - [< DB $id_struct >], - "SELECT EXISTS(SELECT 1 FROM " + $db_table + " WHERE id = ANY($1))" - ); - )? - )? + impl_db_id_interface!( + $id_struct, + [< DB $id_struct >], + $(, generator: $generator_function @ $db_table, $(bulk_generator: $bulk_generator_function,)?)? + ); } }; } @@ -212,6 +224,14 @@ db_id_interface!( SessionId, generator: generate_session_id @ "sessions", ); +db_id_interface!( + SharedInstanceId, + generator: generate_shared_instance_id @ "shared_instances", +); +db_id_interface!( + SharedInstanceVersionId, + generator: generate_shared_instance_version_id @ "shared_instance_versions", +); db_id_interface!( TeamId, generator: generate_team_id @ "teams", diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs index 20dabaad0..6a051b436 100644 --- a/apps/labrinth/src/database/models/mod.rs +++ b/apps/labrinth/src/database/models/mod.rs @@ -20,6 +20,7 @@ pub mod product_item; pub mod project_item; pub mod report_item; pub mod session_item; +pub mod shared_instance_item; pub mod team_item; pub mod thread_item; pub mod user_item; diff --git a/apps/labrinth/src/database/models/shared_instance_item.rs b/apps/labrinth/src/database/models/shared_instance_item.rs new file mode 100644 index 000000000..2be240850 --- /dev/null +++ b/apps/labrinth/src/database/models/shared_instance_item.rs @@ -0,0 +1,335 @@ +use crate::database::models::{ + DBSharedInstanceId, DBSharedInstanceVersionId, DBUserId, +}; +use crate::database::redis::RedisPool; +use crate::models::shared_instances::SharedInstanceUserPermissions; +use chrono::{DateTime, Utc}; +use dashmap::DashMap; +use futures_util::TryStreamExt; +use serde::{Deserialize, Serialize}; + +//region shared_instances +pub struct DBSharedInstance { + pub id: DBSharedInstanceId, + pub title: String, + pub owner_id: DBUserId, + pub public: bool, + pub current_version_id: Option, +} + +struct SharedInstanceQueryResult { + id: i64, + title: String, + owner_id: i64, + public: bool, + current_version_id: Option, +} + +impl From for DBSharedInstance { + fn from(val: SharedInstanceQueryResult) -> Self { + DBSharedInstance { + id: DBSharedInstanceId(val.id), + title: val.title, + owner_id: DBUserId(val.owner_id), + public: val.public, + current_version_id: val + .current_version_id + .map(DBSharedInstanceVersionId), + } + } +} + +impl DBSharedInstance { + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + " + INSERT INTO shared_instances (id, title, owner_id, current_version_id) + VALUES ($1, $2, $3, $4) + ", + self.id as DBSharedInstanceId, + self.title, + self.owner_id as DBUserId, + self.current_version_id.map(|x| x.0), + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } + + pub async fn get( + id: DBSharedInstanceId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, sqlx::Error> { + let result = sqlx::query_as!( + SharedInstanceQueryResult, + " + SELECT id, title, owner_id, public, current_version_id + FROM shared_instances + WHERE id = $1 + ", + id.0, + ) + .fetch_optional(exec) + .await?; + + Ok(result.map(Into::into)) + } + + pub async fn list_for_user( + user: DBUserId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, sqlx::Error> { + let results = sqlx::query_as!( + SharedInstanceQueryResult, + r#" + -- See https://github.com/launchbadge/sqlx/issues/1266 for why we need all the "as" + SELECT + id as "id!", + title as "title!", + public as "public!", + owner_id as "owner_id!", + current_version_id + FROM shared_instances + WHERE owner_id = $1 + UNION + SELECT + id as "id!", + title as "title!", + public as "public!", + owner_id as "owner_id!", + current_version_id + FROM shared_instances + JOIN shared_instance_users ON id = shared_instance_id + WHERE user_id = $1 + "#, + user.0, + ) + .fetch_all(exec) + .await?; + + Ok(results.into_iter().map(Into::into).collect()) + } +} +//endregion + +//region shared_instance_users +const USERS_NAMESPACE: &str = "shared_instance_users"; + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct DBSharedInstanceUser { + pub user_id: DBUserId, + pub shared_instance_id: DBSharedInstanceId, + pub permissions: SharedInstanceUserPermissions, +} + +impl DBSharedInstanceUser { + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + " + INSERT INTO shared_instance_users (user_id, shared_instance_id, permissions) + VALUES ($1, $2, $3) + ", + self.user_id as DBUserId, + self.shared_instance_id as DBSharedInstanceId, + self.permissions.bits() as i64, + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } + + pub async fn get_user_permissions( + instance_id: DBSharedInstanceId, + user_id: DBUserId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, super::DatabaseError> + { + let permissions = sqlx::query!( + " + SELECT permissions + FROM shared_instance_users + WHERE shared_instance_id = $1 AND user_id = $2 + ", + instance_id as DBSharedInstanceId, + user_id as DBUserId, + ) + .fetch_optional(exec) + .await? + .map(|x| { + SharedInstanceUserPermissions::from_bits(x.permissions as u64) + .unwrap_or(SharedInstanceUserPermissions::empty()) + }); + + Ok(permissions) + } + + pub async fn get_from_instance( + instance_id: DBSharedInstanceId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + redis: &RedisPool, + ) -> Result, super::DatabaseError> { + Self::get_from_instance_many(&[instance_id], exec, redis).await + } + + pub async fn get_from_instance_many( + instance_ids: &[DBSharedInstanceId], + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + redis: &RedisPool, + ) -> Result, super::DatabaseError> { + if instance_ids.is_empty() { + return Ok(vec![]); + } + + let users = redis + .get_cached_keys( + USERS_NAMESPACE, + &instance_ids.iter().map(|id| id.0).collect::>(), + async |user_ids| { + let users = sqlx::query!( + " + SELECT shared_instance_id, user_id, permissions + FROM shared_instance_users + WHERE shared_instance_id = ANY($1) + ", + &user_ids + ) + .fetch(exec) + .try_fold(DashMap::new(), |acc: DashMap<_, Vec<_>>, m| { + acc.entry(m.shared_instance_id).or_default().push( + DBSharedInstanceUser { + user_id: DBUserId(m.user_id), + shared_instance_id: DBSharedInstanceId( + m.shared_instance_id, + ), + permissions: + SharedInstanceUserPermissions::from_bits( + m.permissions as u64, + ) + .unwrap_or( + SharedInstanceUserPermissions::empty(), + ), + }, + ); + + async move { Ok(acc) } + }) + .await?; + + Ok(users) + }, + ) + .await?; + + Ok(users.into_iter().flatten().collect()) + } + + pub async fn clear_cache( + instance_id: DBSharedInstanceId, + redis: &RedisPool, + ) -> Result<(), super::DatabaseError> { + let mut redis = redis.connect().await?; + redis.delete(USERS_NAMESPACE, instance_id.0).await?; + Ok(()) + } +} +//endregion + +//region shared_instance_versions +pub struct DBSharedInstanceVersion { + pub id: DBSharedInstanceVersionId, + pub shared_instance_id: DBSharedInstanceId, + pub size: u64, + pub sha512: Vec, + pub created: DateTime, +} + +struct SharedInstanceVersionQueryResult { + id: i64, + shared_instance_id: i64, + size: i64, + sha512: Vec, + created: DateTime, +} + +impl From for DBSharedInstanceVersion { + fn from(val: SharedInstanceVersionQueryResult) -> Self { + DBSharedInstanceVersion { + id: DBSharedInstanceVersionId(val.id), + shared_instance_id: DBSharedInstanceId(val.shared_instance_id), + size: val.size as u64, + sha512: val.sha512, + created: val.created, + } + } +} + +impl DBSharedInstanceVersion { + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), sqlx::Error> { + sqlx::query!( + " + INSERT INTO shared_instance_versions (id, shared_instance_id, size, sha512, created) + VALUES ($1, $2, $3, $4, $5) + ", + self.id as DBSharedInstanceVersionId, + self.shared_instance_id as DBSharedInstanceId, + self.size as i64, + self.sha512, + self.created, + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } + + pub async fn get( + id: DBSharedInstanceVersionId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, sqlx::Error> { + let result = sqlx::query_as!( + SharedInstanceVersionQueryResult, + " + SELECT id, shared_instance_id, size, sha512, created + FROM shared_instance_versions + WHERE id = $1 + ", + id as DBSharedInstanceVersionId, + ) + .fetch_optional(exec) + .await?; + + Ok(result.map(Into::into)) + } + + pub async fn get_for_instance( + instance_id: DBSharedInstanceId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, sqlx::Error> { + let results = sqlx::query_as!( + SharedInstanceVersionQueryResult, + " + SELECT id, shared_instance_id, size, sha512, created + FROM shared_instance_versions + WHERE shared_instance_id = $1 + ORDER BY created DESC + ", + instance_id as DBSharedInstanceId, + ) + .fetch_all(exec) + .await?; + + Ok(results.into_iter().map(Into::into).collect()) + } +} +//endregion diff --git a/apps/labrinth/src/database/models/user_item.rs b/apps/labrinth/src/database/models/user_item.rs index b29687094..6a2e4aba6 100644 --- a/apps/labrinth/src/database/models/user_item.rs +++ b/apps/labrinth/src/database/models/user_item.rs @@ -511,6 +511,18 @@ impl DBUser { .execute(&mut **transaction) .await?; + sqlx::query!( + " + UPDATE shared_instances + SET owner_id = $1 + WHERE owner_id = $2 + ", + deleted_user as DBUserId, + id as DBUserId, + ) + .execute(&mut **transaction) + .await?; + use futures::TryStreamExt; let notifications: Vec = sqlx::query!( " diff --git a/apps/labrinth/src/file_hosting/backblaze.rs b/apps/labrinth/src/file_hosting/backblaze.rs deleted file mode 100644 index 28d302245..000000000 --- a/apps/labrinth/src/file_hosting/backblaze.rs +++ /dev/null @@ -1,108 +0,0 @@ -use super::{DeleteFileData, FileHost, FileHostingError, UploadFileData}; -use async_trait::async_trait; -use bytes::Bytes; -use reqwest::Response; -use serde::Deserialize; -use sha2::Digest; - -mod authorization; -mod delete; -mod upload; - -pub struct BackblazeHost { - upload_url_data: authorization::UploadUrlData, - authorization_data: authorization::AuthorizationData, -} - -impl BackblazeHost { - pub async fn new(key_id: &str, key: &str, bucket_id: &str) -> Self { - let authorization_data = - authorization::authorize_account(key_id, key).await.unwrap(); - let upload_url_data = - authorization::get_upload_url(&authorization_data, bucket_id) - .await - .unwrap(); - - BackblazeHost { - upload_url_data, - authorization_data, - } - } -} - -#[async_trait] -impl FileHost for BackblazeHost { - async fn upload_file( - &self, - content_type: &str, - file_name: &str, - file_bytes: Bytes, - ) -> Result { - let content_sha512 = format!("{:x}", sha2::Sha512::digest(&file_bytes)); - - let upload_data = upload::upload_file( - &self.upload_url_data, - content_type, - file_name, - file_bytes, - ) - .await?; - Ok(UploadFileData { - file_id: upload_data.file_id, - file_name: upload_data.file_name, - content_length: upload_data.content_length, - content_sha512, - content_sha1: upload_data.content_sha1, - content_md5: upload_data.content_md5, - content_type: upload_data.content_type, - upload_timestamp: upload_data.upload_timestamp, - }) - } - - /* - async fn upload_file_streaming( - &self, - content_type: &str, - file_name: &str, - stream: reqwest::Body - ) -> Result { - use futures::stream::StreamExt; - - let mut data = Vec::new(); - while let Some(chunk) = stream.next().await { - data.extend_from_slice(&chunk.map_err(|e| FileHostingError::Other(e))?); - } - self.upload_file(content_type, file_name, data).await - } - */ - - async fn delete_file_version( - &self, - file_id: &str, - file_name: &str, - ) -> Result { - let delete_data = delete::delete_file_version( - &self.authorization_data, - file_id, - file_name, - ) - .await?; - Ok(DeleteFileData { - file_id: delete_data.file_id, - file_name: delete_data.file_name, - }) - } -} - -pub async fn process_response( - response: Response, -) -> Result -where - T: for<'de> Deserialize<'de>, -{ - if response.status().is_success() { - Ok(response.json().await?) - } else { - Err(FileHostingError::BackblazeError(response.json().await?)) - } -} diff --git a/apps/labrinth/src/file_hosting/backblaze/authorization.rs b/apps/labrinth/src/file_hosting/backblaze/authorization.rs deleted file mode 100644 index 9ab9e5982..000000000 --- a/apps/labrinth/src/file_hosting/backblaze/authorization.rs +++ /dev/null @@ -1,81 +0,0 @@ -use crate::file_hosting::FileHostingError; -use base64::Engine; -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct AuthorizationPermissions { - bucket_id: Option, - bucket_name: Option, - capabilities: Vec, - name_prefix: Option, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct AuthorizationData { - pub absolute_minimum_part_size: i32, - pub account_id: String, - pub allowed: AuthorizationPermissions, - pub api_url: String, - pub authorization_token: String, - pub download_url: String, - pub recommended_part_size: i32, -} - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct UploadUrlData { - pub bucket_id: String, - pub upload_url: String, - pub authorization_token: String, -} - -pub async fn authorize_account( - key_id: &str, - application_key: &str, -) -> Result { - let combined_key = format!("{key_id}:{application_key}"); - let formatted_key = format!( - "Basic {}", - base64::engine::general_purpose::STANDARD.encode(combined_key) - ); - - let response = reqwest::Client::new() - .get("https://api.backblazeb2.com/b2api/v2/b2_authorize_account") - .header(reqwest::header::CONTENT_TYPE, "application/json") - .header(reqwest::header::AUTHORIZATION, formatted_key) - .send() - .await?; - - super::process_response(response).await -} - -pub async fn get_upload_url( - authorization_data: &AuthorizationData, - bucket_id: &str, -) -> Result { - let response = reqwest::Client::new() - .post( - format!( - "{}/b2api/v2/b2_get_upload_url", - authorization_data.api_url - ) - .to_string(), - ) - .header(reqwest::header::CONTENT_TYPE, "application/json") - .header( - reqwest::header::AUTHORIZATION, - &authorization_data.authorization_token, - ) - .body( - serde_json::json!({ - "bucketId": bucket_id, - }) - .to_string(), - ) - .send() - .await?; - - super::process_response(response).await -} diff --git a/apps/labrinth/src/file_hosting/backblaze/delete.rs b/apps/labrinth/src/file_hosting/backblaze/delete.rs deleted file mode 100644 index 87e24ac3c..000000000 --- a/apps/labrinth/src/file_hosting/backblaze/delete.rs +++ /dev/null @@ -1,38 +0,0 @@ -use super::authorization::AuthorizationData; -use crate::file_hosting::FileHostingError; -use serde::{Deserialize, Serialize}; - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct DeleteFileData { - pub file_id: String, - pub file_name: String, -} - -pub async fn delete_file_version( - authorization_data: &AuthorizationData, - file_id: &str, - file_name: &str, -) -> Result { - let response = reqwest::Client::new() - .post(format!( - "{}/b2api/v2/b2_delete_file_version", - authorization_data.api_url - )) - .header(reqwest::header::CONTENT_TYPE, "application/json") - .header( - reqwest::header::AUTHORIZATION, - &authorization_data.authorization_token, - ) - .body( - serde_json::json!({ - "fileName": file_name, - "fileId": file_id - }) - .to_string(), - ) - .send() - .await?; - - super::process_response(response).await -} diff --git a/apps/labrinth/src/file_hosting/backblaze/upload.rs b/apps/labrinth/src/file_hosting/backblaze/upload.rs deleted file mode 100644 index 44bed4697..000000000 --- a/apps/labrinth/src/file_hosting/backblaze/upload.rs +++ /dev/null @@ -1,47 +0,0 @@ -use super::authorization::UploadUrlData; -use crate::file_hosting::FileHostingError; -use bytes::Bytes; -use hex::ToHex; -use serde::{Deserialize, Serialize}; -use sha1::Digest; - -#[derive(Serialize, Deserialize, Debug, Clone)] -#[serde(rename_all = "camelCase")] -pub struct UploadFileData { - pub file_id: String, - pub file_name: String, - pub account_id: String, - pub bucket_id: String, - pub content_length: u32, - pub content_sha1: String, - pub content_md5: Option, - pub content_type: String, - pub upload_timestamp: u64, -} - -//Content Types found here: https://www.backblaze.com/b2/docs/content-types.html -pub async fn upload_file( - url_data: &UploadUrlData, - content_type: &str, - file_name: &str, - file_bytes: Bytes, -) -> Result { - let response = reqwest::Client::new() - .post(&url_data.upload_url) - .header( - reqwest::header::AUTHORIZATION, - &url_data.authorization_token, - ) - .header("X-Bz-File-Name", file_name) - .header(reqwest::header::CONTENT_TYPE, content_type) - .header(reqwest::header::CONTENT_LENGTH, file_bytes.len()) - .header( - "X-Bz-Content-Sha1", - sha1::Sha1::digest(&file_bytes).encode_hex::(), - ) - .body(file_bytes) - .send() - .await?; - - super::process_response(response).await -} diff --git a/apps/labrinth/src/file_hosting/mock.rs b/apps/labrinth/src/file_hosting/mock.rs index aef633e58..c04f92420 100644 --- a/apps/labrinth/src/file_hosting/mock.rs +++ b/apps/labrinth/src/file_hosting/mock.rs @@ -1,9 +1,13 @@ -use super::{DeleteFileData, FileHost, FileHostingError, UploadFileData}; +use super::{ + DeleteFileData, FileHost, FileHostPublicity, FileHostingError, + UploadFileData, +}; use async_trait::async_trait; use bytes::Bytes; use chrono::Utc; use hex::ToHex; use sha2::Digest; +use std::path::PathBuf; #[derive(Default)] pub struct MockHost(()); @@ -20,11 +24,10 @@ impl FileHost for MockHost { &self, content_type: &str, file_name: &str, + file_publicity: FileHostPublicity, file_bytes: Bytes, ) -> Result { - let path = - std::path::Path::new(&dotenvy::var("MOCK_FILE_PATH").unwrap()) - .join(file_name.replace("../", "")); + let path = get_file_path(file_name, file_publicity); std::fs::create_dir_all( path.parent().ok_or(FileHostingError::InvalidFilename)?, )?; @@ -33,8 +36,8 @@ impl FileHost for MockHost { std::fs::write(path, &*file_bytes)?; Ok(UploadFileData { - file_id: String::from("MOCK_FILE_ID"), file_name: file_name.to_string(), + file_publicity, content_length: file_bytes.len() as u32, content_sha512, content_sha1, @@ -44,20 +47,40 @@ impl FileHost for MockHost { }) } - async fn delete_file_version( + async fn get_url_for_private_file( &self, - file_id: &str, file_name: &str, + _expiry_secs: u32, + ) -> Result { + let cdn_url = dotenvy::var("CDN_URL").unwrap(); + Ok(format!("{cdn_url}/private/{file_name}")) + } + + async fn delete_file( + &self, + file_name: &str, + file_publicity: FileHostPublicity, ) -> Result { - let path = - std::path::Path::new(&dotenvy::var("MOCK_FILE_PATH").unwrap()) - .join(file_name.replace("../", "")); + let path = get_file_path(file_name, file_publicity); if path.exists() { std::fs::remove_file(path)?; } Ok(DeleteFileData { - file_id: file_id.to_string(), file_name: file_name.to_string(), }) } } + +fn get_file_path( + file_name: &str, + file_publicity: FileHostPublicity, +) -> PathBuf { + let mut path = PathBuf::from(dotenvy::var("MOCK_FILE_PATH").unwrap()); + + if matches!(file_publicity, FileHostPublicity::Private) { + path.push("private"); + } + path.push(file_name.replace("../", "")); + + path +} diff --git a/apps/labrinth/src/file_hosting/mod.rs b/apps/labrinth/src/file_hosting/mod.rs index b89d35cbb..7de0ff6a9 100644 --- a/apps/labrinth/src/file_hosting/mod.rs +++ b/apps/labrinth/src/file_hosting/mod.rs @@ -1,23 +1,17 @@ use async_trait::async_trait; use thiserror::Error; -mod backblaze; mod mock; mod s3_host; -pub use backblaze::BackblazeHost; use bytes::Bytes; pub use mock::MockHost; -pub use s3_host::S3Host; +pub use s3_host::{S3BucketConfig, S3Host}; #[derive(Error, Debug)] pub enum FileHostingError { - #[error("Error while accessing the data from backblaze")] - HttpError(#[from] reqwest::Error), - #[error("Backblaze error: {0}")] - BackblazeError(serde_json::Value), - #[error("S3 error: {0}")] - S3Error(String), + #[error("S3 error when {0}: {1}")] + S3Error(&'static str, s3::error::S3Error), #[error("File system error in file hosting: {0}")] FileSystemError(#[from] std::io::Error), #[error("Invalid Filename")] @@ -26,8 +20,8 @@ pub enum FileHostingError { #[derive(Debug, Clone)] pub struct UploadFileData { - pub file_id: String, pub file_name: String, + pub file_publicity: FileHostPublicity, pub content_length: u32, pub content_sha512: String, pub content_sha1: String, @@ -38,22 +32,34 @@ pub struct UploadFileData { #[derive(Debug, Clone)] pub struct DeleteFileData { - pub file_id: String, pub file_name: String, } +#[derive(Debug, Copy, Clone)] +pub enum FileHostPublicity { + Public, + Private, +} + #[async_trait] pub trait FileHost { async fn upload_file( &self, content_type: &str, file_name: &str, + file_publicity: FileHostPublicity, file_bytes: Bytes, ) -> Result; - async fn delete_file_version( + async fn get_url_for_private_file( &self, - file_id: &str, file_name: &str, + expiry_secs: u32, + ) -> Result; + + async fn delete_file( + &self, + file_name: &str, + file_publicity: FileHostPublicity, ) -> Result; } diff --git a/apps/labrinth/src/file_hosting/s3_host.rs b/apps/labrinth/src/file_hosting/s3_host.rs index 369ee52d6..a1a7c02df 100644 --- a/apps/labrinth/src/file_hosting/s3_host.rs +++ b/apps/labrinth/src/file_hosting/s3_host.rs @@ -1,5 +1,6 @@ use crate::file_hosting::{ - DeleteFileData, FileHost, FileHostingError, UploadFileData, + DeleteFileData, FileHost, FileHostPublicity, FileHostingError, + UploadFileData, }; use async_trait::async_trait; use bytes::Bytes; @@ -10,50 +11,70 @@ use s3::creds::Credentials; use s3::region::Region; use sha2::Digest; +pub struct S3BucketConfig { + pub name: String, + pub uses_path_style: bool, + pub region: String, + pub url: String, + pub access_token: String, + pub secret: String, +} + pub struct S3Host { - bucket: Bucket, + public_bucket: Bucket, + private_bucket: Bucket, } impl S3Host { pub fn new( - bucket_name: &str, - bucket_region: &str, - url: &str, - access_token: &str, - secret: &str, + public_bucket: S3BucketConfig, + private_bucket: S3BucketConfig, ) -> Result { - let bucket = Bucket::new( - bucket_name, - if bucket_region == "r2" { - Region::R2 { - account_id: url.to_string(), - } - } else { - Region::Custom { - region: bucket_region.to_string(), - endpoint: url.to_string(), - } - }, - Credentials::new( - Some(access_token), - Some(secret), - None, - None, - None, - ) - .map_err(|_| { - FileHostingError::S3Error( - "Error while creating credentials".to_string(), + let create_bucket = + |config: S3BucketConfig| -> Result<_, FileHostingError> { + let mut bucket = Bucket::new( + "", + if config.region == "r2" { + Region::R2 { + account_id: config.url, + } + } else { + Region::Custom { + region: config.region, + endpoint: config.url, + } + }, + Credentials { + access_key: Some(config.access_token), + secret_key: Some(config.secret), + ..Credentials::anonymous().unwrap() + }, ) - })?, - ) - .map_err(|_| { - FileHostingError::S3Error( - "Error while creating Bucket instance".to_string(), - ) - })?; + .map_err(|e| { + FileHostingError::S3Error("creating Bucket instance", e) + })?; - Ok(S3Host { bucket: *bucket }) + bucket.name = config.name; + if config.uses_path_style { + bucket.set_path_style(); + } else { + bucket.set_subdomain_style(); + } + + Ok(bucket) + }; + + Ok(S3Host { + public_bucket: *create_bucket(public_bucket)?, + private_bucket: *create_bucket(private_bucket)?, + }) + } + + fn get_bucket(&self, publicity: FileHostPublicity) -> &Bucket { + match publicity { + FileHostPublicity::Public => &self.public_bucket, + FileHostPublicity::Private => &self.private_bucket, + } } } @@ -63,27 +84,24 @@ impl FileHost for S3Host { &self, content_type: &str, file_name: &str, + file_publicity: FileHostPublicity, file_bytes: Bytes, ) -> Result { let content_sha1 = sha1::Sha1::digest(&file_bytes).encode_hex(); let content_sha512 = format!("{:x}", sha2::Sha512::digest(&file_bytes)); - self.bucket + self.get_bucket(file_publicity) .put_object_with_content_type( format!("/{file_name}"), &file_bytes, content_type, ) .await - .map_err(|err| { - FileHostingError::S3Error(format!( - "Error while uploading file {file_name} to S3: {err}" - )) - })?; + .map_err(|e| FileHostingError::S3Error("uploading file", e))?; Ok(UploadFileData { - file_id: file_name.to_string(), file_name: file_name.to_string(), + file_publicity, content_length: file_bytes.len() as u32, content_sha512, content_sha1, @@ -93,22 +111,32 @@ impl FileHost for S3Host { }) } - async fn delete_file_version( + async fn get_url_for_private_file( &self, - file_id: &str, file_name: &str, + expiry_secs: u32, + ) -> Result { + let url = self + .private_bucket + .presign_get(format!("/{file_name}"), expiry_secs, None) + .await + .map_err(|e| { + FileHostingError::S3Error("generating presigned URL", e) + })?; + Ok(url) + } + + async fn delete_file( + &self, + file_name: &str, + file_publicity: FileHostPublicity, ) -> Result { - self.bucket + self.get_bucket(file_publicity) .delete_object(format!("/{file_name}")) .await - .map_err(|err| { - FileHostingError::S3Error(format!( - "Error while deleting file {file_name} to S3: {err}" - )) - })?; + .map_err(|e| FileHostingError::S3Error("deleting file", e))?; Ok(DeleteFileData { - file_id: file_id.to_string(), file_name: file_name.to_string(), }) } diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs index 99a75a2e2..4318934e8 100644 --- a/apps/labrinth/src/lib.rs +++ b/apps/labrinth/src/lib.rs @@ -334,7 +334,7 @@ pub fn app_config( pub fn check_env_vars() -> bool { let mut failed = false; - fn check_var(var: &'static str) -> bool { + fn check_var(var: &str) -> bool { let check = parse_var::(var).is_none(); if check { warn!( @@ -361,25 +361,33 @@ pub fn check_env_vars() -> bool { let storage_backend = dotenvy::var("STORAGE_BACKEND").ok(); match storage_backend.as_deref() { - Some("backblaze") => { - failed |= check_var::("BACKBLAZE_KEY_ID"); - failed |= check_var::("BACKBLAZE_KEY"); - failed |= check_var::("BACKBLAZE_BUCKET_ID"); - } Some("s3") => { - failed |= check_var::("S3_ACCESS_TOKEN"); - failed |= check_var::("S3_SECRET"); - failed |= check_var::("S3_URL"); - failed |= check_var::("S3_REGION"); - failed |= check_var::("S3_BUCKET_NAME"); + let mut check_var_set = |var_prefix| { + failed |= check_var::(&format!( + "S3_{var_prefix}_BUCKET_NAME" + )); + failed |= check_var::(&format!( + "S3_{var_prefix}_USES_PATH_STYLE_BUCKET" + )); + failed |= + check_var::(&format!("S3_{var_prefix}_REGION")); + failed |= check_var::(&format!("S3_{var_prefix}_URL")); + failed |= check_var::(&format!( + "S3_{var_prefix}_ACCESS_TOKEN" + )); + failed |= + check_var::(&format!("S3_{var_prefix}_SECRET")); + }; + + check_var_set("PUBLIC"); + check_var_set("PRIVATE"); } Some("local") => { failed |= check_var::("MOCK_FILE_PATH"); } Some(backend) => { warn!( - "Variable `STORAGE_BACKEND` contains an invalid value: {}. Expected \"backblaze\", \"s3\", or \"local\".", - backend + "Variable `STORAGE_BACKEND` contains an invalid value: {backend}. Expected \"s3\" or \"local\"." ); failed |= true; } diff --git a/apps/labrinth/src/main.rs b/apps/labrinth/src/main.rs index 065239a18..0e9bc762e 100644 --- a/apps/labrinth/src/main.rs +++ b/apps/labrinth/src/main.rs @@ -4,8 +4,9 @@ use actix_web_prom::PrometheusMetricsBuilder; use clap::Parser; use labrinth::background_task::BackgroundTask; use labrinth::database::redis::RedisPool; -use labrinth::file_hosting::S3Host; +use labrinth::file_hosting::{S3BucketConfig, S3Host}; use labrinth::search; +use labrinth::util::env::parse_var; use labrinth::util::ratelimit::rate_limit_middleware; use labrinth::{check_env_vars, clickhouse, database, file_hosting, queue}; use std::ffi::CStr; @@ -51,6 +52,7 @@ async fn main() -> std::io::Result<()> { if check_env_vars() { error!("Some environment variables are missing!"); + std::process::exit(1); } // DSN is from SENTRY_DSN env variable. @@ -93,24 +95,33 @@ async fn main() -> std::io::Result<()> { let file_host: Arc = match storage_backend.as_str() { - "backblaze" => Arc::new( - file_hosting::BackblazeHost::new( - &dotenvy::var("BACKBLAZE_KEY_ID").unwrap(), - &dotenvy::var("BACKBLAZE_KEY").unwrap(), - &dotenvy::var("BACKBLAZE_BUCKET_ID").unwrap(), + "s3" => { + let config_from_env = |bucket_type| S3BucketConfig { + name: parse_var(&format!("S3_{bucket_type}_BUCKET_NAME")) + .unwrap(), + uses_path_style: parse_var(&format!( + "S3_{bucket_type}_USES_PATH_STYLE_BUCKET" + )) + .unwrap(), + region: parse_var(&format!("S3_{bucket_type}_REGION")) + .unwrap(), + url: parse_var(&format!("S3_{bucket_type}_URL")).unwrap(), + access_token: parse_var(&format!( + "S3_{bucket_type}_ACCESS_TOKEN" + )) + .unwrap(), + secret: parse_var(&format!("S3_{bucket_type}_SECRET")) + .unwrap(), + }; + + Arc::new( + S3Host::new( + config_from_env("PUBLIC"), + config_from_env("PRIVATE"), + ) + .unwrap(), ) - .await, - ), - "s3" => Arc::new( - S3Host::new( - &dotenvy::var("S3_BUCKET_NAME").unwrap(), - &dotenvy::var("S3_REGION").unwrap(), - &dotenvy::var("S3_URL").unwrap(), - &dotenvy::var("S3_ACCESS_TOKEN").unwrap(), - &dotenvy::var("S3_SECRET").unwrap(), - ) - .unwrap(), - ), + } "local" => Arc::new(file_hosting::MockHost::new()), _ => panic!("Invalid storage backend specified. Aborting startup!"), }; diff --git a/apps/labrinth/src/models/mod.rs b/apps/labrinth/src/models/mod.rs index aea510d79..8b31a04c7 100644 --- a/apps/labrinth/src/models/mod.rs +++ b/apps/labrinth/src/models/mod.rs @@ -16,6 +16,7 @@ pub use v3::payouts; pub use v3::projects; pub use v3::reports; pub use v3::sessions; +pub use v3::shared_instances; pub use v3::teams; pub use v3::threads; pub use v3::users; diff --git a/apps/labrinth/src/models/v3/ids.rs b/apps/labrinth/src/models/v3/ids.rs index 2e1c9745e..a95729712 100644 --- a/apps/labrinth/src/models/v3/ids.rs +++ b/apps/labrinth/src/models/v3/ids.rs @@ -17,6 +17,8 @@ base62_id!(ProductPriceId); base62_id!(ProjectId); base62_id!(ReportId); base62_id!(SessionId); +base62_id!(SharedInstanceId); +base62_id!(SharedInstanceVersionId); base62_id!(TeamId); base62_id!(TeamMemberId); base62_id!(ThreadId); diff --git a/apps/labrinth/src/models/v3/mod.rs b/apps/labrinth/src/models/v3/mod.rs index d9ffb8451..c51c026fa 100644 --- a/apps/labrinth/src/models/v3/mod.rs +++ b/apps/labrinth/src/models/v3/mod.rs @@ -12,6 +12,7 @@ pub mod payouts; pub mod projects; pub mod reports; pub mod sessions; +pub mod shared_instances; pub mod teams; pub mod threads; pub mod users; diff --git a/apps/labrinth/src/models/v3/pats.rs b/apps/labrinth/src/models/v3/pats.rs index edfb557ee..fc700238e 100644 --- a/apps/labrinth/src/models/v3/pats.rs +++ b/apps/labrinth/src/models/v3/pats.rs @@ -100,6 +100,24 @@ bitflags::bitflags! { // only accessible by modrinth-issued sessions const SESSION_ACCESS = 1 << 39; + // create a shared instance + const SHARED_INSTANCE_CREATE = 1 << 40; + // read a shared instance + const SHARED_INSTANCE_READ = 1 << 41; + // write to a shared instance + const SHARED_INSTANCE_WRITE = 1 << 42; + // delete a shared instance + const SHARED_INSTANCE_DELETE = 1 << 43; + + // create a shared instance version + const SHARED_INSTANCE_VERSION_CREATE = 1 << 44; + // read a shared instance version + const SHARED_INSTANCE_VERSION_READ = 1 << 45; + // write to a shared instance version + const SHARED_INSTANCE_VERSION_WRITE = 1 << 46; + // delete a shared instance version + const SHARED_INSTANCE_VERSION_DELETE = 1 << 47; + const NONE = 0b0; } } diff --git a/apps/labrinth/src/models/v3/shared_instances.rs b/apps/labrinth/src/models/v3/shared_instances.rs new file mode 100644 index 000000000..abbf773f9 --- /dev/null +++ b/apps/labrinth/src/models/v3/shared_instances.rs @@ -0,0 +1,89 @@ +use crate::bitflags_serde_impl; +use crate::database::models::shared_instance_item::{ + DBSharedInstance, DBSharedInstanceUser, DBSharedInstanceVersion, +}; +use crate::models::ids::{SharedInstanceId, SharedInstanceVersionId}; +use ariadne::ids::UserId; +use bitflags::bitflags; +use chrono::{DateTime, Utc}; +use hex::ToHex; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SharedInstance { + pub id: SharedInstanceId, + pub title: String, + pub owner: UserId, + pub public: bool, + pub current_version: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub additional_users: Option>, +} + +impl SharedInstance { + pub fn from_db( + instance: DBSharedInstance, + users: Option>, + current_version: Option, + ) -> Self { + SharedInstance { + id: instance.id.into(), + title: instance.title, + owner: instance.owner_id.into(), + public: instance.public, + current_version: current_version.map(Into::into), + additional_users: users + .map(|x| x.into_iter().map(Into::into).collect()), + } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SharedInstanceVersion { + pub id: SharedInstanceVersionId, + pub shared_instance: SharedInstanceId, + pub size: u64, + pub sha512: String, + pub created: DateTime, +} + +impl From for SharedInstanceVersion { + fn from(value: DBSharedInstanceVersion) -> Self { + let version_id = value.id.into(); + let shared_instance_id = value.shared_instance_id.into(); + SharedInstanceVersion { + id: version_id, + shared_instance: shared_instance_id, + size: value.size, + sha512: value.sha512.encode_hex(), + created: value.created, + } + } +} + +bitflags! { + #[derive(Copy, Clone, Debug)] + pub struct SharedInstanceUserPermissions: u64 { + const EDIT = 1 << 0; + const DELETE = 1 << 1; + const UPLOAD_VERSION = 1 << 2; + const DELETE_VERSION = 1 << 3; + } +} + +bitflags_serde_impl!(SharedInstanceUserPermissions, u64); + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SharedInstanceUser { + pub user: UserId, + pub permissions: SharedInstanceUserPermissions, +} + +impl From for SharedInstanceUser { + fn from(user: DBSharedInstanceUser) -> Self { + SharedInstanceUser { + user: user.user_id.into(), + permissions: user.permissions, + } + } +} diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs index 01e35efb0..23d8db903 100644 --- a/apps/labrinth/src/routes/internal/flows.rs +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -4,7 +4,7 @@ use crate::auth::{AuthProvider, AuthenticationError, get_user_from_headers}; use crate::database::models::DBUser; use crate::database::models::flow_item::DBFlow; use crate::database::redis::RedisPool; -use crate::file_hosting::FileHost; +use crate::file_hosting::{FileHost, FileHostPublicity}; use crate::models::pats::Scopes; use crate::models::users::{Badges, Role}; use crate::queue::session::AuthQueue; @@ -136,6 +136,7 @@ impl TempUser { let upload_result = upload_image_optimized( &format!("user/{}", ariadne::ids::UserId::from(user_id)), + FileHostPublicity::Public, bytes, ext, Some(96), diff --git a/apps/labrinth/src/routes/v3/collections.rs b/apps/labrinth/src/routes/v3/collections.rs index 071cefadd..6f795de95 100644 --- a/apps/labrinth/src/routes/v3/collections.rs +++ b/apps/labrinth/src/routes/v3/collections.rs @@ -4,7 +4,7 @@ use crate::database::models::{ collection_item, generate_collection_id, project_item, }; use crate::database::redis::RedisPool; -use crate::file_hosting::FileHost; +use crate::file_hosting::{FileHost, FileHostPublicity}; use crate::models::collections::{Collection, CollectionStatus}; use crate::models::ids::{CollectionId, ProjectId}; use crate::models::pats::Scopes; @@ -12,7 +12,7 @@ use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::routes::v3::project_creation::CreateError; use crate::util::img::delete_old_images; -use crate::util::routes::read_from_payload; +use crate::util::routes::read_limited_from_payload; use crate::util::validate::validation_errors_to_string; use crate::{database, models}; use actix_web::web::Data; @@ -413,11 +413,12 @@ pub async fn collection_icon_edit( delete_old_images( collection_item.icon_url, collection_item.raw_icon_url, + FileHostPublicity::Public, &***file_host, ) .await?; - let bytes = read_from_payload( + let bytes = read_limited_from_payload( &mut payload, 262144, "Icons must be smaller than 256KiB", @@ -427,6 +428,7 @@ pub async fn collection_icon_edit( let collection_id: CollectionId = collection_item.id.into(); let upload_result = crate::util::img::upload_image_optimized( &format!("data/{collection_id}"), + FileHostPublicity::Public, bytes.freeze(), &ext.ext, Some(96), @@ -493,6 +495,7 @@ pub async fn delete_collection_icon( delete_old_images( collection_item.icon_url, collection_item.raw_icon_url, + FileHostPublicity::Public, &***file_host, ) .await?; diff --git a/apps/labrinth/src/routes/v3/images.rs b/apps/labrinth/src/routes/v3/images.rs index 90e54cbb8..93669a1aa 100644 --- a/apps/labrinth/src/routes/v3/images.rs +++ b/apps/labrinth/src/routes/v3/images.rs @@ -8,13 +8,13 @@ use crate::database::models::{ project_item, report_item, thread_item, version_item, }; use crate::database::redis::RedisPool; -use crate::file_hosting::FileHost; +use crate::file_hosting::{FileHost, FileHostPublicity}; use crate::models::ids::{ReportId, ThreadMessageId, VersionId}; use crate::models::images::{Image, ImageContext}; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::img::upload_image_optimized; -use crate::util::routes::read_from_payload; +use crate::util::routes::read_limited_from_payload; use actix_web::{HttpRequest, HttpResponse, web}; use serde::{Deserialize, Serialize}; use sqlx::PgPool; @@ -176,7 +176,7 @@ pub async fn images_add( } // Upload the image to the file host - let bytes = read_from_payload( + let bytes = read_limited_from_payload( &mut payload, 1_048_576, "Icons must be smaller than 1MiB", @@ -186,6 +186,7 @@ pub async fn images_add( let content_length = bytes.len(); let upload_result = upload_image_optimized( "data/cached_images", + FileHostPublicity::Public, // FIXME: Maybe use private images for threads bytes.freeze(), &data.ext, None, diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs index 336a773f1..9b5040a9f 100644 --- a/apps/labrinth/src/routes/v3/mod.rs +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -13,6 +13,8 @@ pub mod payouts; pub mod project_creation; pub mod projects; pub mod reports; +pub mod shared_instance_version_creation; +pub mod shared_instances; pub mod statistics; pub mod tags; pub mod teams; @@ -36,6 +38,8 @@ pub fn config(cfg: &mut web::ServiceConfig) { .configure(project_creation::config) .configure(projects::config) .configure(reports::config) + .configure(shared_instance_version_creation::config) + .configure(shared_instances::config) .configure(statistics::config) .configure(tags::config) .configure(teams::config) diff --git a/apps/labrinth/src/routes/v3/oauth_clients.rs b/apps/labrinth/src/routes/v3/oauth_clients.rs index b0fd6406f..e738c7e52 100644 --- a/apps/labrinth/src/routes/v3/oauth_clients.rs +++ b/apps/labrinth/src/routes/v3/oauth_clients.rs @@ -1,6 +1,9 @@ use std::{collections::HashSet, fmt::Display, sync::Arc}; use super::ApiError; +use crate::file_hosting::FileHostPublicity; +use crate::models::ids::OAuthClientId; +use crate::util::img::{delete_old_images, upload_image_optimized}; use crate::{ auth::{checks::ValidateAuthorized, get_user_from_headers}, database::{ @@ -23,7 +26,7 @@ use crate::{ }; use crate::{ file_hosting::FileHost, models::oauth_clients::DeleteOAuthClientQueryParam, - util::routes::read_from_payload, + util::routes::read_limited_from_payload, }; use actix_web::{ HttpRequest, HttpResponse, delete, get, patch, post, @@ -38,9 +41,6 @@ use serde::{Deserialize, Serialize}; use sqlx::PgPool; use validator::Validate; -use crate::models::ids::OAuthClientId; -use crate::util::img::{delete_old_images, upload_image_optimized}; - pub fn config(cfg: &mut web::ServiceConfig) { cfg.service( scope("oauth") @@ -381,11 +381,12 @@ pub async fn oauth_client_icon_edit( delete_old_images( client.icon_url.clone(), client.raw_icon_url.clone(), + FileHostPublicity::Public, &***file_host, ) .await?; - let bytes = read_from_payload( + let bytes = read_limited_from_payload( &mut payload, 262144, "Icons must be smaller than 256KiB", @@ -393,6 +394,7 @@ pub async fn oauth_client_icon_edit( .await?; let upload_result = upload_image_optimized( &format!("data/{client_id}"), + FileHostPublicity::Public, bytes.freeze(), &ext.ext, Some(96), @@ -447,6 +449,7 @@ pub async fn oauth_client_icon_delete( delete_old_images( client.icon_url.clone(), client.raw_icon_url.clone(), + FileHostPublicity::Public, &***file_host, ) .await?; diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs index 77eb67004..0f8fe35e1 100644 --- a/apps/labrinth/src/routes/v3/organizations.rs +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -8,14 +8,14 @@ use crate::database::models::{ DBOrganization, generate_organization_id, team_item, }; use crate::database::redis::RedisPool; -use crate::file_hosting::FileHost; +use crate::file_hosting::{FileHost, FileHostPublicity}; use crate::models::ids::OrganizationId; use crate::models::pats::Scopes; use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; use crate::queue::session::AuthQueue; use crate::routes::v3::project_creation::CreateError; use crate::util::img::delete_old_images; -use crate::util::routes::read_from_payload; +use crate::util::routes::read_limited_from_payload; use crate::util::validate::validation_errors_to_string; use crate::{database, models}; use actix_web::{HttpRequest, HttpResponse, web}; @@ -1088,11 +1088,12 @@ pub async fn organization_icon_edit( delete_old_images( organization_item.icon_url, organization_item.raw_icon_url, + FileHostPublicity::Public, &***file_host, ) .await?; - let bytes = read_from_payload( + let bytes = read_limited_from_payload( &mut payload, 262144, "Icons must be smaller than 256KiB", @@ -1102,6 +1103,7 @@ pub async fn organization_icon_edit( let organization_id: OrganizationId = organization_item.id.into(); let upload_result = crate::util::img::upload_image_optimized( &format!("data/{organization_id}"), + FileHostPublicity::Public, bytes.freeze(), &ext.ext, Some(96), @@ -1191,6 +1193,7 @@ pub async fn delete_organization_icon( delete_old_images( organization_item.icon_url, organization_item.raw_icon_url, + FileHostPublicity::Public, &***file_host, ) .await?; diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs index e7a9a33cf..cc5c89b1e 100644 --- a/apps/labrinth/src/routes/v3/project_creation.rs +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -6,7 +6,7 @@ use crate::database::models::loader_fields::{ use crate::database::models::thread_item::ThreadBuilder; use crate::database::models::{self, DBUser, image_item}; use crate::database::redis::RedisPool; -use crate::file_hosting::{FileHost, FileHostingError}; +use crate::file_hosting::{FileHost, FileHostPublicity, FileHostingError}; use crate::models::error::ApiError; use crate::models::ids::{ImageId, OrganizationId, ProjectId, VersionId}; use crate::models::images::{Image, ImageContext}; @@ -240,18 +240,16 @@ pub struct NewGalleryItem { } pub struct UploadedFile { - pub file_id: String, - pub file_name: String, + pub name: String, + pub publicity: FileHostPublicity, } pub async fn undo_uploads( file_host: &dyn FileHost, uploaded_files: &[UploadedFile], -) -> Result<(), CreateError> { +) -> Result<(), FileHostingError> { for file in uploaded_files { - file_host - .delete_file_version(&file.file_id, &file.file_name) - .await?; + file_host.delete_file(&file.name, file.publicity).await?; } Ok(()) } @@ -309,13 +307,13 @@ Get logged in user 2. Upload - Icon: check file format & size - - Upload to backblaze & record URL + - Upload to S3 & record URL - Project files - Check for matching version - File size limits? - Check file type - Eventually, malware scan - - Upload to backblaze & create VersionFileBuilder + - Upload to S3 & create VersionFileBuilder - 3. Creation @@ -334,7 +332,7 @@ async fn project_create_inner( redis: &RedisPool, session_queue: &AuthQueue, ) -> Result { - // The base URL for files uploaded to backblaze + // The base URL for files uploaded to S3 let cdn_url = dotenvy::var("CDN_URL")?; // The currently logged in user @@ -516,6 +514,7 @@ async fn project_create_inner( let url = format!("data/{project_id}/images"); let upload_result = upload_image_optimized( &url, + FileHostPublicity::Public, data.freeze(), file_extension, Some(350), @@ -526,8 +525,8 @@ async fn project_create_inner( .map_err(|e| CreateError::InvalidIconFormat(e.to_string()))?; uploaded_files.push(UploadedFile { - file_id: upload_result.raw_url_path.clone(), - file_name: upload_result.raw_url_path, + name: upload_result.raw_url_path, + publicity: FileHostPublicity::Public, }); gallery_urls.push(crate::models::projects::GalleryItem { url: upload_result.url, @@ -1010,6 +1009,7 @@ async fn process_icon_upload( .await?; let upload_result = crate::util::img::upload_image_optimized( &format!("data/{}", to_base62(id)), + FileHostPublicity::Public, data.freeze(), file_extension, Some(96), @@ -1020,13 +1020,13 @@ async fn process_icon_upload( .map_err(|e| CreateError::InvalidIconFormat(e.to_string()))?; uploaded_files.push(UploadedFile { - file_id: upload_result.raw_url_path.clone(), - file_name: upload_result.raw_url_path, + name: upload_result.raw_url_path, + publicity: FileHostPublicity::Public, }); uploaded_files.push(UploadedFile { - file_id: upload_result.url_path.clone(), - file_name: upload_result.url_path, + name: upload_result.url_path, + publicity: FileHostPublicity::Public, }); Ok(( diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs index e7f829e57..c6be6e5a6 100644 --- a/apps/labrinth/src/routes/v3/projects.rs +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -9,7 +9,7 @@ use crate::database::models::thread_item::ThreadMessageBuilder; use crate::database::models::{DBTeamMember, ids as db_ids, image_item}; use crate::database::redis::RedisPool; use crate::database::{self, models as db_models}; -use crate::file_hosting::FileHost; +use crate::file_hosting::{FileHost, FileHostPublicity}; use crate::models; use crate::models::ids::ProjectId; use crate::models::images::ImageContext; @@ -28,7 +28,7 @@ use crate::search::indexing::remove_documents; use crate::search::{SearchConfig, SearchError, search_for_project}; use crate::util::img; use crate::util::img::{delete_old_images, upload_image_optimized}; -use crate::util::routes::read_from_payload; +use crate::util::routes::read_limited_from_payload; use crate::util::validate::validation_errors_to_string; use actix_web::{HttpRequest, HttpResponse, web}; use ariadne::ids::base62_impl::parse_base62; @@ -1487,11 +1487,12 @@ pub async fn project_icon_edit( delete_old_images( project_item.inner.icon_url, project_item.inner.raw_icon_url, + FileHostPublicity::Public, &***file_host, ) .await?; - let bytes = read_from_payload( + let bytes = read_limited_from_payload( &mut payload, 262144, "Icons must be smaller than 256KiB", @@ -1501,6 +1502,7 @@ pub async fn project_icon_edit( let project_id: ProjectId = project_item.inner.id.into(); let upload_result = upload_image_optimized( &format!("data/{project_id}"), + FileHostPublicity::Public, bytes.freeze(), &ext.ext, Some(96), @@ -1597,6 +1599,7 @@ pub async fn delete_project_icon( delete_old_images( project_item.inner.icon_url, project_item.inner.raw_icon_url, + FileHostPublicity::Public, &***file_host, ) .await?; @@ -1709,7 +1712,7 @@ pub async fn add_gallery_item( } } - let bytes = read_from_payload( + let bytes = read_limited_from_payload( &mut payload, 2 * (1 << 20), "Gallery image exceeds the maximum of 2MiB.", @@ -1719,6 +1722,7 @@ pub async fn add_gallery_item( let id: ProjectId = project_item.inner.id.into(); let upload_result = upload_image_optimized( &format!("data/{id}/images"), + FileHostPublicity::Public, bytes.freeze(), &ext.ext, Some(350), @@ -2049,6 +2053,7 @@ pub async fn delete_gallery_item( delete_old_images( Some(item.image_url), Some(item.raw_image_url), + FileHostPublicity::Public, &***file_host, ) .await?; diff --git a/apps/labrinth/src/routes/v3/reports.rs b/apps/labrinth/src/routes/v3/reports.rs index 61a3cc223..8708054a8 100644 --- a/apps/labrinth/src/routes/v3/reports.rs +++ b/apps/labrinth/src/routes/v3/reports.rs @@ -14,11 +14,11 @@ use crate::models::threads::{MessageBody, ThreadType}; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::util::img; +use crate::util::routes::read_typed_from_payload; use actix_web::{HttpRequest, HttpResponse, web}; use ariadne::ids::UserId; use ariadne::ids::base62_impl::parse_base62; use chrono::Utc; -use futures::StreamExt; use serde::Deserialize; use sqlx::PgPool; use validator::Validate; @@ -63,15 +63,7 @@ pub async fn report_create( .await? .1; - let mut bytes = web::BytesMut::new(); - while let Some(item) = body.next().await { - bytes.extend_from_slice(&item.map_err(|_| { - ApiError::InvalidInput( - "Error while parsing request payload!".to_string(), - ) - })?); - } - let new_report: CreateReport = serde_json::from_slice(bytes.as_ref())?; + let new_report: CreateReport = read_typed_from_payload(&mut body).await?; let id = crate::database::models::generate_report_id(&mut transaction).await?; diff --git a/apps/labrinth/src/routes/v3/shared_instance_version_creation.rs b/apps/labrinth/src/routes/v3/shared_instance_version_creation.rs new file mode 100644 index 000000000..aaf35b6fb --- /dev/null +++ b/apps/labrinth/src/routes/v3/shared_instance_version_creation.rs @@ -0,0 +1,200 @@ +use crate::auth::get_user_from_headers; +use crate::database::models::shared_instance_item::{ + DBSharedInstance, DBSharedInstanceUser, DBSharedInstanceVersion, +}; +use crate::database::models::{ + DBSharedInstanceId, DBSharedInstanceVersionId, + generate_shared_instance_version_id, +}; +use crate::database::redis::RedisPool; +use crate::file_hosting::{FileHost, FileHostPublicity}; +use crate::models::ids::{SharedInstanceId, SharedInstanceVersionId}; +use crate::models::pats::Scopes; +use crate::models::shared_instances::{ + SharedInstanceUserPermissions, SharedInstanceVersion, +}; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use crate::routes::v3::project_creation::UploadedFile; +use crate::util::ext::MRPACK_MIME_TYPE; +use actix_web::http::header::ContentLength; +use actix_web::web::Data; +use actix_web::{HttpRequest, HttpResponse, web}; +use bytes::BytesMut; +use chrono::Utc; +use futures_util::StreamExt; +use hex::FromHex; +use sqlx::{PgPool, Postgres, Transaction}; +use std::sync::Arc; + +const MAX_FILE_SIZE: usize = 500 * 1024 * 1024; +const MAX_FILE_SIZE_TEXT: &str = "500 MB"; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.route( + "shared-instance/{id}/version", + web::post().to(shared_instance_version_create), + ); +} + +#[allow(clippy::too_many_arguments)] +pub async fn shared_instance_version_create( + req: HttpRequest, + pool: Data, + payload: web::Payload, + web::Header(ContentLength(content_length)): web::Header, + redis: Data, + file_host: Data>, + info: web::Path<(SharedInstanceId,)>, + session_queue: Data, +) -> Result { + if content_length > MAX_FILE_SIZE { + return Err(ApiError::InvalidInput(format!( + "File size exceeds the maximum limit of {MAX_FILE_SIZE_TEXT}" + ))); + } + + let mut transaction = pool.begin().await?; + let mut uploaded_files = vec![]; + + let result = shared_instance_version_create_inner( + req, + &pool, + payload, + content_length, + &redis, + &***file_host, + info.into_inner().0.into(), + &session_queue, + &mut transaction, + &mut uploaded_files, + ) + .await; + + if result.is_err() { + let undo_result = super::project_creation::undo_uploads( + &***file_host, + &uploaded_files, + ) + .await; + let rollback_result = transaction.rollback().await; + + undo_result?; + if let Err(e) = rollback_result { + return Err(e.into()); + } + } else { + transaction.commit().await?; + } + + result +} + +#[allow(clippy::too_many_arguments)] +async fn shared_instance_version_create_inner( + req: HttpRequest, + pool: &PgPool, + mut payload: web::Payload, + content_length: usize, + redis: &RedisPool, + file_host: &dyn FileHost, + instance_id: DBSharedInstanceId, + session_queue: &AuthQueue, + transaction: &mut Transaction<'_, Postgres>, + uploaded_files: &mut Vec, +) -> Result { + let user = get_user_from_headers( + &req, + pool, + redis, + session_queue, + Scopes::SHARED_INSTANCE_VERSION_CREATE, + ) + .await? + .1; + + let Some(instance) = DBSharedInstance::get(instance_id, pool).await? else { + return Err(ApiError::NotFound); + }; + if !user.role.is_mod() && instance.owner_id != user.id.into() { + let permissions = DBSharedInstanceUser::get_user_permissions( + instance_id, + user.id.into(), + pool, + ) + .await?; + if let Some(permissions) = permissions { + if !permissions + .contains(SharedInstanceUserPermissions::UPLOAD_VERSION) + { + return Err(ApiError::CustomAuthentication( + "You do not have permission to upload a version for this shared instance.".to_string() + )); + } + } else { + return Err(ApiError::NotFound); + } + } + + let version_id = + generate_shared_instance_version_id(&mut *transaction).await?; + + let mut file_data = BytesMut::new(); + while let Some(chunk) = payload.next().await { + let chunk = chunk.map_err(|_| { + ApiError::InvalidInput( + "Unable to parse bytes in payload sent!".to_string(), + ) + })?; + + if file_data.len() + chunk.len() <= MAX_FILE_SIZE { + file_data.extend_from_slice(&chunk); + } else { + file_data + .extend_from_slice(&chunk[..MAX_FILE_SIZE - file_data.len()]); + break; + } + } + + let file_data = file_data.freeze(); + let file_path = format!( + "shared_instance/{}.mrpack", + SharedInstanceVersionId::from(version_id), + ); + + let upload_data = file_host + .upload_file( + MRPACK_MIME_TYPE, + &file_path, + FileHostPublicity::Private, + file_data, + ) + .await?; + + uploaded_files.push(UploadedFile { + name: file_path, + publicity: upload_data.file_publicity, + }); + + let sha512 = Vec::::from_hex(upload_data.content_sha512).unwrap(); + + let new_version = DBSharedInstanceVersion { + id: version_id, + shared_instance_id: instance_id, + size: content_length as u64, + sha512, + created: Utc::now(), + }; + new_version.insert(transaction).await?; + + sqlx::query!( + "UPDATE shared_instances SET current_version_id = $1 WHERE id = $2", + new_version.id as DBSharedInstanceVersionId, + instance_id as DBSharedInstanceId, + ) + .execute(&mut **transaction) + .await?; + + let version: SharedInstanceVersion = new_version.into(); + Ok(HttpResponse::Created().json(version)) +} diff --git a/apps/labrinth/src/routes/v3/shared_instances.rs b/apps/labrinth/src/routes/v3/shared_instances.rs new file mode 100644 index 000000000..fa1fc402e --- /dev/null +++ b/apps/labrinth/src/routes/v3/shared_instances.rs @@ -0,0 +1,612 @@ +use crate::auth::get_user_from_headers; +use crate::auth::validate::get_maybe_user_from_headers; +use crate::database::models::shared_instance_item::{ + DBSharedInstance, DBSharedInstanceUser, DBSharedInstanceVersion, +}; +use crate::database::models::{ + DBSharedInstanceId, DBSharedInstanceVersionId, generate_shared_instance_id, +}; +use crate::database::redis::RedisPool; +use crate::file_hosting::FileHost; +use crate::models::ids::{SharedInstanceId, SharedInstanceVersionId}; +use crate::models::pats::Scopes; +use crate::models::shared_instances::{ + SharedInstance, SharedInstanceUserPermissions, SharedInstanceVersion, +}; +use crate::models::users::User; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use crate::util::routes::read_typed_from_payload; +use actix_web::web::{Data, Redirect}; +use actix_web::{HttpRequest, HttpResponse, web}; +use futures_util::future::try_join_all; +use serde::Deserialize; +use sqlx::PgPool; +use std::sync::Arc; +use validator::Validate; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.route("shared-instance", web::post().to(shared_instance_create)); + cfg.route("shared-instance", web::get().to(shared_instance_list)); + cfg.service( + web::scope("shared-instance") + .route("{id}", web::get().to(shared_instance_get)) + .route("{id}", web::patch().to(shared_instance_edit)) + .route("{id}", web::delete().to(shared_instance_delete)) + .route("{id}/version", web::get().to(shared_instance_version_list)), + ); + cfg.service( + web::scope("shared-instance-version") + .route("{id}", web::get().to(shared_instance_version_get)) + .route("{id}", web::delete().to(shared_instance_version_delete)) + .route( + "{id}/download", + web::get().to(shared_instance_version_download), + ), + ); +} + +#[derive(Deserialize, Validate)] +pub struct CreateSharedInstance { + #[validate( + length(min = 3, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + pub title: String, + #[serde(default)] + pub public: bool, +} + +pub async fn shared_instance_create( + req: HttpRequest, + pool: Data, + mut body: web::Payload, + redis: Data, + session_queue: Data, +) -> Result { + let new_instance: CreateSharedInstance = + read_typed_from_payload(&mut body).await?; + + let mut transaction = pool.begin().await?; + + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SHARED_INSTANCE_CREATE, + ) + .await? + .1; + + let id = generate_shared_instance_id(&mut transaction).await?; + + let instance = DBSharedInstance { + id, + title: new_instance.title, + owner_id: user.id.into(), + public: new_instance.public, + current_version_id: None, + }; + instance.insert(&mut transaction).await?; + + transaction.commit().await?; + + Ok(HttpResponse::Created().json(SharedInstance { + id: id.into(), + title: instance.title, + owner: user.id, + public: instance.public, + current_version: None, + additional_users: Some(vec![]), + })) +} + +pub async fn shared_instance_list( + req: HttpRequest, + pool: Data, + redis: Data, + session_queue: Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SHARED_INSTANCE_READ, + ) + .await? + .1; + + // TODO: Something for moderators to be able to see all instances? + let instances = + DBSharedInstance::list_for_user(user.id.into(), &**pool).await?; + let instances = try_join_all(instances.into_iter().map( + async |instance| -> Result { + let version = if let Some(version_id) = instance.current_version_id + { + DBSharedInstanceVersion::get(version_id, &**pool).await? + } else { + None + }; + let instance_id = instance.id; + Ok(SharedInstance::from_db( + instance, + Some( + DBSharedInstanceUser::get_from_instance( + instance_id, + &**pool, + &redis, + ) + .await?, + ), + version, + )) + }, + )) + .await?; + + Ok(HttpResponse::Ok().json(instances)) +} + +pub async fn shared_instance_get( + req: HttpRequest, + pool: Data, + redis: Data, + info: web::Path<(SharedInstanceId,)>, + session_queue: Data, +) -> Result { + let id = info.into_inner().0.into(); + + let user = get_maybe_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SHARED_INSTANCE_READ, + ) + .await? + .map(|(_, user)| user); + + let shared_instance = DBSharedInstance::get(id, &**pool).await?; + + if let Some(shared_instance) = shared_instance { + let users = + DBSharedInstanceUser::get_from_instance(id, &**pool, &redis) + .await?; + + let privately_accessible = user.is_some_and(|user| { + can_access_instance_privately(&shared_instance, &users, &user) + }); + if !shared_instance.public && !privately_accessible { + return Err(ApiError::NotFound); + } + + let current_version = + if let Some(version_id) = shared_instance.current_version_id { + DBSharedInstanceVersion::get(version_id, &**pool).await? + } else { + None + }; + let shared_instance = SharedInstance::from_db( + shared_instance, + privately_accessible.then_some(users), + current_version, + ); + + Ok(HttpResponse::Ok().json(shared_instance)) + } else { + Err(ApiError::NotFound) + } +} + +fn can_access_instance_privately( + instance: &DBSharedInstance, + users: &[DBSharedInstanceUser], + user: &User, +) -> bool { + user.role.is_mod() + || instance.owner_id == user.id.into() + || users.iter().any(|x| x.user_id == user.id.into()) +} + +#[derive(Deserialize, Validate)] +pub struct EditSharedInstance { + #[validate( + length(min = 3, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + pub title: Option, + pub public: Option, +} + +pub async fn shared_instance_edit( + req: HttpRequest, + pool: Data, + mut body: web::Payload, + redis: Data, + info: web::Path<(SharedInstanceId,)>, + session_queue: Data, +) -> Result { + let id = info.into_inner().0.into(); + let edit_instance: EditSharedInstance = + read_typed_from_payload(&mut body).await?; + + let mut transaction = pool.begin().await?; + + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SHARED_INSTANCE_WRITE, + ) + .await? + .1; + + let Some(instance) = DBSharedInstance::get(id, &**pool).await? else { + return Err(ApiError::NotFound); + }; + + if !user.role.is_mod() && instance.owner_id != user.id.into() { + let permissions = DBSharedInstanceUser::get_user_permissions( + id, + user.id.into(), + &**pool, + ) + .await?; + if let Some(permissions) = permissions { + if !permissions.contains(SharedInstanceUserPermissions::EDIT) { + return Err(ApiError::CustomAuthentication( + "You do not have permission to edit this shared instance." + .to_string(), + )); + } + } else { + return Err(ApiError::NotFound); + } + } + + if let Some(title) = edit_instance.title { + sqlx::query!( + " + UPDATE shared_instances + SET title = $1 + WHERE id = $2 + ", + title, + id as DBSharedInstanceId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(public) = edit_instance.public { + sqlx::query!( + " + UPDATE shared_instances + SET public = $1 + WHERE id = $2 + ", + public, + id as DBSharedInstanceId, + ) + .execute(&mut *transaction) + .await?; + } + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) +} + +pub async fn shared_instance_delete( + req: HttpRequest, + pool: Data, + redis: Data, + info: web::Path<(SharedInstanceId,)>, + session_queue: Data, +) -> Result { + let id: DBSharedInstanceId = info.into_inner().0.into(); + + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SHARED_INSTANCE_DELETE, + ) + .await? + .1; + + let Some(instance) = DBSharedInstance::get(id, &**pool).await? else { + return Err(ApiError::NotFound); + }; + + if !user.role.is_mod() && instance.owner_id != user.id.into() { + let permissions = DBSharedInstanceUser::get_user_permissions( + id, + user.id.into(), + &**pool, + ) + .await?; + if let Some(permissions) = permissions { + if !permissions.contains(SharedInstanceUserPermissions::DELETE) { + return Err(ApiError::CustomAuthentication( + "You do not have permission to delete this shared instance.".to_string() + )); + } + } else { + return Err(ApiError::NotFound); + } + } + + sqlx::query!( + " + DELETE FROM shared_instances + WHERE id = $1 + ", + id as DBSharedInstanceId, + ) + .execute(&**pool) + .await?; + + DBSharedInstanceUser::clear_cache(id, &redis).await?; + + Ok(HttpResponse::NoContent().body("")) +} + +pub async fn shared_instance_version_list( + req: HttpRequest, + pool: Data, + redis: Data, + info: web::Path<(SharedInstanceId,)>, + session_queue: Data, +) -> Result { + let id = info.into_inner().0.into(); + + let user = get_maybe_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SHARED_INSTANCE_READ, + ) + .await? + .map(|(_, user)| user); + + let shared_instance = DBSharedInstance::get(id, &**pool).await?; + + if let Some(shared_instance) = shared_instance { + if !can_access_instance_as_maybe_user( + &pool, + &redis, + &shared_instance, + user, + ) + .await? + { + return Err(ApiError::NotFound); + } + + let versions = + DBSharedInstanceVersion::get_for_instance(id, &**pool).await?; + let versions = versions + .into_iter() + .map(Into::into) + .collect::>(); + + Ok(HttpResponse::Ok().json(versions)) + } else { + Err(ApiError::NotFound) + } +} + +pub async fn shared_instance_version_get( + req: HttpRequest, + pool: Data, + redis: Data, + info: web::Path<(SharedInstanceVersionId,)>, + session_queue: Data, +) -> Result { + let version_id = info.into_inner().0.into(); + + let user = get_maybe_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SHARED_INSTANCE_READ, + ) + .await? + .map(|(_, user)| user); + + let version = DBSharedInstanceVersion::get(version_id, &**pool).await?; + + if let Some(version) = version { + let instance = + DBSharedInstance::get(version.shared_instance_id, &**pool).await?; + if let Some(instance) = instance { + if !can_access_instance_as_maybe_user( + &pool, &redis, &instance, user, + ) + .await? + { + return Err(ApiError::NotFound); + } + + let version: SharedInstanceVersion = version.into(); + Ok(HttpResponse::Ok().json(version)) + } else { + Err(ApiError::NotFound) + } + } else { + Err(ApiError::NotFound) + } +} + +async fn can_access_instance_as_maybe_user( + pool: &PgPool, + redis: &RedisPool, + instance: &DBSharedInstance, + user: Option, +) -> Result { + if instance.public { + return Ok(true); + } + let users = + DBSharedInstanceUser::get_from_instance(instance.id, pool, redis) + .await?; + Ok(user.is_some_and(|user| { + can_access_instance_privately(instance, &users, &user) + })) +} + +pub async fn shared_instance_version_delete( + req: HttpRequest, + pool: Data, + redis: Data, + info: web::Path<(SharedInstanceVersionId,)>, + session_queue: Data, +) -> Result { + let version_id = info.into_inner().0.into(); + + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SHARED_INSTANCE_VERSION_DELETE, + ) + .await? + .1; + + let shared_instance_version = + DBSharedInstanceVersion::get(version_id, &**pool).await?; + + if let Some(shared_instance_version) = shared_instance_version { + let shared_instance = DBSharedInstance::get( + shared_instance_version.shared_instance_id, + &**pool, + ) + .await?; + if let Some(shared_instance) = shared_instance { + if !user.role.is_mod() && shared_instance.owner_id != user.id.into() + { + let permissions = DBSharedInstanceUser::get_user_permissions( + shared_instance.id, + user.id.into(), + &**pool, + ) + .await?; + if let Some(permissions) = permissions { + if !permissions + .contains(SharedInstanceUserPermissions::DELETE) + { + return Err(ApiError::CustomAuthentication( + "You do not have permission to delete this shared instance version.".to_string() + )); + } + } else { + return Err(ApiError::NotFound); + } + } + + delete_instance_version(shared_instance.id, version_id, &pool) + .await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::NotFound) + } + } else { + Err(ApiError::NotFound) + } +} + +async fn delete_instance_version( + instance_id: DBSharedInstanceId, + version_id: DBSharedInstanceVersionId, + pool: &PgPool, +) -> Result<(), ApiError> { + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + DELETE FROM shared_instance_versions + WHERE id = $1 + ", + version_id as DBSharedInstanceVersionId, + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + " + UPDATE shared_instances + SET current_version_id = ( + SELECT id FROM shared_instance_versions + WHERE shared_instance_id = $1 + ORDER BY created DESC + LIMIT 1 + ) + WHERE id = $1 + ", + instance_id as DBSharedInstanceId, + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + Ok(()) +} + +pub async fn shared_instance_version_download( + req: HttpRequest, + pool: Data, + redis: Data, + file_host: Data>, + info: web::Path<(SharedInstanceVersionId,)>, + session_queue: Data, +) -> Result { + let version_id = info.into_inner().0.into(); + + let user = get_maybe_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Scopes::SHARED_INSTANCE_VERSION_READ, + ) + .await? + .map(|(_, user)| user); + + let version = DBSharedInstanceVersion::get(version_id, &**pool).await?; + + if let Some(version) = version { + let instance = + DBSharedInstance::get(version.shared_instance_id, &**pool).await?; + if let Some(instance) = instance { + if !can_access_instance_as_maybe_user( + &pool, &redis, &instance, user, + ) + .await? + { + return Err(ApiError::NotFound); + } + + let file_name = format!( + "shared_instance/{}.mrpack", + SharedInstanceVersionId::from(version_id) + ); + let url = + file_host.get_url_for_private_file(&file_name, 180).await?; + + Ok(Redirect::to(url).see_other()) + } else { + Err(ApiError::NotFound) + } + } else { + Err(ApiError::NotFound) + } +} diff --git a/apps/labrinth/src/routes/v3/threads.rs b/apps/labrinth/src/routes/v3/threads.rs index 0939ae720..364b94a44 100644 --- a/apps/labrinth/src/routes/v3/threads.rs +++ b/apps/labrinth/src/routes/v3/threads.rs @@ -6,7 +6,7 @@ use crate::database::models::image_item; use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::thread_item::ThreadMessageBuilder; use crate::database::redis::RedisPool; -use crate::file_hosting::FileHost; +use crate::file_hosting::{FileHost, FileHostPublicity}; use crate::models::ids::{ThreadId, ThreadMessageId}; use crate::models::images::{Image, ImageContext}; use crate::models::notifications::NotificationBody; @@ -606,7 +606,12 @@ pub async fn message_delete( for image in images { let name = image.url.split(&format!("{cdn_url}/")).nth(1); if let Some(icon_path) = name { - file_host.delete_file_version("", icon_path).await?; + file_host + .delete_file( + icon_path, + FileHostPublicity::Public, // FIXME: Consider using private file storage? + ) + .await?; } database::DBImage::remove(image.id, &mut transaction, &redis) .await?; diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs index 4ce13a411..d11c6b2d3 100644 --- a/apps/labrinth/src/routes/v3/users.rs +++ b/apps/labrinth/src/routes/v3/users.rs @@ -1,6 +1,7 @@ use std::{collections::HashMap, sync::Arc}; use super::{ApiError, oauth_clients::get_user_clients}; +use crate::file_hosting::FileHostPublicity; use crate::util::img::delete_old_images; use crate::{ auth::{filter_visible_projects, get_user_from_headers}, @@ -14,7 +15,10 @@ use crate::{ users::{Badges, Role}, }, queue::session::AuthQueue, - util::{routes::read_from_payload, validate::validation_errors_to_string}, + util::{ + routes::read_limited_from_payload, + validate::validation_errors_to_string, + }, }; use actix_web::{HttpRequest, HttpResponse, web}; use ariadne::ids::UserId; @@ -576,11 +580,12 @@ pub async fn user_icon_edit( delete_old_images( actual_user.avatar_url, actual_user.raw_avatar_url, + FileHostPublicity::Public, &***file_host, ) .await?; - let bytes = read_from_payload( + let bytes = read_limited_from_payload( &mut payload, 262144, "Icons must be smaller than 256KiB", @@ -590,6 +595,7 @@ pub async fn user_icon_edit( let user_id: UserId = actual_user.id.into(); let upload_result = crate::util::img::upload_image_optimized( &format!("data/{user_id}"), + FileHostPublicity::Public, bytes.freeze(), &ext.ext, Some(96), @@ -648,6 +654,7 @@ pub async fn user_icon_delete( delete_old_images( actual_user.avatar_url, actual_user.raw_avatar_url, + FileHostPublicity::Public, &***file_host, ) .await?; diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs index 65a299d51..d992fd6ca 100644 --- a/apps/labrinth/src/routes/v3/version_creation.rs +++ b/apps/labrinth/src/routes/v3/version_creation.rs @@ -9,7 +9,7 @@ use crate::database::models::version_item::{ }; use crate::database::models::{self, DBOrganization, image_item}; use crate::database::redis::RedisPool; -use crate::file_hosting::FileHost; +use crate::file_hosting::{FileHost, FileHostPublicity}; use crate::models::ids::{ImageId, ProjectId, VersionId}; use crate::models::images::{Image, ImageContext}; use crate::models::notifications::NotificationBody; @@ -952,12 +952,12 @@ pub async fn upload_file( format!("data/{}/versions/{}/{}", project_id, version_id, &file_name); let upload_data = file_host - .upload_file(content_type, &file_path, data) + .upload_file(content_type, &file_path, FileHostPublicity::Public, data) .await?; uploaded_files.push(UploadedFile { - file_id: upload_data.file_id, - file_name: file_path, + name: file_path, + publicity: FileHostPublicity::Public, }); let sha1_bytes = upload_data.content_sha1.into_bytes(); diff --git a/apps/labrinth/src/util/env.rs b/apps/labrinth/src/util/env.rs index 9de970c6f..78a5b72de 100644 --- a/apps/labrinth/src/util/env.rs +++ b/apps/labrinth/src/util/env.rs @@ -1,6 +1,6 @@ use std::str::FromStr; -pub fn parse_var(var: &'static str) -> Option { +pub fn parse_var(var: &str) -> Option { dotenvy::var(var).ok().and_then(|i| i.parse().ok()) } pub fn parse_strings_from_var(var: &'static str) -> Option> { diff --git a/apps/labrinth/src/util/ext.rs b/apps/labrinth/src/util/ext.rs index 1f2e9fd38..b54256829 100644 --- a/apps/labrinth/src/util/ext.rs +++ b/apps/labrinth/src/util/ext.rs @@ -1,3 +1,5 @@ +pub const MRPACK_MIME_TYPE: &str = "application/x-modrinth-modpack+zip"; + pub fn get_image_content_type(extension: &str) -> Option<&'static str> { match extension { "bmp" => Some("image/bmp"), @@ -24,7 +26,7 @@ pub fn project_file_type(ext: &str) -> Option<&str> { match ext { "jar" => Some("application/java-archive"), "zip" | "litemod" => Some("application/zip"), - "mrpack" => Some("application/x-modrinth-modpack+zip"), + "mrpack" => Some(MRPACK_MIME_TYPE), _ => None, } } diff --git a/apps/labrinth/src/util/img.rs b/apps/labrinth/src/util/img.rs index 41bb108ed..680b48417 100644 --- a/apps/labrinth/src/util/img.rs +++ b/apps/labrinth/src/util/img.rs @@ -1,7 +1,7 @@ use crate::database; use crate::database::models::image_item; use crate::database::redis::RedisPool; -use crate::file_hosting::FileHost; +use crate::file_hosting::{FileHost, FileHostPublicity}; use crate::models::images::ImageContext; use crate::routes::ApiError; use color_thief::ColorFormat; @@ -38,11 +38,14 @@ pub struct UploadImageResult { pub raw_url: String, pub raw_url_path: String, + pub publicity: FileHostPublicity, + pub color: Option, } pub async fn upload_image_optimized( upload_folder: &str, + publicity: FileHostPublicity, bytes: bytes::Bytes, file_extension: &str, target_width: Option, @@ -80,6 +83,7 @@ pub async fn upload_image_optimized( target_width.unwrap_or(0), processed_image_ext ), + publicity, processed_image, ) .await?, @@ -92,6 +96,7 @@ pub async fn upload_image_optimized( .upload_file( content_type, &format!("{upload_folder}/{hash}.{file_extension}"), + publicity, bytes, ) .await?; @@ -107,6 +112,9 @@ pub async fn upload_image_optimized( raw_url: url, raw_url_path: upload_data.file_name, + + publicity, + color, }) } @@ -165,6 +173,7 @@ fn convert_to_webp(img: &DynamicImage) -> Result, ImageError> { pub async fn delete_old_images( image_url: Option, raw_image_url: Option, + publicity: FileHostPublicity, file_host: &dyn FileHost, ) -> Result<(), ApiError> { let cdn_url = dotenvy::var("CDN_URL")?; @@ -173,7 +182,7 @@ pub async fn delete_old_images( let name = image_url.split(&cdn_url_start).nth(1); if let Some(icon_path) = name { - file_host.delete_file_version("", icon_path).await?; + file_host.delete_file(icon_path, publicity).await?; } } @@ -181,7 +190,7 @@ pub async fn delete_old_images( let name = raw_image_url.split(&cdn_url_start).nth(1); if let Some(icon_path) = name { - file_host.delete_file_version("", icon_path).await?; + file_host.delete_file(icon_path, publicity).await?; } } diff --git a/apps/labrinth/src/util/routes.rs b/apps/labrinth/src/util/routes.rs index 4766ef81f..c96393721 100644 --- a/apps/labrinth/src/util/routes.rs +++ b/apps/labrinth/src/util/routes.rs @@ -1,11 +1,14 @@ use crate::routes::ApiError; use crate::routes::v3::project_creation::CreateError; +use crate::util::validate::validation_errors_to_string; use actix_multipart::Field; use actix_web::web::Payload; use bytes::BytesMut; use futures::StreamExt; +use serde::de::DeserializeOwned; +use validator::Validate; -pub async fn read_from_payload( +pub async fn read_limited_from_payload( payload: &mut Payload, cap: usize, err_msg: &'static str, @@ -25,6 +28,28 @@ pub async fn read_from_payload( Ok(bytes) } +pub async fn read_typed_from_payload( + payload: &mut Payload, +) -> Result +where + T: DeserializeOwned + Validate, +{ + let mut bytes = BytesMut::new(); + while let Some(item) = payload.next().await { + bytes.extend_from_slice(&item.map_err(|_| { + ApiError::InvalidInput( + "Unable to parse bytes in payload sent!".to_string(), + ) + })?); + } + + let parsed: T = serde_json::from_slice(&bytes)?; + parsed.validate().map_err(|err| { + ApiError::InvalidInput(validation_errors_to_string(err, None)) + })?; + Ok(parsed) +} + pub async fn read_from_field( field: &mut Field, cap: usize, diff --git a/packages/ariadne/src/ids.rs b/packages/ariadne/src/ids.rs index 9f51b145c..5b389c8f0 100644 --- a/packages/ariadne/src/ids.rs +++ b/packages/ariadne/src/ids.rs @@ -71,7 +71,6 @@ pub enum DecodingError { } #[macro_export] -#[doc(hidden)] macro_rules! impl_base62_display { ($struct:ty) => { impl std::fmt::Display for $struct {