diff --git a/migrations/20230816085700_collections_and_more.sql b/migrations/20230816085700_collections_and_more.sql new file mode 100644 index 000000000..81efecdae --- /dev/null +++ b/migrations/20230816085700_collections_and_more.sql @@ -0,0 +1,37 @@ +CREATE TABLE collections ( + id bigint PRIMARY KEY, + title varchar(255) NOT NULL, + description varchar(2048) NOT NULL, + user_id bigint REFERENCES users NOT NULL, + created timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + + status varchar(64) NOT NULL DEFAULT 'listed', + + icon_url varchar(2048) NULL, + color integer NULL +); + +CREATE TABLE collections_mods ( + collection_id bigint REFERENCES collections NOT NULL, + mod_id bigint REFERENCES mods NOT NULL, + PRIMARY KEY (collection_id, mod_id) +); + +CREATE TABLE uploaded_images ( + id bigint PRIMARY KEY, + url varchar(2048) NOT NULL, + size integer NOT NULL, + created timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + owner_id bigint REFERENCES users NOT NULL, + + -- Type of contextual association + context varchar(64) NOT NULL, -- project, version, thread_message, report, etc. + + -- Only one of these should be set (based on 'context') + mod_id bigint NULL REFERENCES mods, + version_id bigint NULL REFERENCES versions, + thread_message_id bigint NULL REFERENCES threads_messages, + report_id bigint NULL REFERENCES reports + +); \ No newline at end of file diff --git a/sqlx-data.json b/sqlx-data.json index 8e17155e1..f56aeea53 100644 --- a/sqlx-data.json +++ b/sqlx-data.json @@ -309,6 +309,80 @@ }, "query": "\n SELECT f.id, f.version_id, v.mod_id, f.url, f.filename, f.is_primary, f.size, f.file_type,\n JSONB_AGG(DISTINCT jsonb_build_object('algorithm', h.algorithm, 'hash', encode(h.hash, 'escape'))) filter (where h.hash is not null) hashes\n FROM files f\n INNER JOIN versions v on v.id = f.version_id\n INNER JOIN hashes h on h.file_id = f.id\n WHERE h.algorithm = $1 AND h.hash = ANY($2)\n GROUP BY f.id, v.mod_id, v.date_published\n ORDER BY v.date_published\n " }, + "0b9f174d86badae0d30e34b32130c7cee69926e37db95494ab08f025d19cdb7c": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "title", + "ordinal": 1, + "type_info": "Varchar" + }, + { + "name": "description", + "ordinal": 2, + "type_info": "Varchar" + }, + { + "name": "icon_url", + "ordinal": 3, + "type_info": "Varchar" + }, + { + "name": "color", + "ordinal": 4, + "type_info": "Int4" + }, + { + "name": "created", + "ordinal": 5, + "type_info": "Timestamptz" + }, + { + "name": "user_id", + "ordinal": 6, + "type_info": "Int8" + }, + { + "name": "updated", + "ordinal": 7, + "type_info": "Timestamptz" + }, + { + "name": "status", + "ordinal": 8, + "type_info": "Varchar" + }, + { + "name": "mods", + "ordinal": 9, + "type_info": "Int8Array" + } + ], + "nullable": [ + false, + false, + false, + true, + true, + false, + false, + false, + false, + null + ], + "parameters": { + "Left": [ + "Int8Array" + ] + } + }, + "query": "\n SELECT c.id id, c.title title, c.description description,\n c.icon_url icon_url, c.color color, c.created created, c.user_id user_id,\n c.updated updated, c.status status,\n ARRAY_AGG(DISTINCT cm.mod_id) filter (where cm.mod_id is not null) mods\n FROM collections c\n LEFT JOIN collections_mods cm ON cm.collection_id = c.id\n WHERE c.id = ANY($1)\n GROUP BY c.id;\n " + }, "0ba5a9f4d1381ed37a67b7dc90edf7e3ec86cae6c2860e5db1e53144d4654e58": { "describe": { "columns": [ @@ -507,6 +581,27 @@ }, "query": "\n UPDATE notifications\n SET read = TRUE\n WHERE id = ANY($1)\n " }, + "15fac93c76e72348b50f526e1acb183521d94be335ad8b9dfeb0398d4a8a2fc4": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Int4", + "Timestamptz", + "Int8", + "Varchar", + "Int8", + "Int8", + "Int8", + "Int8" + ] + } + }, + "query": "\n INSERT INTO uploaded_images (\n id, url, size, created, owner_id, context, mod_id, version_id, thread_message_id, report_id\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10\n );\n " + }, "16049957962ded08751d5a4ddce2ffac17ecd486f61210c51a952508425d83e6": { "describe": { "columns": [], @@ -647,6 +742,18 @@ }, "query": "\n UPDATE users\n SET avatar_url = $1\n WHERE (id = $2)\n " }, + "1abc74fe1da85e031edbc896797991337b57d2c47a8a978f9b9f34b20bf8f410": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n UPDATE collections\n SET icon_url = NULL, color = NULL\n WHERE (id = $1)\n " + }, "1cefe4924d3c1f491739858ce844a22903d2dbe26f255219299f1833a10ce3d7": { "describe": { "columns": [ @@ -668,6 +775,84 @@ }, "query": "\n SELECT id FROM mods TABLESAMPLE SYSTEM_ROWS($1) WHERE status = ANY($2)\n " }, + "1d6a53187082ad9a57294d9f1c13d66131ccc3d4a0cf59d42346474196ea50f8": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "url", + "ordinal": 1, + "type_info": "Varchar" + }, + { + "name": "size", + "ordinal": 2, + "type_info": "Int4" + }, + { + "name": "created", + "ordinal": 3, + "type_info": "Timestamptz" + }, + { + "name": "owner_id", + "ordinal": 4, + "type_info": "Int8" + }, + { + "name": "context", + "ordinal": 5, + "type_info": "Varchar" + }, + { + "name": "mod_id", + "ordinal": 6, + "type_info": "Int8" + }, + { + "name": "version_id", + "ordinal": 7, + "type_info": "Int8" + }, + { + "name": "thread_message_id", + "ordinal": 8, + "type_info": "Int8" + }, + { + "name": "report_id", + "ordinal": 9, + "type_info": "Int8" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + true, + true + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8", + "Int8", + "Int8" + ] + } + }, + "query": "\n SELECT id, url, size, created, owner_id, context, mod_id, version_id, thread_message_id, report_id\n FROM uploaded_images\n WHERE context = $1\n AND (mod_id = $2 OR ($2 IS NULL AND mod_id IS NULL))\n AND (version_id = $3 OR ($3 IS NULL AND version_id IS NULL))\n AND (thread_message_id = $4 OR ($4 IS NULL AND thread_message_id IS NULL))\n AND (report_id = $5 OR ($5 IS NULL AND report_id IS NULL))\n GROUP BY id\n " + }, "1d6f3e926fc4a27c5af172f672b7f825f9f5fe2d538b06337ef182ab1a553398": { "describe": { "columns": [ @@ -926,6 +1111,18 @@ }, "query": "\n UPDATE team_members\n SET ordering = $1\n WHERE (team_id = $2 AND user_id = $3)\n " }, + "2a043ce990f4a31c1a3e5c836af515027eaf1ff1bbf08310fd215d0e96c2cdb3": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n DELETE FROM uploaded_images\n WHERE id = $1\n " + }, "2b8dafe9c3df9fd25235a13868e8e7607decfbe96a413cc576919a1fb510f269": { "describe": { "columns": [], @@ -1229,6 +1426,26 @@ }, "query": "\n UPDATE mods\n SET follows = follows - 1\n WHERE id = $1\n " }, + "38429340be03cc5f539d9d14c156e6b6710051d2826b53a5ccfdbd231af964ca": { + "describe": { + "columns": [ + { + "name": "exists", + "ordinal": 0, + "type_info": "Bool" + } + ], + "nullable": [ + null + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT EXISTS(SELECT 1 FROM collections WHERE id=$1)" + }, "3af747b5543a5a9b10dcce0a1eb9c2a1926dd5a507fe0d8b7f52d8ccc7fcd0af": { "describe": { "columns": [], @@ -1289,6 +1506,18 @@ }, "query": "\n UPDATE mods_gallery\n SET title = $2\n WHERE id = $1\n " }, + "3c50c07cddcc936a60ff1583b36fe0682da965b4aaf4579d08e2fe5468e71a3d": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n DELETE FROM collections_mods\n WHERE mod_id = $1\n " + }, "3d384766d179f804c17e03d1917da65cc6043f88971ddc3fd23ba3be00717dfc": { "describe": { "columns": [ @@ -1572,6 +1801,19 @@ }, "query": "\n DELETE FROM mods_donations\n WHERE joining_mod_id = $1\n " }, + "45e3f7d3ae0396c0b0196ed959f9b60c57b7c57390758ddcc58fb2e0f276a426": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "\n UPDATE uploaded_images\n SET thread_message_id = $1\n WHERE id = $2\n " + }, "4778d2f5994fda2f978fa53e0840c1a9a2582ef0434a5ff7f21706f1dc4edcf4": { "describe": { "columns": [], @@ -1618,6 +1860,19 @@ }, "query": "\n SELECT d.dependency_id, COALESCE(vd.mod_id, 0) mod_id, d.mod_dependency_id\n FROM versions v\n INNER JOIN dependencies d ON d.dependent_id = v.id\n LEFT JOIN versions vd ON d.dependency_id = vd.id\n WHERE v.mod_id = $1\n " }, + "489913b3c32631fb329a3259cfe620d65053e2abf425a0d3f1bc01f1cdbdd73d": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "\n INSERT INTO collections_mods (collection_id, mod_id)\n VALUES ($1, $2)\n ON CONFLICT DO NOTHING\n " + }, "49813a96f007216072d69468aae705d73d5b85dcdd64a22060009b12d947ed5a": { "describe": { "columns": [], @@ -1845,6 +2100,24 @@ }, "query": "\n DELETE FROM user_backup_codes\n WHERE user_id = $1\n " }, + "536f628092168eead27519db013ec8a1510a06f27e699839bac9dc85d16d99c2": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Varchar", + "Varchar", + "Timestamptz", + "Varchar", + "Varchar" + ] + } + }, + "query": "\n INSERT INTO collections (\n id, user_id, title, description, \n created, icon_url, status\n )\n VALUES (\n $1, $2, $3, $4, \n $5, $6, $7\n )\n " + }, "53a8966ac345cc334ad65ea907be81af74e90b1217696c7eedcf8a8e3fca736e": { "describe": { "columns": [], @@ -1878,6 +2151,33 @@ }, "query": "\n SELECT id FROM mods\n WHERE status = $1 AND queued < NOW() - INTERVAL '40 hours'\n ORDER BY updated ASC\n " }, + "5627b3516fc7c3799154098a663b1586aac11b2dc736810f06630ee5d8a54946": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "user_id", + "ordinal": 1, + "type_info": "Int8" + } + ], + "nullable": [ + false, + false + ], + "parameters": { + "Left": [ + "Int8Array", + "Int8" + ] + } + }, + "query": "\n SELECT c.id id, c.user_id user_id FROM collections c\n WHERE c.user_id = $2 AND c.id = ANY($1)\n " + }, "5944eb30a2bc0381c4d15eb1cf6ccf6e146a54381f2da8ab224960430e951976": { "describe": { "columns": [ @@ -2099,6 +2399,80 @@ }, "query": "\n UPDATE versions\n SET status = $1\n WHERE (id = $2)\n " }, + "5c7bc2b59e5bcbe50e556cf28fb7a20de645752beef330b6779ec256f33e666a": { + "describe": { + "columns": [ + { + "name": "id", + "ordinal": 0, + "type_info": "Int8" + }, + { + "name": "url", + "ordinal": 1, + "type_info": "Varchar" + }, + { + "name": "size", + "ordinal": 2, + "type_info": "Int4" + }, + { + "name": "created", + "ordinal": 3, + "type_info": "Timestamptz" + }, + { + "name": "owner_id", + "ordinal": 4, + "type_info": "Int8" + }, + { + "name": "context", + "ordinal": 5, + "type_info": "Varchar" + }, + { + "name": "mod_id", + "ordinal": 6, + "type_info": "Int8" + }, + { + "name": "version_id", + "ordinal": 7, + "type_info": "Int8" + }, + { + "name": "thread_message_id", + "ordinal": 8, + "type_info": "Int8" + }, + { + "name": "report_id", + "ordinal": 9, + "type_info": "Int8" + } + ], + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + true, + true, + true + ], + "parameters": { + "Left": [ + "Int8Array" + ] + } + }, + "query": "\n SELECT id, url, size, created, owner_id, context, mod_id, version_id, thread_message_id, report_id\n FROM uploaded_images\n WHERE id = ANY($1)\n GROUP BY id;\n " + }, "5ca43f2fddda27ad857f230a3427087f1e58150949adc6273156718730c10f69": { "describe": { "columns": [], @@ -2724,6 +3098,26 @@ }, "query": "\n DELETE FROM mod_follows\n WHERE follower_id = $1\n " }, + "6fbff950c4c996976a29898b120b9b8b562f25729166c21d6f5ed45c240c71be": { + "describe": { + "columns": [ + { + "name": "exists", + "ordinal": 0, + "type_info": "Bool" + } + ], + "nullable": [ + null + ], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "SELECT EXISTS(SELECT 1 FROM uploaded_images WHERE id=$1)" + }, "6fd06767f42be894c7a35c6b61f43407c55de43dc77ed02b39062278f3de81e3": { "describe": { "columns": [], @@ -2889,6 +3283,19 @@ }, "query": "\n UPDATE files\n SET file_type = $2\n WHERE (id = $1)\n " }, + "7628dd456f01d307cc8647b36734b189a5f08dbaa9db78fe28f1de3d8f4757b7": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "\n UPDATE uploaded_images\n SET report_id = $1\n WHERE id = $2\n " + }, "76db1c204139e18002e5751c3dcefff79791a1dd852b62d34fcf008151e8945a": { "describe": { "columns": [ @@ -3451,6 +3858,19 @@ }, "query": "\n SELECT name FROM side_types\n " }, + "86049f204c9eda5241403d22b5f8ffe13b258ddfffb81a1a9ee8602e21c64723": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + } + }, + "query": "\n UPDATE collections\n SET status = $1\n WHERE (id = $2)\n " + }, "868c29019bd7e9ad71fb3515ca3489304ade3f6ebe3f77c018a8a521a96fb41f": { "describe": { "columns": [ @@ -3686,6 +4106,20 @@ }, "query": "\n SELECT t.id, t.thread_type, t.mod_id, t.report_id, t.show_in_mod_inbox,\n ARRAY_AGG(DISTINCT tm.user_id) filter (where tm.user_id is not null) members,\n JSONB_AGG(DISTINCT jsonb_build_object('id', tmsg.id, 'author_id', tmsg.author_id, 'thread_id', tmsg.thread_id, 'body', tmsg.body, 'created', tmsg.created)) filter (where tmsg.id is not null) messages\n FROM threads t\n LEFT OUTER JOIN threads_messages tmsg ON tmsg.thread_id = t.id\n LEFT OUTER JOIN threads_members tm ON tm.thread_id = t.id\n WHERE t.id = ANY($1)\n GROUP BY t.id\n " }, + "9544cea57095a94109be5fef9a4737626a9003d58680943cdbffc7c9ada7877b": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Varchar", + "Int4", + "Int8" + ] + } + }, + "query": "\n UPDATE collections\n SET icon_url = $1, color = $2\n WHERE (id = $3)\n " + }, "95cb791af4ea4d5b959de9e451bb8875336db33238024812086b5237b4dac350": { "describe": { "columns": [], @@ -3828,6 +4262,18 @@ }, "query": "\n INSERT INTO team_members (id, team_id, user_id, role, permissions, accepted, payouts_split, ordering)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n " }, + "9d68929e384db6dc734afca0dfdfef15f103b6eccdf0d1d144180b0d7d4e3400": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n DELETE FROM collections_mods\n WHERE collection_id = $1\n " + }, "a0148ff25855202e7bb220b6a2bc9220a95e309fb0dae41d9a05afa86e6b33af": { "describe": { "columns": [], @@ -4248,6 +4694,18 @@ }, "query": "\n UPDATE users\n SET balance = balance - $1\n WHERE id = $2\n " }, + "b28b380e2d728c4733b9654e433b716114a215240845345b168d832e75769398": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8" + ] + } + }, + "query": "\n DELETE FROM collections\n WHERE id = $1\n " + }, "b297c97cd18785279cee369a1a269326ade765652ccf87405e6ee7dd3cbdaabf": { "describe": { "columns": [], @@ -4306,6 +4764,19 @@ }, "query": "\n INSERT INTO mods (\n id, team_id, title, description, body,\n published, downloads, icon_url, issues_url,\n source_url, wiki_url, status, requested_status, discord_url,\n client_side, server_side, license_url, license,\n slug, project_type, color, monetization_status\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8, $9,\n $10, $11, $12, $13, $14,\n $15, $16, $17, $18,\n LOWER($19), $20, $21, $22\n )\n " }, + "b641616b81b1cef2f95db719a492cc1f7aaba66da52efeadb05fc555611b174b": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + } + }, + "query": "\n UPDATE collections\n SET description = $1\n WHERE (id = $2)\n " + }, "b768d9db6c785d6a701324ea746794d33e94121403163a774b6ef775640fd3d3": { "describe": { "columns": [ @@ -4769,6 +5240,19 @@ }, "query": "\n SELECT id FROM reports\n WHERE id = ANY($1) AND reporter = $2\n " }, + "c920cc500f431a2b174d176c3a356d40295137fd87a5308d71aad173d18d9d91": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "\n UPDATE uploaded_images\n SET version_id = $1\n WHERE id = $2\n " + }, "c94faba99d486b11509fff59465b7cc71983551b035e936ce4d9776510afb514": { "describe": { "columns": [ @@ -4876,6 +5360,19 @@ }, "query": "\n INSERT INTO hashes (file_id, algorithm, hash)\n VALUES ($1, $2, $3)\n " }, + "ccce60dc60ca6c4ea1142ab6d0d81bdb1ee9ed97c992695324aec015e0e190bf": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "\n INSERT INTO collections_mods (collection_id, mod_id)\n VALUES ($1, $2)\n ON CONFLICT DO NOTHING\n " + }, "ccd913bb2f3006ffe881ce2fc4ef1e721d18fe2eed6ac62627046c955129610c": { "describe": { "columns": [ @@ -4921,6 +5418,19 @@ }, "query": "\n INSERT INTO mods_categories (joining_mod_id, joining_category_id, is_additional)\n VALUES ($1, $2, FALSE)\n " }, + "ce2e7642142f79bdce78ba3316fe402e18ae203cc65fe79f724d37a7076df2dd": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + } + }, + "query": "\n UPDATE collections\n SET title = $1\n WHERE (id = $2)\n " + }, "cef01012769dcd499a0d16ce65ffc1e94bce362a7246b6a0a38d133afb90d3b6": { "describe": { "columns": [], @@ -5754,6 +6264,19 @@ }, "query": "\n SELECT id FROM reports\n WHERE closed = FALSE AND reporter = $1\n ORDER BY created ASC\n LIMIT $2;\n " }, + "f2c5eccd8099d6f527c1665cfc0f1204b8a0dab6f2b84f9f72fbf5462c6cb1f4": { + "describe": { + "columns": [], + "nullable": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + } + }, + "query": "\n UPDATE uploaded_images\n SET mod_id = $1\n WHERE id = $2\n " + }, "f34bbe639ad21801258dc8beaab9877229a451761be07f85a1dd04d027832329": { "describe": { "columns": [ diff --git a/src/auth/checks.rs b/src/auth/checks.rs index 98a675411..6cf65223f 100644 --- a/src/auth/checks.rs +++ b/src/auth/checks.rs @@ -1,6 +1,7 @@ use crate::database; use crate::database::models::project_item::QueryProject; use crate::database::models::version_item::QueryVersion; +use crate::database::models::Collection; use crate::database::{models, Project, Version}; use crate::models::users::User; use crate::routes::ApiError; @@ -192,3 +193,76 @@ pub async fn filter_authorized_versions( Ok(return_versions) } + +pub async fn is_authorized_collection( + collection_data: &Collection, + user_option: &Option, +) -> Result { + let mut authorized = !collection_data.status.is_hidden(); + + if let Some(user) = &user_option { + if !authorized && (user.role.is_mod() || user.id == collection_data.user_id.into()) { + authorized = true; + } + } + + Ok(authorized) +} + +pub async fn filter_authorized_collections( + collections: Vec, + user_option: &Option, + pool: &web::Data, +) -> Result, ApiError> { + let mut return_collections = Vec::new(); + let mut check_collections = Vec::new(); + + for collection in collections { + if !collection.status.is_hidden() + || user_option + .as_ref() + .map(|x| x.role.is_mod()) + .unwrap_or(false) + { + return_collections.push(collection.into()); + } else if user_option.is_some() { + check_collections.push(collection); + } + } + + if !check_collections.is_empty() { + if let Some(user) = user_option { + let user_id: models::ids::UserId = user.id.into(); + + use futures::TryStreamExt; + + sqlx::query!( + " + SELECT c.id id, c.user_id user_id FROM collections c + WHERE c.user_id = $2 AND c.id = ANY($1) + ", + &check_collections.iter().map(|x| x.id.0).collect::>(), + user_id as database::models::ids::UserId, + ) + .fetch_many(&***pool) + .try_for_each(|e| { + if let Some(row) = e.right() { + check_collections.retain(|x| { + let bool = x.id.0 == row.id && x.user_id.0 == row.user_id; + + if bool { + return_collections.push(x.clone().into()); + } + + !bool + }); + } + + futures::future::ready(Ok(())) + }) + .await?; + } + } + + Ok(return_collections) +} diff --git a/src/auth/validate.rs b/src/auth/validate.rs index 601e97d7f..8589e176b 100644 --- a/src/auth/validate.rs +++ b/src/auth/validate.rs @@ -24,7 +24,6 @@ where get_user_record_from_bearer_token(req, None, executor, redis, session_queue) .await? .ok_or_else(|| AuthenticationError::InvalidCredentials)?; - let mut auth_providers = Vec::new(); if db_user.github_id.is_some() { auth_providers.push(AuthProvider::GitHub) diff --git a/src/database/mod.rs b/src/database/mod.rs index 6cda6f976..9c51cd172 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,5 +1,6 @@ pub mod models; mod postgres_database; +pub use models::Image; pub use models::Project; pub use models::Version; pub use postgres_database::check_for_migrations; diff --git a/src/database/models/collection_item.rs b/src/database/models/collection_item.rs new file mode 100644 index 000000000..0500ee81a --- /dev/null +++ b/src/database/models/collection_item.rs @@ -0,0 +1,263 @@ +use super::ids::*; +use crate::database::models; +use crate::database::models::DatabaseError; +use crate::models::collections::CollectionStatus; +use chrono::{DateTime, Utc}; +use redis::cmd; +use serde::{Deserialize, Serialize}; + +const COLLECTIONS_NAMESPACE: &str = "collections"; +const DEFAULT_EXPIRY: i64 = 1800; // 30 minutes + +#[derive(Clone)] +pub struct CollectionBuilder { + pub collection_id: CollectionId, + pub user_id: UserId, + pub title: String, + pub description: String, + pub status: CollectionStatus, + pub projects: Vec, +} + +impl CollectionBuilder { + pub async fn insert( + self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result { + let collection_struct = Collection { + id: self.collection_id, + title: self.title, + user_id: self.user_id, + description: self.description, + created: Utc::now(), + updated: Utc::now(), + icon_url: None, + color: None, + status: self.status, + projects: self.projects, + }; + collection_struct.insert(&mut *transaction).await?; + + Ok(self.collection_id) + } +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Collection { + pub id: CollectionId, + pub user_id: UserId, + pub title: String, + pub description: String, + pub created: DateTime, + pub updated: DateTime, + pub icon_url: Option, + pub color: Option, + pub status: CollectionStatus, + pub projects: Vec, +} + +impl Collection { + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + sqlx::query!( + " + INSERT INTO collections ( + id, user_id, title, description, + created, icon_url, status + ) + VALUES ( + $1, $2, $3, $4, + $5, $6, $7 + ) + ", + self.id as CollectionId, + self.user_id as UserId, + &self.title, + &self.description, + self.created, + self.icon_url.as_ref(), + self.status.to_string(), + ) + .execute(&mut *transaction) + .await?; + + for project_id in self.projects.iter() { + sqlx::query!( + " + INSERT INTO collections_mods (collection_id, mod_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING + ", + self.id as CollectionId, + *project_id as ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + Ok(()) + } + + pub async fn remove( + id: CollectionId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &deadpool_redis::Pool, + ) -> Result, DatabaseError> { + let collection = Self::get(id, &mut *transaction, redis).await?; + + if let Some(collection) = collection { + sqlx::query!( + " + DELETE FROM collections_mods + WHERE collection_id = $1 + ", + id as CollectionId, + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + " + DELETE FROM collections + WHERE id = $1 + ", + id as CollectionId, + ) + .execute(&mut *transaction) + .await?; + + models::Collection::clear_cache(collection.id, redis).await?; + + Ok(Some(())) + } else { + Ok(None) + } + } + + pub async fn get<'a, 'b, E>( + id: CollectionId, + executor: E, + redis: &deadpool_redis::Pool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + Collection::get_many(&[id], executor, redis) + .await + .map(|x| x.into_iter().next()) + } + + pub async fn get_many<'a, E>( + collection_ids: &[CollectionId], + exec: E, + redis: &deadpool_redis::Pool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + use futures::TryStreamExt; + + if collection_ids.is_empty() { + return Ok(Vec::new()); + } + + let mut redis = redis.get().await?; + + let mut found_collections = Vec::new(); + let mut remaining_collections: Vec = collection_ids.to_vec(); + + if !collection_ids.is_empty() { + let collections = cmd("MGET") + .arg( + collection_ids + .iter() + .map(|x| format!("{}:{}", COLLECTIONS_NAMESPACE, x.0)) + .collect::>(), + ) + .query_async::<_, Vec>>(&mut redis) + .await?; + + for collection in collections { + if let Some(collection) = + collection.and_then(|x| serde_json::from_str::(&x).ok()) + { + remaining_collections.retain(|x| collection.id.0 != x.0); + found_collections.push(collection); + continue; + } + } + } + + if !remaining_collections.is_empty() { + let collection_ids_parsed: Vec = + remaining_collections.iter().map(|x| x.0).collect(); + let db_collections: Vec = sqlx::query!( + " + SELECT c.id id, c.title title, c.description description, + c.icon_url icon_url, c.color color, c.created created, c.user_id user_id, + c.updated updated, c.status status, + ARRAY_AGG(DISTINCT cm.mod_id) filter (where cm.mod_id is not null) mods + FROM collections c + LEFT JOIN collections_mods cm ON cm.collection_id = c.id + WHERE c.id = ANY($1) + GROUP BY c.id; + ", + &collection_ids_parsed, + ) + .fetch_many(exec) + .try_filter_map(|e| async { + Ok(e.right().map(|m| { + let id = m.id; + + Collection { + id: CollectionId(id), + user_id: UserId(m.user_id), + title: m.title.clone(), + description: m.description.clone(), + icon_url: m.icon_url.clone(), + color: m.color.map(|x| x as u32), + created: m.created, + updated: m.updated, + status: CollectionStatus::from_str(&m.status), + projects: m + .mods + .unwrap_or_default() + .into_iter() + .map(ProjectId) + .collect(), + } + })) + }) + .try_collect::>() + .await?; + + for collection in db_collections { + cmd("SET") + .arg(format!("{}:{}", COLLECTIONS_NAMESPACE, collection.id.0)) + .arg(serde_json::to_string(&collection)?) + .arg("EX") + .arg(DEFAULT_EXPIRY) + .query_async::<_, ()>(&mut redis) + .await?; + + found_collections.push(collection); + } + } + + Ok(found_collections) + } + + pub async fn clear_cache( + id: CollectionId, + redis: &deadpool_redis::Pool, + ) -> Result<(), DatabaseError> { + let mut redis = redis.get().await?; + let mut cmd = cmd("DEL"); + + cmd.arg(format!("{}:{}", COLLECTIONS_NAMESPACE, id.0)); + cmd.query_async::<_, ()>(&mut redis).await?; + + Ok(()) + } +} diff --git a/src/database/models/ids.rs b/src/database/models/ids.rs index 055e75744..98d0f54b8 100644 --- a/src/database/models/ids.rs +++ b/src/database/models/ids.rs @@ -62,6 +62,13 @@ generate_ids!( "SELECT EXISTS(SELECT 1 FROM teams WHERE id=$1)", TeamId ); +generate_ids!( + pub generate_collection_id, + CollectionId, + 8, + "SELECT EXISTS(SELECT 1 FROM collections WHERE id=$1)", + CollectionId +); generate_ids!( pub generate_file_id, FileId, @@ -130,6 +137,14 @@ generate_ids!( SessionId ); +generate_ids!( + pub generate_image_id, + ImageId, + 8, + "SELECT EXISTS(SELECT 1 FROM uploaded_images WHERE id=$1)", + ImageId +); + #[derive(Copy, Clone, Debug, PartialEq, Eq, Type, Serialize, Deserialize)] #[sqlx(transparent)] pub struct UserId(pub i64); @@ -171,7 +186,11 @@ pub struct LoaderId(pub i32); #[sqlx(transparent)] pub struct CategoryId(pub i32); -#[derive(Copy, Clone, Debug, Type)] +#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize)] +#[sqlx(transparent)] +pub struct CollectionId(pub i64); + +#[derive(Copy, Clone, Debug, Type, Deserialize, Serialize)] #[sqlx(transparent)] pub struct ReportId(pub i64); #[derive(Copy, Clone, Debug, Type)] @@ -196,7 +215,7 @@ pub struct NotificationActionId(pub i32); #[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq)] #[sqlx(transparent)] pub struct ThreadId(pub i64); -#[derive(Copy, Clone, Debug, Type, Deserialize)] +#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash)] #[sqlx(transparent)] pub struct ThreadMessageId(pub i64); @@ -204,6 +223,10 @@ pub struct ThreadMessageId(pub i64); #[sqlx(transparent)] pub struct SessionId(pub i64); +#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash)] +#[sqlx(transparent)] +pub struct ImageId(pub i64); + use crate::models::ids; impl From for ProjectId { @@ -246,6 +269,16 @@ impl From for ids::VersionId { ids::VersionId(id.0 as u64) } } +impl From for CollectionId { + fn from(id: ids::CollectionId) -> Self { + CollectionId(id.0 as i64) + } +} +impl From for ids::CollectionId { + fn from(id: CollectionId) -> Self { + ids::CollectionId(id.0 as u64) + } +} impl From for ReportId { fn from(id: ids::ReportId) -> Self { ReportId(id.0 as i64) @@ -256,6 +289,16 @@ impl From for ids::ReportId { ids::ReportId(id.0 as u64) } } +impl From for ids::ImageId { + fn from(id: ImageId) -> Self { + ids::ImageId(id.0 as u64) + } +} +impl From for ImageId { + fn from(id: ids::ImageId) -> Self { + ImageId(id.0 as i64) + } +} impl From for NotificationId { fn from(id: ids::NotificationId) -> Self { NotificationId(id.0 as i64) diff --git a/src/database/models/image_item.rs b/src/database/models/image_item.rs new file mode 100644 index 000000000..fd6d0abbe --- /dev/null +++ b/src/database/models/image_item.rs @@ -0,0 +1,275 @@ +use super::ids::*; +use crate::{database::models::DatabaseError, models::images::ImageContext}; +use chrono::{DateTime, Utc}; +use redis::cmd; +use serde::{Deserialize, Serialize}; + +const IMAGES_NAMESPACE: &str = "images"; +const DEFAULT_EXPIRY: i64 = 1800; // 30 minutes + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Image { + pub id: ImageId, + pub url: String, + pub size: u64, + pub created: DateTime, + pub owner_id: UserId, + + // context it is associated with + pub context: String, + + pub project_id: Option, + pub version_id: Option, + pub thread_message_id: Option, + pub report_id: Option, +} + +impl Image { + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + sqlx::query!( + " + INSERT INTO uploaded_images ( + id, url, size, created, owner_id, context, mod_id, version_id, thread_message_id, report_id + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10 + ); + ", + self.id as ImageId, + self.url, + self.size as i64, + self.created, + self.owner_id as UserId, + self.context, + self.project_id.map(|x| x.0), + self.version_id.map(|x| x.0), + self.thread_message_id.map(|x| x.0), + self.report_id.map(|x| x.0), + ) + .execute(&mut *transaction) + .await?; + + Ok(()) + } + + pub async fn remove( + id: ImageId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &deadpool_redis::Pool, + ) -> Result, DatabaseError> { + let image = Self::get(id, &mut *transaction, redis).await?; + + if let Some(image) = image { + sqlx::query!( + " + DELETE FROM uploaded_images + WHERE id = $1 + ", + id as ImageId, + ) + .execute(&mut *transaction) + .await?; + + Image::clear_cache(image.id, redis).await?; + + Ok(Some(())) + } else { + Ok(None) + } + } + + pub async fn get_many_contexted( + context: ImageContext, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result, sqlx::Error> { + // Set all of project_id, version_id, thread_message_id, report_id to None + // Then set the one that is relevant to Some + + let mut project_id = None; + let mut version_id = None; + let mut thread_message_id = None; + let mut report_id = None; + match context { + ImageContext::Project { + project_id: Some(id), + } => { + project_id = Some(ProjectId::from(id)); + } + ImageContext::Version { + version_id: Some(id), + } => { + version_id = Some(VersionId::from(id)); + } + ImageContext::ThreadMessage { + thread_message_id: Some(id), + } => { + thread_message_id = Some(ThreadMessageId::from(id)); + } + ImageContext::Report { + report_id: Some(id), + } => { + report_id = Some(ReportId::from(id)); + } + _ => {} + } + + use futures::stream::TryStreamExt; + sqlx::query!( + " + SELECT id, url, size, created, owner_id, context, mod_id, version_id, thread_message_id, report_id + FROM uploaded_images + WHERE context = $1 + AND (mod_id = $2 OR ($2 IS NULL AND mod_id IS NULL)) + AND (version_id = $3 OR ($3 IS NULL AND version_id IS NULL)) + AND (thread_message_id = $4 OR ($4 IS NULL AND thread_message_id IS NULL)) + AND (report_id = $5 OR ($5 IS NULL AND report_id IS NULL)) + GROUP BY id + ", + context.context_as_str(), + project_id.map(|x| x.0), + version_id.map(|x| x.0), + thread_message_id.map(|x| x.0), + report_id.map(|x| x.0), + + ) + .fetch_many(transaction) + .try_filter_map(|e| async { + Ok(e.right().map(|row| { + let id = ImageId(row.id); + + Image { + id, + url: row.url, + size: row.size as u64, + created: row.created, + owner_id: UserId(row.owner_id), + context: row.context, + project_id: row.mod_id.map(ProjectId), + version_id: row.version_id.map(VersionId), + thread_message_id: row.thread_message_id.map(ThreadMessageId), + report_id: row.report_id.map(ReportId), + } + })) + }) + .try_collect::>() + .await + } + + pub async fn get<'a, 'b, E>( + id: ImageId, + executor: E, + redis: &deadpool_redis::Pool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + Image::get_many(&[id], executor, redis) + .await + .map(|x| x.into_iter().next()) + } + + pub async fn get_many<'a, E>( + image_ids: &[ImageId], + exec: E, + redis: &deadpool_redis::Pool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + use futures::TryStreamExt; + + if image_ids.is_empty() { + return Ok(Vec::new()); + } + + let mut redis = redis.get().await?; + + let mut found_images = Vec::new(); + let mut remaining_ids = image_ids.to_vec(); + + let image_ids = image_ids.iter().map(|x| x.0).collect::>(); + + if !image_ids.is_empty() { + let images = cmd("MGET") + .arg( + image_ids + .iter() + .map(|x| format!("{}:{}", IMAGES_NAMESPACE, x)) + .collect::>(), + ) + .query_async::<_, Vec>>(&mut redis) + .await?; + + for image in images { + if let Some(image) = image.and_then(|x| serde_json::from_str::(&x).ok()) { + remaining_ids.retain(|x| image.id.0 != x.0); + found_images.push(image); + continue; + } + } + } + + if !remaining_ids.is_empty() { + let db_images: Vec = sqlx::query!( + " + SELECT id, url, size, created, owner_id, context, mod_id, version_id, thread_message_id, report_id + FROM uploaded_images + WHERE id = ANY($1) + GROUP BY id; + ", + &remaining_ids.iter().map(|x| x.0).collect::>(), + ) + .fetch_many(exec) + .try_filter_map(|e| async { + Ok(e.right().map(|i| { + let id = i.id; + + Image { + id: ImageId(id), + url: i.url, + size: i.size as u64, + created: i.created, + owner_id: UserId(i.owner_id), + context: i.context, + project_id: i.mod_id.map(ProjectId), + version_id: i.version_id.map(VersionId), + thread_message_id: i.thread_message_id.map(ThreadMessageId), + report_id: i.report_id.map(ReportId), + } + })) + }) + .try_collect::>() + .await?; + + for image in db_images { + cmd("SET") + .arg(format!("{}:{}", IMAGES_NAMESPACE, image.id.0)) + .arg(serde_json::to_string(&image)?) + .arg("EX") + .arg(DEFAULT_EXPIRY) + .query_async::<_, ()>(&mut redis) + .await?; + + found_images.push(image); + } + } + + Ok(found_images) + } + + pub async fn clear_cache( + id: ImageId, + redis: &deadpool_redis::Pool, + ) -> Result<(), DatabaseError> { + let mut redis = redis.get().await?; + let mut cmd = cmd("DEL"); + + cmd.arg(format!("{}:{}", IMAGES_NAMESPACE, id.0)); + cmd.query_async::<_, ()>(&mut redis).await?; + + Ok(()) + } +} diff --git a/src/database/models/mod.rs b/src/database/models/mod.rs index e175ab037..4b2909b76 100644 --- a/src/database/models/mod.rs +++ b/src/database/models/mod.rs @@ -1,8 +1,10 @@ use thiserror::Error; pub mod categories; +pub mod collection_item; pub mod flow_item; pub mod ids; +pub mod image_item; pub mod notification_item; pub mod pat_item; pub mod project_item; @@ -13,7 +15,9 @@ pub mod thread_item; pub mod user_item; pub mod version_item; +pub use collection_item::Collection; pub use ids::*; +pub use image_item::Image; pub use project_item::Project; pub use team_item::Team; pub use team_item::TeamMember; diff --git a/src/database/models/project_item.rs b/src/database/models/project_item.rs index d8a88464b..81db0e816 100644 --- a/src/database/models/project_item.rs +++ b/src/database/models/project_item.rs @@ -546,6 +546,7 @@ impl Project { .flat_map(|x| parse_base62(&x.to_string()).ok()) .map(|x| x as i64) .collect(); + let db_projects: Vec = sqlx::query!( " SELECT m.id id, m.project_type project_type, m.title title, m.description description, m.downloads downloads, m.follows follows, diff --git a/src/database/models/report_item.rs b/src/database/models/report_item.rs index 4afbbf056..369e42eb7 100644 --- a/src/database/models/report_item.rs +++ b/src/database/models/report_item.rs @@ -58,7 +58,7 @@ impl Report { pub async fn get<'a, E>(id: ReportId, exec: E) -> Result, sqlx::Error> where - E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + E: sqlx::Executor<'a, Database = sqlx::Postgres>, { Self::get_many(&[id], exec) .await @@ -70,7 +70,7 @@ impl Report { exec: E, ) -> Result, sqlx::Error> where - E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + E: sqlx::Executor<'a, Database = sqlx::Postgres>, { use futures::stream::TryStreamExt; diff --git a/src/database/models/thread_item.rs b/src/database/models/thread_item.rs index af6759c3e..091eece3a 100644 --- a/src/database/models/thread_item.rs +++ b/src/database/models/thread_item.rs @@ -212,7 +212,7 @@ impl ThreadMessage { exec: E, ) -> Result, sqlx::Error> where - E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + E: sqlx::Executor<'a, Database = sqlx::Postgres>, { Self::get_many(&[id], exec) .await @@ -224,7 +224,7 @@ impl ThreadMessage { exec: E, ) -> Result, sqlx::Error> where - E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + E: sqlx::Executor<'a, Database = sqlx::Postgres>, { use futures::stream::TryStreamExt; diff --git a/src/models/collections.rs b/src/models/collections.rs new file mode 100644 index 000000000..dc2a62849 --- /dev/null +++ b/src/models/collections.rs @@ -0,0 +1,127 @@ +use super::{ + ids::{Base62Id, ProjectId}, + users::UserId, +}; +use crate::database; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// The ID of a specific collection, encoded as base62 for usage in the API +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct CollectionId(pub u64); + +/// A collection returned from the API +#[derive(Serialize, Deserialize, Clone)] +pub struct Collection { + /// The ID of the collection, encoded as a base62 string. + pub id: CollectionId, + /// The person that has ownership of this collection. + pub user: UserId, + /// The title or name of the collection. + pub title: String, + /// A short description of the collection. + pub description: String, + + /// An icon URL for the collection. + pub icon_url: Option, + /// Color of the collection. + pub color: Option, + + /// The status of the collectin (eg: whether collection is public or not) + pub status: CollectionStatus, + + /// The date at which the collection was first published. + pub created: DateTime, + + /// The date at which the collection was updated. + pub updated: DateTime, + + /// A list of ProjectIds that are in this collection. + pub projects: Vec, +} + +impl From for Collection { + fn from(c: database::models::Collection) -> Self { + Self { + id: c.id.into(), + user: c.user_id.into(), + created: c.created, + title: c.title, + description: c.description, + updated: c.updated, + projects: c.projects.into_iter().map(|x| x.into()).collect(), + icon_url: c.icon_url, + color: c.color, + status: c.status, + } + } +} + +/// A status decides the visibility of a collection in search, URLs, and the whole site itself. +/// Listed - collection is displayed on search, and accessible by URL (for if/when search is implemented for collections) +/// Unlisted - collection is not displayed on search, but accessible by URL +/// Rejected - collection is disabled +#[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug)] +#[serde(rename_all = "lowercase")] +pub enum CollectionStatus { + Listed, + Unlisted, + Rejected, + Unknown, +} + +impl std::fmt::Display for CollectionStatus { + fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(fmt, "{}", self.as_str()) + } +} + +impl CollectionStatus { + pub fn from_str(string: &str) -> CollectionStatus { + match string { + "listed" => CollectionStatus::Listed, + "unlisted" => CollectionStatus::Unlisted, + "rejected" => CollectionStatus::Rejected, + _ => CollectionStatus::Unknown, + } + } + pub fn as_str(&self) -> &'static str { + match self { + CollectionStatus::Listed => "listed", + CollectionStatus::Unlisted => "unlisted", + CollectionStatus::Rejected => "rejected", + CollectionStatus::Unknown => "unknown", + } + } + + // Project pages + info cannot be viewed + pub fn is_hidden(&self) -> bool { + match self { + CollectionStatus::Rejected => true, + + CollectionStatus::Listed => false, + CollectionStatus::Unlisted => false, + CollectionStatus::Unknown => false, + } + } + + pub fn is_approved(&self) -> bool { + match self { + CollectionStatus::Listed => true, + CollectionStatus::Unlisted => true, + CollectionStatus::Rejected => false, + CollectionStatus::Unknown => false, + } + } + + pub fn can_be_requested(&self) -> bool { + match self { + CollectionStatus::Listed => true, + CollectionStatus::Unlisted => true, + CollectionStatus::Rejected => false, + CollectionStatus::Unknown => false, + } + } +} diff --git a/src/models/ids.rs b/src/models/ids.rs index 181c08c33..0d4682e63 100644 --- a/src/models/ids.rs +++ b/src/models/ids.rs @@ -1,5 +1,7 @@ use thiserror::Error; +pub use super::collections::CollectionId; +pub use super::images::ImageId; pub use super::notifications::NotificationId; pub use super::pats::PatId; pub use super::projects::{ProjectId, VersionId}; @@ -109,6 +111,7 @@ macro_rules! base62_id_impl { base62_id_impl!(ProjectId, ProjectId); base62_id_impl!(UserId, UserId); base62_id_impl!(VersionId, VersionId); +base62_id_impl!(CollectionId, CollectionId); base62_id_impl!(TeamId, TeamId); base62_id_impl!(ReportId, ReportId); base62_id_impl!(NotificationId, NotificationId); @@ -116,6 +119,7 @@ base62_id_impl!(ThreadId, ThreadId); base62_id_impl!(ThreadMessageId, ThreadMessageId); base62_id_impl!(SessionId, SessionId); base62_id_impl!(PatId, PatId); +base62_id_impl!(ImageId, ImageId); pub mod base62_impl { use serde::de::{self, Deserializer, Visitor}; diff --git a/src/models/images.rs b/src/models/images.rs new file mode 100644 index 000000000..1cff0481d --- /dev/null +++ b/src/models/images.rs @@ -0,0 +1,124 @@ +use super::{ + ids::{Base62Id, ProjectId, ThreadMessageId, VersionId}, + pats::Scopes, + reports::ReportId, + users::UserId, +}; +use crate::database::models::image_item::Image as DBImage; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct ImageId(pub u64); + +#[derive(Serialize, Deserialize)] +pub struct Image { + pub id: ImageId, + pub url: String, + pub size: u64, + pub created: DateTime, + pub owner_id: UserId, + + // context it is associated with + #[serde(flatten)] + pub context: ImageContext, +} + +impl From for Image { + fn from(x: DBImage) -> Self { + let mut context = ImageContext::from_str(&x.context, None); + match &mut context { + ImageContext::Project { project_id } => { + *project_id = x.project_id.map(|x| x.into()); + } + ImageContext::Version { version_id } => { + *version_id = x.version_id.map(|x| x.into()); + } + ImageContext::ThreadMessage { thread_message_id } => { + *thread_message_id = x.thread_message_id.map(|x| x.into()); + } + ImageContext::Report { report_id } => { + *report_id = x.report_id.map(|x| x.into()); + } + ImageContext::Unknown => {} + } + + Image { + id: x.id.into(), + url: x.url, + size: x.size, + created: x.created, + owner_id: x.owner_id.into(), + context, + } + } +} + +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] +#[serde(tag = "context")] +#[serde(rename_all = "snake_case")] +pub enum ImageContext { + Project { + project_id: Option, + }, + Version { + // version changelogs + version_id: Option, + }, + ThreadMessage { + thread_message_id: Option, + }, + Report { + report_id: Option, + }, + Unknown, +} + +impl ImageContext { + pub fn context_as_str(&self) -> &'static str { + match self { + ImageContext::Project { .. } => "project", + ImageContext::Version { .. } => "version", + ImageContext::ThreadMessage { .. } => "thread_message", + ImageContext::Report { .. } => "report", + ImageContext::Unknown => "unknown", + } + } + pub fn inner_id(&self) -> Option { + match self { + ImageContext::Project { project_id } => project_id.map(|x| x.0), + ImageContext::Version { version_id } => version_id.map(|x| x.0), + ImageContext::ThreadMessage { thread_message_id } => thread_message_id.map(|x| x.0), + ImageContext::Report { report_id } => report_id.map(|x| x.0), + ImageContext::Unknown => None, + } + } + pub fn relevant_scope(&self) -> Scopes { + match self { + ImageContext::Project { .. } => Scopes::PROJECT_WRITE, + ImageContext::Version { .. } => Scopes::VERSION_WRITE, + ImageContext::ThreadMessage { .. } => Scopes::THREAD_WRITE, + ImageContext::Report { .. } => Scopes::REPORT_WRITE, + ImageContext::Unknown => Scopes::NONE, + } + } + pub fn from_str(context: &str, id: Option) -> Self { + match context { + "project" => ImageContext::Project { + project_id: id.map(ProjectId), + }, + "version" => ImageContext::Version { + version_id: id.map(VersionId), + }, + "thread_message" => ImageContext::ThreadMessage { + thread_message_id: id.map(ThreadMessageId), + }, + "report" => ImageContext::Report { + report_id: id.map(ReportId), + }, + _ => ImageContext::Unknown, + } + } +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 87a98f888..47312a2d0 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -1,6 +1,8 @@ pub mod analytics; +pub mod collections; pub mod error; pub mod ids; +pub mod images; pub mod notifications; pub mod pack; pub mod pats; diff --git a/src/models/pats.rs b/src/models/pats.rs index 429a64f7f..6a906a760 100644 --- a/src/models/pats.rs +++ b/src/models/pats.rs @@ -85,8 +85,17 @@ bitflags::bitflags! { // perform analytics action const PERFORM_ANALYTICS = 1 << 30; - const ALL = 0b1111111111111111111111111111111; - const NOT_RESTRICTED = 0b00000011111111111111100111; + // create a collection + const COLLECTION_CREATE = 1 << 31; + // read a user's collections + const COLLECTION_READ = 1 << 32; + // write to a collection + const COLLECTION_WRITE = 1 << 33; + // delete a collection + const COLLECTION_DELETE = 1 << 34; + + const ALL = 0b11111111111111111111111111111111111; + const NOT_RESTRICTED = 0b111100000011111111111111100111; const NONE = 0b0; } } diff --git a/src/models/reports.rs b/src/models/reports.rs index b6393d610..f620bc133 100644 --- a/src/models/reports.rs +++ b/src/models/reports.rs @@ -4,7 +4,7 @@ use crate::models::ids::{ProjectId, ThreadId, UserId, VersionId}; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; -#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] #[serde(from = "Base62Id")] #[serde(into = "Base62Id")] pub struct ReportId(pub u64); diff --git a/src/models/teams.rs b/src/models/teams.rs index 53118cba4..d5c5494ff 100644 --- a/src/models/teams.rs +++ b/src/models/teams.rs @@ -35,6 +35,7 @@ bitflags::bitflags! { const DELETE_PROJECT = 1 << 7; const VIEW_ANALYTICS = 1 << 8; const VIEW_PAYOUTS = 1 << 9; + const ALL = 0b1111111111; } } diff --git a/src/models/threads.rs b/src/models/threads.rs index 4f7d96238..5a5a214d6 100644 --- a/src/models/threads.rs +++ b/src/models/threads.rs @@ -1,4 +1,4 @@ -use super::ids::Base62Id; +use super::ids::{Base62Id, ImageId}; use crate::models::ids::{ProjectId, ReportId}; use crate::models::projects::ProjectStatus; use crate::models::users::{User, UserId}; @@ -10,7 +10,7 @@ use serde::{Deserialize, Serialize}; #[serde(into = "Base62Id")] pub struct ThreadId(pub u64); -#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] #[serde(from = "Base62Id")] #[serde(into = "Base62Id")] pub struct ThreadMessageId(pub u64); @@ -42,6 +42,8 @@ pub enum MessageBody { #[serde(default)] private: bool, replying_to: Option, + #[serde(default)] + associated_images: Vec, }, StatusChange { new_status: ProjectStatus, diff --git a/src/routes/v2/collections.rs b/src/routes/v2/collections.rs new file mode 100644 index 000000000..b4920588e --- /dev/null +++ b/src/routes/v2/collections.rs @@ -0,0 +1,533 @@ +use crate::auth::checks::{filter_authorized_collections, is_authorized_collection}; +use crate::auth::get_user_from_headers; +use crate::database; +use crate::database::models::{collection_item, generate_collection_id, project_item}; +use crate::file_hosting::FileHost; +use crate::models::collections::{Collection, CollectionStatus}; +use crate::models::ids::base62_impl::parse_base62; +use crate::models::ids::{CollectionId, ProjectId}; +use crate::models::pats::Scopes; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use crate::util::routes::read_from_payload; +use crate::util::validate::validation_errors_to_string; +use actix_web::web::Data; +use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; +use chrono::Utc; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use std::sync::Arc; +use validator::Validate; + +use super::project_creation::CreateError; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(collections_get); + cfg.service(collection_create); + cfg.service( + web::scope("collection") + .service(collection_get) + .service(collection_delete) + .service(collection_edit) + .service(collection_icon_edit) + .service(delete_collection_icon), + ); +} + +#[derive(Serialize, Deserialize, Validate, Clone)] +pub struct CollectionCreateData { + #[validate( + length(min = 3, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + /// The title or name of the project. + pub title: String, + #[validate(length(min = 3, max = 255))] + /// A short description of the collection. + pub description: String, + #[validate(length(max = 32))] + #[serde(default = "Vec::new")] + /// A list of initial projects to use with the created collection + pub projects: Vec, +} + +#[post("collection")] +pub async fn collection_create( + req: HttpRequest, + collection_create_data: web::Json, + client: Data, + redis: Data, + session_queue: Data, +) -> Result { + let collection_create_data = collection_create_data.into_inner(); + + // The currently logged in user + let current_user = get_user_from_headers( + &req, + &**client, + &redis, + &session_queue, + Some(&[Scopes::COLLECTION_CREATE]), + ) + .await? + .1; + + collection_create_data + .validate() + .map_err(|err| CreateError::InvalidInput(validation_errors_to_string(err, None)))?; + + let mut transaction = client.begin().await?; + + let collection_id: CollectionId = generate_collection_id(&mut transaction).await?.into(); + + let initial_project_ids = + project_item::Project::get_many(&collection_create_data.projects, &mut transaction, &redis) + .await? + .into_iter() + .map(|x| x.inner.id.into()) + .collect::>(); + + let collection_builder_actual = collection_item::CollectionBuilder { + collection_id: collection_id.into(), + user_id: current_user.id.into(), + title: collection_create_data.title, + description: collection_create_data.description, + status: CollectionStatus::Listed, + projects: initial_project_ids + .iter() + .copied() + .map(|x| x.into()) + .collect(), + }; + let collection_builder = collection_builder_actual.clone(); + + let now = Utc::now(); + collection_builder_actual.insert(&mut transaction).await?; + + let response = crate::models::collections::Collection { + id: collection_id, + user: collection_builder.user_id.into(), + title: collection_builder.title.clone(), + description: collection_builder.description.clone(), + created: now, + updated: now, + icon_url: None, + color: None, + status: collection_builder.status, + projects: initial_project_ids, + }; + transaction.commit().await?; + + Ok(HttpResponse::Ok().json(response)) +} + +#[derive(Serialize, Deserialize)] +pub struct CollectionIds { + pub ids: String, +} +#[get("collections")] +pub async fn collections_get( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let ids = serde_json::from_str::>(&ids.ids)?; + let ids = ids + .into_iter() + .map(|x| parse_base62(x).map(|x| database::models::CollectionId(x as i64))) + .collect::, _>>()?; + + let collections_data = database::models::Collection::get_many(&ids, &**pool, &redis).await?; + + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::COLLECTION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let collections = filter_authorized_collections(collections_data, &user_option, &pool).await?; + + Ok(HttpResponse::Ok().json(collections)) +} + +#[get("{id}")] +pub async fn collection_get( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let string = info.into_inner().0; + + let id = database::models::CollectionId(parse_base62(&string)? as i64); + let collection_data = database::models::Collection::get(id, &**pool, &redis).await?; + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::COLLECTION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + if let Some(data) = collection_data { + if is_authorized_collection(&data, &user_option).await? { + return Ok(HttpResponse::Ok().json(Collection::from(data))); + } + } + Ok(HttpResponse::NotFound().body("")) +} + +#[derive(Deserialize, Validate)] +pub struct EditCollection { + #[validate( + length(min = 3, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + pub title: Option, + #[validate(length(min = 3, max = 256))] + pub description: Option, + pub status: Option, + #[validate(length(max = 64))] + pub new_projects: Option>, +} + +#[patch("{id}")] +pub async fn collection_edit( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + new_collection: web::Json, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::COLLECTION_WRITE]), + ) + .await + .map(|x| x.1) + .ok(); + + new_collection + .validate() + .map_err(|err| ApiError::Validation(validation_errors_to_string(err, None)))?; + + let string = info.into_inner().0; + let id = database::models::CollectionId(parse_base62(&string)? as i64); + let result = database::models::Collection::get(id, &**pool, &redis).await?; + + if let Some(collection_item) = result { + if !is_authorized_collection(&collection_item, &user_option).await? { + return Ok(HttpResponse::Unauthorized().body("")); + } + + let id = collection_item.id; + + let mut transaction = pool.begin().await?; + + if let Some(title) = &new_collection.title { + sqlx::query!( + " + UPDATE collections + SET title = $1 + WHERE (id = $2) + ", + title.trim(), + id as database::models::ids::CollectionId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(description) = &new_collection.description { + sqlx::query!( + " + UPDATE collections + SET description = $1 + WHERE (id = $2) + ", + description, + id as database::models::ids::CollectionId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(status) = &new_collection.status { + if let Some(user) = user_option { + if !(user.role.is_mod() + || collection_item.status.is_approved() && status.can_be_requested()) + { + return Err(ApiError::CustomAuthentication( + "You don't have permission to set this status!".to_string(), + )); + } + + sqlx::query!( + " + UPDATE collections + SET status = $1 + WHERE (id = $2) + ", + status.to_string(), + id as database::models::ids::CollectionId, + ) + .execute(&mut *transaction) + .await?; + } + } + + if let Some(new_project_ids) = &new_collection.new_projects { + // Delete all existing projects + sqlx::query!( + " + DELETE FROM collections_mods + WHERE collection_id = $1 + ", + collection_item.id as database::models::ids::CollectionId, + ) + .execute(&mut *transaction) + .await?; + + for project_id in new_project_ids { + let project = database::models::Project::get(project_id, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput(format!( + "The specified project {project_id} does not exist!" + )) + })?; + + // Insert- don't throw an error if it already exists + sqlx::query!( + " + INSERT INTO collections_mods (collection_id, mod_id) + VALUES ($1, $2) + ON CONFLICT DO NOTHING + ", + collection_item.id as database::models::ids::CollectionId, + project.inner.id as database::models::ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + } + + database::models::Collection::clear_cache(collection_item.id, &redis).await?; + + transaction.commit().await?; + Ok(HttpResponse::NoContent().body("")) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +#[derive(Serialize, Deserialize)] +pub struct Extension { + pub ext: String, +} + +#[patch("{id}/icon")] +#[allow(clippy::too_many_arguments)] +pub async fn collection_icon_edit( + web::Query(ext): web::Query, + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + mut payload: web::Payload, + session_queue: web::Data, +) -> Result { + if let Some(content_type) = crate::util::ext::get_image_content_type(&ext.ext) { + let cdn_url = dotenvy::var("CDN_URL")?; + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::COLLECTION_WRITE]), + ) + .await + .map(|x| x.1) + .ok(); + + let string = info.into_inner().0; + let id = database::models::CollectionId(parse_base62(&string)? as i64); + let collection_item = database::models::Collection::get(id, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified collection does not exist!".to_string()) + })?; + + if !is_authorized_collection(&collection_item, &user_option).await? { + return Ok(HttpResponse::Unauthorized().body("")); + } + + if let Some(icon) = collection_item.icon_url { + let name = icon.split(&format!("{cdn_url}/")).nth(1); + + if let Some(icon_path) = name { + file_host.delete_file_version("", icon_path).await?; + } + } + + let bytes = + read_from_payload(&mut payload, 262144, "Icons must be smaller than 256KiB").await?; + + let color = crate::util::img::get_color_from_img(&bytes)?; + + let hash = sha1::Sha1::from(&bytes).hexdigest(); + let collection_id: CollectionId = collection_item.id.into(); + let upload_data = file_host + .upload_file( + content_type, + &format!("data/{}/{}.{}", collection_id, hash, ext.ext), + bytes.freeze(), + ) + .await?; + + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE collections + SET icon_url = $1, color = $2 + WHERE (id = $3) + ", + format!("{}/{}", cdn_url, upload_data.file_name), + color.map(|x| x as i32), + collection_item.id as database::models::ids::CollectionId, + ) + .execute(&mut *transaction) + .await?; + + database::models::Collection::clear_cache(collection_item.id, &redis).await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::InvalidInput(format!( + "Invalid format for collection icon: {}", + ext.ext + ))) + } +} + +#[delete("{id}/icon")] +pub async fn delete_collection_icon( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + session_queue: web::Data, +) -> Result { + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::COLLECTION_WRITE]), + ) + .await + .map(|x| x.1) + .ok(); + let string = info.into_inner().0; + let id = database::models::CollectionId(parse_base62(&string)? as i64); + let collection_item = database::models::Collection::get(id, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified collection does not exist!".to_string()) + })?; + if !is_authorized_collection(&collection_item, &user_option).await? { + return Ok(HttpResponse::Unauthorized().body("")); + } + + let cdn_url = dotenvy::var("CDN_URL")?; + if let Some(icon) = collection_item.icon_url { + let name = icon.split(&format!("{cdn_url}/")).nth(1); + + if let Some(icon_path) = name { + file_host.delete_file_version("", icon_path).await?; + } + } + + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE collections + SET icon_url = NULL, color = NULL + WHERE (id = $1) + ", + collection_item.id as database::models::ids::CollectionId, + ) + .execute(&mut *transaction) + .await?; + + database::models::Collection::clear_cache(collection_item.id, &redis).await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) +} + +#[delete("{id}")] +pub async fn collection_delete( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::COLLECTION_DELETE]), + ) + .await + .map(|x| x.1) + .ok(); + + let string = info.into_inner().0; + let id = database::models::CollectionId(parse_base62(&string)? as i64); + let collection = database::models::Collection::get(id, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The specified collection does not exist!".to_string()) + })?; + if !is_authorized_collection(&collection, &user_option).await? { + return Ok(HttpResponse::Unauthorized().body("")); + } + let mut transaction = pool.begin().await?; + + let result = + database::models::Collection::remove(collection.id, &mut transaction, &redis).await?; + database::models::Collection::clear_cache(collection.id, &redis).await?; + + transaction.commit().await?; + + if result.is_some() { + Ok(HttpResponse::NoContent().body("")) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} diff --git a/src/routes/v2/images.rs b/src/routes/v2/images.rs new file mode 100644 index 000000000..a945d1e7c --- /dev/null +++ b/src/routes/v2/images.rs @@ -0,0 +1,233 @@ +use std::sync::Arc; + +use crate::auth::{get_user_from_headers, is_authorized, is_authorized_version}; +use crate::database; +use crate::database::models::{project_item, report_item, thread_item, version_item}; +use crate::file_hosting::FileHost; +use crate::models::ids::{ThreadMessageId, VersionId}; +use crate::models::images::{Image, ImageContext}; +use crate::models::reports::ReportId; +use crate::queue::session::AuthQueue; +use crate::routes::v2::threads::is_authorized_thread; +use crate::routes::ApiError; +use crate::util::routes::read_from_payload; +use actix_web::{post, web, HttpRequest, HttpResponse}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(images_add); +} + +#[derive(Serialize, Deserialize)] +pub struct ImageUpload { + pub ext: String, + + // Context must be an allowed context + // currently: project, version, thread_message, report + pub context: String, + + // Optional context id to associate with + pub project_id: Option, // allow slug or id + pub version_id: Option, + pub thread_message_id: Option, + pub report_id: Option, +} + +#[post("image")] +pub async fn images_add( + req: HttpRequest, + web::Query(data): web::Query, + file_host: web::Data>, + mut payload: web::Payload, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + if let Some(content_type) = crate::util::ext::get_image_content_type(&data.ext) { + let mut context = ImageContext::from_str(&data.context, None); + + let scopes = vec![context.relevant_scope()]; + + let cdn_url = dotenvy::var("CDN_URL")?; + let user = get_user_from_headers(&req, &**pool, &redis, &session_queue, Some(&scopes)) + .await? + .1; + + // Attempt to associated a supplied id with the context + // If the context cannot be found, or the user is not authorized to upload images for the context, return an error + match &mut context { + ImageContext::Project { project_id } => { + if let Some(id) = data.project_id { + let project = project_item::Project::get(&id, &**pool, &redis).await?; + if let Some(project) = project { + if is_authorized(&project.inner, &Some(user.clone()), &pool).await? { + *project_id = Some(project.inner.id.into()); + } else { + return Err(ApiError::CustomAuthentication( + "You are not authorized to upload images for this project" + .to_string(), + )); + } + } else { + return Err(ApiError::InvalidInput( + "The project could not be found.".to_string(), + )); + } + } + } + ImageContext::Version { version_id } => { + if let Some(id) = data.version_id { + let version = version_item::Version::get(id.into(), &**pool, &redis).await?; + if let Some(version) = version { + if is_authorized_version(&version.inner, &Some(user.clone()), &pool).await? + { + *version_id = Some(version.inner.id.into()); + } else { + return Err(ApiError::CustomAuthentication( + "You are not authorized to upload images for this version" + .to_string(), + )); + } + } else { + return Err(ApiError::InvalidInput( + "The version could not be found.".to_string(), + )); + } + } + } + ImageContext::ThreadMessage { thread_message_id } => { + if let Some(id) = data.thread_message_id { + let thread_message = thread_item::ThreadMessage::get(id.into(), &**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The thread message could not found.".to_string(), + ) + })?; + let thread = thread_item::Thread::get(thread_message.thread_id, &**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The thread associated with the thread message could not be found" + .to_string(), + ) + })?; + if is_authorized_thread(&thread, &user, &pool).await? { + *thread_message_id = Some(thread_message.id.into()); + } else { + return Err(ApiError::CustomAuthentication( + "You are not authorized to upload images for this thread message" + .to_string(), + )); + } + } + } + ImageContext::Report { report_id } => { + if let Some(id) = data.report_id { + let report = report_item::Report::get(id.into(), &**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("The report could not be found.".to_string()) + })?; + let thread = thread_item::Thread::get(report.thread_id, &**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The thread associated with the report could not be found." + .to_string(), + ) + })?; + if is_authorized_thread(&thread, &user, &pool).await? { + *report_id = Some(report.id.into()); + } else { + return Err(ApiError::CustomAuthentication( + "You are not authorized to upload images for this report".to_string(), + )); + } + } + } + ImageContext::Unknown => { + return Err(ApiError::InvalidInput( + "Context must be one of: project, version, thread_message, report".to_string(), + )); + } + } + + // Upload the image to the file host + let bytes = + read_from_payload(&mut payload, 1_048_576, "Icons must be smaller than 1MiB").await?; + + let hash = sha1::Sha1::from(&bytes).hexdigest(); + let upload_data = file_host + .upload_file( + content_type, + &format!("data/cached_images/{}.{}", hash, data.ext), + bytes.freeze(), + ) + .await?; + + let mut transaction = pool.begin().await?; + + let db_image: database::models::Image = database::models::Image { + id: database::models::generate_image_id(&mut transaction).await?, + url: format!("{}/{}", cdn_url, upload_data.file_name), + size: upload_data.content_length as u64, + created: chrono::Utc::now(), + owner_id: database::models::UserId::from(user.id), + context: context.context_as_str().to_string(), + project_id: if let ImageContext::Project { + project_id: Some(id), + } = context + { + Some(database::models::ProjectId::from(id)) + } else { + None + }, + version_id: if let ImageContext::Version { + version_id: Some(id), + } = context + { + Some(database::models::VersionId::from(id)) + } else { + None + }, + thread_message_id: if let ImageContext::ThreadMessage { + thread_message_id: Some(id), + } = context + { + Some(database::models::ThreadMessageId::from(id)) + } else { + None + }, + report_id: if let ImageContext::Report { + report_id: Some(id), + } = context + { + Some(database::models::ReportId::from(id)) + } else { + None + }, + }; + + // Insert + db_image.insert(&mut transaction).await?; + + let image = Image { + id: db_image.id.into(), + url: db_image.url, + size: db_image.size, + created: db_image.created, + owner_id: db_image.owner_id.into(), + context, + }; + + transaction.commit().await?; + + Ok(HttpResponse::Ok().json(image)) + } else { + Err(ApiError::InvalidInput( + "The specified file is not an image!".to_string(), + )) + } +} diff --git a/src/routes/v2/mod.rs b/src/routes/v2/mod.rs index e3b769e83..e2c9443fc 100644 --- a/src/routes/v2/mod.rs +++ b/src/routes/v2/mod.rs @@ -1,4 +1,6 @@ mod admin; +mod collections; +mod images; mod analytics_get; mod moderation; mod notifications; @@ -30,6 +32,8 @@ pub fn config(cfg: &mut actix_web::web::ServiceConfig) { .configure(notifications::config) //.configure(pats::config) .configure(project_creation::config) + .configure(collections::config) + .configure(images::config) .configure(projects::config) .configure(reports::config) .configure(statistics::config) diff --git a/src/routes/v2/project_creation.rs b/src/routes/v2/project_creation.rs index 4a1ffaa2f..906f210af 100644 --- a/src/routes/v2/project_creation.rs +++ b/src/routes/v2/project_creation.rs @@ -1,9 +1,11 @@ use super::version_creation::InitialVersionData; use crate::auth::{get_user_from_headers, AuthenticationError}; -use crate::database::models; use crate::database::models::thread_item::ThreadBuilder; +use crate::database::models::{self, image_item}; use crate::file_hosting::{FileHost, FileHostingError}; use crate::models::error::ApiError; +use crate::models::ids::ImageId; +use crate::models::images::{Image, ImageContext}; use crate::models::pats::Scopes; use crate::models::projects::{ DonationLink, License, MonetizationStatus, ProjectId, ProjectStatus, SideType, VersionId, @@ -233,6 +235,11 @@ struct ProjectCreateData { #[serde(default = "default_requested_status")] /// The status of the mod to be set once it is approved pub requested_status: ProjectStatus, + + // Associations to uploaded images in body/description + #[validate(length(max = 10))] + #[serde(default)] + pub uploaded_images: Vec, } #[derive(Serialize, Deserialize, Validate, Clone)] @@ -466,7 +473,6 @@ async fn project_create_inner( .await?, ); } - project_create_data = create_data; } @@ -512,7 +518,7 @@ async fn project_create_inner( icon_data = Some( process_icon_upload( uploaded_files, - project_id, + project_id.0, file_extension, file_host, field, @@ -779,6 +785,41 @@ async fn project_create_inner( let id = project_builder_actual.insert(&mut *transaction).await?; + for image_id in project_create_data.uploaded_images { + if let Some(db_image) = + image_item::Image::get(image_id.into(), &mut *transaction, redis).await? + { + let image: Image = db_image.into(); + if !matches!(image.context, ImageContext::Project { .. }) + || image.context.inner_id().is_some() + { + return Err(CreateError::InvalidInput(format!( + "Image {} is not unused and in the 'project' context", + image_id + ))); + } + + sqlx::query!( + " + UPDATE uploaded_images + SET mod_id = $1 + WHERE id = $2 + ", + id as models::ids::ProjectId, + image_id.0 as i64 + ) + .execute(&mut *transaction) + .await?; + + image_item::Image::clear_cache(image.id.into(), redis).await?; + } else { + return Err(CreateError::InvalidInput(format!( + "Image {} does not exist", + image_id + ))); + } + } + let thread_id = ThreadBuilder { type_: ThreadType::Project, members: vec![], @@ -935,7 +976,7 @@ async fn create_initial_version( async fn process_icon_upload( uploaded_files: &mut Vec, - project_id: ProjectId, + id: u64, file_extension: &str, file_host: &dyn FileHost, mut field: Field, @@ -950,7 +991,7 @@ async fn process_icon_upload( let upload_data = file_host .upload_file( content_type, - &format!("data/{project_id}/{hash}.{file_extension}"), + &format!("data/{id}/{hash}.{file_extension}"), data.freeze(), ) .await?; diff --git a/src/routes/v2/projects.rs b/src/routes/v2/projects.rs index d75cc4b9d..73f166b86 100644 --- a/src/routes/v2/projects.rs +++ b/src/routes/v2/projects.rs @@ -1,10 +1,12 @@ use crate::auth::{filter_authorized_projects, get_user_from_headers, is_authorized}; use crate::database; +use crate::database::models::image_item; use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::thread_item::ThreadMessageBuilder; use crate::file_hosting::FileHost; use crate::models; use crate::models::ids::base62_impl::parse_base62; +use crate::models::images::ImageContext; use crate::models::notifications::NotificationBody; use crate::models::pats::Scopes; use crate::models::projects::{ @@ -15,6 +17,7 @@ use crate::models::threads::MessageBody; use crate::queue::session::AuthQueue; use crate::routes::ApiError; use crate::search::{search_for_project, SearchConfig, SearchError}; +use crate::util::img; use crate::util::routes::read_from_payload; use crate::util::validate::validation_errors_to_string; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; @@ -1112,6 +1115,18 @@ pub async fn project_edit( .await?; } + // check new description and body for links to associated images + // if they no longer exist in the description or body, delete them + let checkable_strings: Vec<&str> = vec![&new_project.description, &new_project.body] + .into_iter() + .filter_map(|x| x.as_ref().map(|y| y.as_str())) + .collect(); + + let context = ImageContext::Project { + project_id: Some(id.into()), + }; + + img::delete_unused_images(context, checkable_strings, &mut transaction, &redis).await?; database::models::Project::clear_cache( project_item.inner.id, project_item.inner.slug, @@ -2280,6 +2295,24 @@ pub async fn project_delete( } let mut transaction = pool.begin().await?; + let context = ImageContext::Project { + project_id: Some(project.inner.id.into()), + }; + let uploaded_images = + database::models::Image::get_many_contexted(context, &mut transaction).await?; + for image in uploaded_images { + image_item::Image::remove(image.id, &mut transaction, &redis).await?; + } + + sqlx::query!( + " + DELETE FROM collections_mods + WHERE mod_id = $1 + ", + project.inner.id as database::models::ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; let result = database::models::Project::remove(project.inner.id, &mut transaction, &redis).await?; diff --git a/src/routes/v2/reports.rs b/src/routes/v2/reports.rs index 5f71bef4d..90960e30f 100644 --- a/src/routes/v2/reports.rs +++ b/src/routes/v2/reports.rs @@ -1,11 +1,16 @@ use crate::auth::{check_is_moderator_from_headers, get_user_from_headers}; +use crate::database; +use crate::database::models::image_item; use crate::database::models::thread_item::{ThreadBuilder, ThreadMessageBuilder}; +use crate::models::ids::ImageId; use crate::models::ids::{base62_impl::parse_base62, ProjectId, UserId, VersionId}; +use crate::models::images::{Image, ImageContext}; use crate::models::pats::Scopes; use crate::models::reports::{ItemType, Report}; use crate::models::threads::{MessageBody, ThreadType}; use crate::queue::session::AuthQueue; use crate::routes::ApiError; +use crate::util::img; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use chrono::Utc; use futures::StreamExt; @@ -22,12 +27,16 @@ pub fn config(cfg: &mut web::ServiceConfig) { cfg.service(report_get); } -#[derive(Deserialize)] +#[derive(Deserialize, Validate)] pub struct CreateReport { pub report_type: String, pub item_id: String, pub item_type: ItemType, pub body: String, + // Associations to uploaded images + #[validate(length(max = 10))] + #[serde(default)] + pub uploaded_images: Vec, } #[post("report")] @@ -147,6 +156,42 @@ pub async fn report_create( } report.insert(&mut transaction).await?; + + for image_id in new_report.uploaded_images { + if let Some(db_image) = + image_item::Image::get(image_id.into(), &mut *transaction, &redis).await? + { + let image: Image = db_image.into(); + if !matches!(image.context, ImageContext::Report { .. }) + || image.context.inner_id().is_some() + { + return Err(ApiError::InvalidInput(format!( + "Image {} is not unused and in the 'report' context", + image_id + ))); + } + + sqlx::query!( + " + UPDATE uploaded_images + SET report_id = $1 + WHERE id = $2 + ", + id.0 as i64, + image_id.0 as i64 + ) + .execute(&mut *transaction) + .await?; + + image_item::Image::clear_cache(image.id.into(), &redis).await?; + } else { + return Err(ApiError::InvalidInput(format!( + "Image {} could not be found", + image_id + ))); + } + } + let thread_id = ThreadBuilder { type_: ThreadType::Report, members: vec![], @@ -423,6 +468,17 @@ pub async fn report_edit( .await?; } + // delete any images no longer in the body + let checkable_strings: Vec<&str> = vec![&edit_report.body] + .into_iter() + .filter_map(|x: &Option| x.as_ref().map(|y| y.as_str())) + .collect(); + let image_context = ImageContext::Report { + report_id: Some(id.into()), + }; + img::delete_unused_images(image_context, checkable_strings, &mut transaction, &redis) + .await?; + transaction.commit().await?; Ok(HttpResponse::NoContent().body("")) @@ -442,11 +498,20 @@ pub async fn report_delete( check_is_moderator_from_headers(&req, &**pool, &redis, &session_queue).await?; let mut transaction = pool.begin().await?; - let result = crate::database::models::report_item::Report::remove_full( - info.into_inner().0.into(), - &mut transaction, - ) - .await?; + + let id = info.into_inner().0; + let context = ImageContext::Report { + report_id: Some(id), + }; + let uploaded_images = + database::models::Image::get_many_contexted(context, &mut transaction).await?; + for image in uploaded_images { + image_item::Image::remove(image.id, &mut transaction, &redis).await?; + } + + let result = + crate::database::models::report_item::Report::remove_full(id.into(), &mut transaction) + .await?; transaction.commit().await?; if result.is_some() { diff --git a/src/routes/v2/threads.rs b/src/routes/v2/threads.rs index b9f539068..8ec218058 100644 --- a/src/routes/v2/threads.rs +++ b/src/routes/v2/threads.rs @@ -1,8 +1,13 @@ +use std::sync::Arc; + use crate::auth::{check_is_moderator_from_headers, get_user_from_headers}; use crate::database; +use crate::database::models::image_item; use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::thread_item::ThreadMessageBuilder; +use crate::file_hosting::FileHost; use crate::models::ids::ThreadMessageId; +use crate::models::images::{Image, ImageContext}; use crate::models::notifications::NotificationBody; use crate::models::pats::Scopes; use crate::models::projects::ProjectStatus; @@ -327,6 +332,7 @@ pub async fn thread_send_message( body, replying_to, private, + .. } = &new_message.body { if body.len() > 65536 { @@ -452,6 +458,46 @@ pub async fn thread_send_message( .execute(&mut *transaction) .await?; + if let MessageBody::Text { + associated_images, .. + } = &new_message.body + { + for image_id in associated_images { + if let Some(db_image) = + image_item::Image::get((*image_id).into(), &mut *transaction, &redis).await? + { + let image: Image = db_image.into(); + if !matches!(image.context, ImageContext::ThreadMessage { .. }) + || image.context.inner_id().is_some() + { + return Err(ApiError::InvalidInput(format!( + "Image {} is not unused and in the 'thread_message' context", + image_id + ))); + } + + sqlx::query!( + " + UPDATE uploaded_images + SET thread_message_id = $1 + WHERE id = $2 + ", + thread.id.0, + image_id.0 as i64 + ) + .execute(&mut *transaction) + .await?; + + image_item::Image::clear_cache(image.id.into(), &redis).await?; + } else { + return Err(ApiError::InvalidInput(format!( + "Image {} does not exist", + image_id + ))); + } + } + } + transaction.commit().await?; Ok(HttpResponse::NoContent().body("")) @@ -523,6 +569,7 @@ pub async fn message_delete( pool: web::Data, redis: web::Data, session_queue: web::Data, + file_host: web::Data>, ) -> Result { let user = get_user_from_headers( &req, @@ -544,6 +591,20 @@ pub async fn message_delete( } let mut transaction = pool.begin().await?; + + let context = ImageContext::ThreadMessage { + thread_message_id: Some(thread.id.into()), + }; + let images = database::Image::get_many_contexted(context, &mut transaction).await?; + let cdn_url = dotenvy::var("CDN_URL")?; + 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?; + } + database::Image::remove(image.id, &mut transaction, &redis).await?; + } + database::models::ThreadMessage::remove_full(thread.id, &mut transaction).await?; transaction.commit().await?; diff --git a/src/routes/v2/version_creation.rs b/src/routes/v2/version_creation.rs index 58359bbd2..7a398e2f7 100644 --- a/src/routes/v2/version_creation.rs +++ b/src/routes/v2/version_creation.rs @@ -1,11 +1,12 @@ use super::project_creation::{CreateError, UploadedFile}; use crate::auth::get_user_from_headers; -use crate::database::models; use crate::database::models::notification_item::NotificationBuilder; use crate::database::models::version_item::{ DependencyBuilder, VersionBuilder, VersionFileBuilder, }; +use crate::database::models::{self, image_item}; use crate::file_hosting::FileHost; +use crate::models::images::{Image, ImageContext, ImageId}; use crate::models::notifications::NotificationBody; use crate::models::pack::PackFileHash; use crate::models::pats::Scopes; @@ -70,6 +71,10 @@ pub struct InitialVersionData { pub status: VersionStatus, #[serde(default = "HashMap::new")] pub file_types: HashMap>, + // Associations to uploaded images in changelog + #[validate(length(max = 10))] + #[serde(default)] + pub uploaded_images: Vec, } #[derive(Serialize, Deserialize, Clone)] @@ -436,6 +441,41 @@ async fn version_create_inner( let project_id = builder.project_id; builder.insert(transaction).await?; + for image_id in version_data.uploaded_images { + if let Some(db_image) = + image_item::Image::get(image_id.into(), &mut *transaction, redis).await? + { + let image: Image = db_image.into(); + if !matches!(image.context, ImageContext::Report { .. }) + || image.context.inner_id().is_some() + { + return Err(CreateError::InvalidInput(format!( + "Image {} is not unused and in the 'version' context", + image_id + ))); + } + + sqlx::query!( + " + UPDATE uploaded_images + SET version_id = $1 + WHERE id = $2 + ", + version_id.0 as i64, + image_id.0 as i64 + ) + .execute(&mut *transaction) + .await?; + + image_item::Image::clear_cache(image.id.into(), redis).await?; + } else { + return Err(CreateError::InvalidInput(format!( + "Image {} does not exist", + image_id + ))); + } + } + models::Project::update_game_versions(project_id, &mut *transaction).await?; models::Project::update_loaders(project_id, &mut *transaction).await?; models::Project::clear_cache(project_id, None, Some(true), redis).await?; diff --git a/src/routes/v2/versions.rs b/src/routes/v2/versions.rs index 93da53d4a..ee949a6c2 100644 --- a/src/routes/v2/versions.rs +++ b/src/routes/v2/versions.rs @@ -3,12 +3,15 @@ use crate::auth::{ filter_authorized_versions, get_user_from_headers, is_authorized, is_authorized_version, }; use crate::database; +use crate::database::models::image_item; use crate::models; use crate::models::ids::base62_impl::parse_base62; +use crate::models::images::ImageContext; use crate::models::pats::Scopes; use crate::models::projects::{Dependency, FileType, VersionStatus, VersionType}; use crate::models::teams::Permissions; use crate::queue::session::AuthQueue; +use crate::util::img; use crate::util::validate::validation_errors_to_string; use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; use chrono::{DateTime, Utc}; @@ -672,6 +675,17 @@ pub async fn version_edit( } } + // delete any images no longer in the changelog + let checkable_strings: Vec<&str> = vec![&new_version.changelog] + .into_iter() + .filter_map(|x| x.as_ref().map(|y| y.as_str())) + .collect(); + let context = ImageContext::Version { + version_id: Some(version_item.inner.id.into()), + }; + + img::delete_unused_images(context, checkable_strings, &mut transaction, &redis).await?; + database::models::Version::clear_cache(&version_item, &redis).await?; database::models::Project::clear_cache( version_item.inner.project_id, @@ -823,6 +837,14 @@ pub async fn version_delete( } let mut transaction = pool.begin().await?; + let context = ImageContext::Version { + version_id: Some(version.inner.id.into()), + }; + let uploaded_images = + database::models::Image::get_many_contexted(context, &mut transaction).await?; + for image in uploaded_images { + image_item::Image::remove(image.id, &mut transaction, &redis).await?; + } let result = database::models::Version::remove_full(version.inner.id, &redis, &mut transaction).await?; diff --git a/src/util/img.rs b/src/util/img.rs index 78af4afee..99574e22b 100644 --- a/src/util/img.rs +++ b/src/util/img.rs @@ -2,6 +2,11 @@ use color_thief::ColorFormat; use image::imageops::FilterType; use image::{EncodableLayout, ImageError}; +use crate::database; +use crate::database::models::image_item; +use crate::models::images::ImageContext; +use crate::routes::ApiError; + pub fn get_color_from_img(data: &[u8]) -> Result, ImageError> { let image = image::load_from_memory(data)? .resize(256, 256, FilterType::Nearest) @@ -13,3 +18,32 @@ pub fn get_color_from_img(data: &[u8]) -> Result, ImageError> { Ok(color) } + +// check changes to associated images +// if they no longer exist in the String list, delete them +// Eg: if description is modified and no longer contains a link to an iamge +pub async fn delete_unused_images( + context: ImageContext, + reference_strings: Vec<&str>, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &deadpool_redis::Pool, +) -> Result<(), ApiError> { + let uploaded_images = database::models::Image::get_many_contexted(context, transaction).await?; + + for image in uploaded_images { + let mut should_delete = true; + for reference in &reference_strings { + if image.url.contains(reference) { + should_delete = false; + break; + } + } + + if should_delete { + image_item::Image::remove(image.id, transaction, redis).await?; + image_item::Image::clear_cache(image.id, redis).await?; + } + } + + Ok(()) +}