diff --git a/.env b/.env index 9aa32eb0..3acfccee 100644 --- a/.env +++ b/.env @@ -1,7 +1,7 @@ DEBUG=true RUST_LOG=info,sqlx::query=warn -CDN_URL=cdn.modrinth.com +CDN_URL=https://cdn.modrinth.com DATABASE_URL=postgresql://labrinth@localhost/labrinth MEILISEARCH_ADDR=http://localhost:7700 diff --git a/migrations/20200730223151_more-not-null.sql b/migrations/20200730223151_more-not-null.sql new file mode 100644 index 00000000..2fb04bb1 --- /dev/null +++ b/migrations/20200730223151_more-not-null.sql @@ -0,0 +1,6 @@ +-- Add migration script here +ALTER TABLE versions +ALTER COLUMN mod_id SET NOT NULL; + +ALTER TABLE release_channels +ALTER COLUMN channel SET NOT NULL; diff --git a/migrations/20200812183213_unique-loaders.sql b/migrations/20200812183213_unique-loaders.sql new file mode 100644 index 00000000..9d44fbef --- /dev/null +++ b/migrations/20200812183213_unique-loaders.sql @@ -0,0 +1,5 @@ +ALTER TABLE game_versions +ADD UNIQUE(version); + +ALTER TABLE loaders +ADD UNIQUE(loader); diff --git a/sqlx-data.json b/sqlx-data.json index 85c6db26..4056865f 100644 --- a/sqlx-data.json +++ b/sqlx-data.json @@ -1,5 +1,31 @@ { "db": "PostgreSQL", + "1524c0462be70077736ac70fcd037fbf75651456b692e2ce40fa2e3fc8123984": { + "query": "\n SELECT hashes.algorithm, hashes.hash FROM hashes\n WHERE hashes.file_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "algorithm", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "hash", + "type_info": "Bytea" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false + ] + } + }, "15b2a2f1bbbbab4f1d99e5e428b2ffba77c83814b936fa6e10e2703b207f6e9a": { "query": "\n INSERT INTO team_members (id, team_id, user_id, member_name, role)\n VALUES ($1, $2, $3, $4, $5)\n ", "describe": { @@ -16,6 +42,26 @@ "nullable": [] } }, + "1b74bdb59773ffd2a78a56e4d920bb83c322e180e6174c741d4bb722c353de43": { + "query": "\n INSERT INTO loaders (loader)\n VALUES ($1)\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Varchar" + ] + }, + "nullable": [ + false + ] + } + }, "1ffce9b2d5c9fa6c8b9abce4bad9f9419c44ad6367b7463b979c91b9b5b4fea1": { "query": "SELECT EXISTS(SELECT 1 FROM versions WHERE id=$1)", "describe": { @@ -36,6 +82,26 @@ ] } }, + "25131559cb73a088000ab6379a769233440ade6c7511542da410065190d203fc": { + "query": "\n SELECT id FROM loaders\n WHERE loader = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + } + }, "29e657d26f0fb24a766f5b5eb6a94d01d1616884d8ca10e91536e974d5b585a6": { "query": "\n INSERT INTO loaders_versions (loader_id, version_id)\n VALUES ($1, $2)\n ", "describe": { @@ -49,49 +115,74 @@ "nullable": [] } }, - "320b24c5ec3c7e71a4088a2862fb02b31a3d3cfc331ccd60d73dfd49af3e53c0": { - "query": "\n SELECT *\n FROM versions\n WHERE id = $1\n ", + "2fa070eef3fe8f708a1495104f78eda2bfa0fe19ada2bf66ac35fb2468631774": { + "query": "\n SELECT category FROM categories\n ", "describe": { "columns": [ { "ordinal": 0, - "name": "id", - "type_info": "Int8" - }, + "name": "category", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + } + }, + "33fc96ac71cfa382991cfb153e89da1e9f43ebf5367c28b30c336b758222307b": { + "query": "\n DELETE FROM loaders_versions\n WHERE loaders_versions.version_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + } + }, + "35272854c6aeb743218e73ccf6f34427ab72f25492dfa752f87a50e3da7204c5": { + "query": "\n SELECT v.mod_id, v.name, v.version_number,\n v.changelog_url, v.date_published, v.downloads,\n release_channels.channel\n FROM versions v\n INNER JOIN release_channels ON v.release_channel = release_channels.id\n WHERE v.id = $1\n ", + "describe": { + "columns": [ { - "ordinal": 1, + "ordinal": 0, "name": "mod_id", "type_info": "Int8" }, { - "ordinal": 2, + "ordinal": 1, "name": "name", "type_info": "Varchar" }, { - "ordinal": 3, + "ordinal": 2, "name": "version_number", "type_info": "Varchar" }, { - "ordinal": 4, + "ordinal": 3, "name": "changelog_url", "type_info": "Varchar" }, { - "ordinal": 5, + "ordinal": 4, "name": "date_published", "type_info": "Timestamptz" }, { - "ordinal": 6, + "ordinal": 5, "name": "downloads", "type_info": "Int4" }, { - "ordinal": 7, - "name": "release_channel", - "type_info": "Int4" + "ordinal": 6, + "name": "channel", + "type_info": "Varchar" } ], "parameters": { @@ -103,7 +194,6 @@ false, false, false, - false, true, false, false, @@ -111,6 +201,38 @@ ] } }, + "398ac436f5fe2f6a66544204b9ff01ae1ea1204edf03ffc16de657a861cfe0ba": { + "query": "\n DELETE FROM categories\n WHERE category = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + } + }, + "4411f2aefd43881450da34db81e826110ac86c3a6cef9fd6a3e9e341508d1f09": { + "query": "\n SELECT id FROM versions\n WHERE mod_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + } + }, "449920c44d498adf8b771973d6034dc97e1c7f3ff4d9d23599af432f294ed564": { "query": "\n INSERT INTO files (id, version_id, url, filename)\n VALUES ($1, $2, $3, $4)\n ", "describe": { @@ -146,6 +268,26 @@ ] } }, + "4c9e2190e2a68ffc093a69aaa1fc9384957138f57ac9cd85cbc6179613c13a08": { + "query": "SELECT EXISTS(SELECT 1 FROM mods WHERE id = $1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + } + }, "560c3ba57c965c3ebdbe393b062da8a30a8a7116a9bace2aa7de2e8431fe0bc7": { "query": "\n INSERT INTO mods_categories (joining_mod_id, joining_category_id)\n VALUES ($1, $2)\n ", "describe": { @@ -159,6 +301,262 @@ "nullable": [] } }, + "56cb9274e92f185dee3accf69cca2e34c035efbef908baefeb60548fb14e02bd": { + "query": "\n INSERT INTO categories (category)\n VALUES ($1)\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Varchar" + ] + }, + "nullable": [ + false + ] + } + }, + "59cf9d085593887595ea45246291f2cd64fc6677d551e96bdb60c09ff1eebf99": { + "query": "\n SELECT files.id, files.url, files.filename FROM files\n WHERE files.version_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "url", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "filename", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false + ] + } + }, + "5aaae159c75c9385f4d969338bce509852d4b3e3ae9d4c4e366055b5b499b19a": { + "query": "\n SELECT v.mod_id, v.name, v.version_number,\n v.changelog_url, v.date_published, v.downloads,\n v.release_channel\n FROM versions v\n WHERE v.id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "version_number", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "changelog_url", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "date_published", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "downloads", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "release_channel", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + false + ] + } + }, + "6b28cb8b54ef57c9b6f03607611f688455f0e2b27eb5deda5a8cbc5b506b4602": { + "query": "\n DELETE FROM mods\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + } + }, + "72d6b5f2f11d88981db82c7247c9e7e5ebfd8d34985a1a8209d6628e66490f37": { + "query": "\n SELECT id FROM categories\n WHERE category = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + } + }, + "73bdd6c9e7cd8c1ed582261aebdee0f8fd2734e712ef288a2608564c918009cb": { + "query": "\n DELETE FROM versions WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + } + }, + "89fbff6249b248d3e150879aaea1662140bcb10d5104992c784285322c8b3b94": { + "query": "\n SELECT version FROM game_versions\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + } + }, + "8f706d78ac4235ea04c59e2c220a4791e1d08fdf287b783b4aaef36fd2445467": { + "query": "\n DELETE FROM loaders\n WHERE loader = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + } + }, + "96d7b2c8b7b69fc370bb1a2d4a449f972eb3893dad5d6c59e498663cfc93a5c3": { + "query": "\n SELECT title, description, downloads,\n icon_url, body_url, published,\n issues_url, source_url, wiki_url,\n team_id\n FROM mods\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "title", + "type_info": "Varchar" + }, + { + "ordinal": 1, + "name": "description", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "downloads", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "icon_url", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "body_url", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "published", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "issues_url", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "source_url", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "wiki_url", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "team_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + false, + true, + true, + true, + false + ] + } + }, "a55925860b4a46af864a8c38f942d7cdd85c00638e761b9696de0bf47335173b": { "query": "\n SELECT mod_id, version_number\n FROM versions\n WHERE id = $1\n ", "describe": { @@ -185,8 +583,8 @@ ] } }, - "b133dbf99fbf7b02e0e7ebd7948445bec2ce952ea0f926575fae0af07913d8b6": { - "query": "\n SELECT *\n FROM versions\n WHERE id = $1\n ", + "a5d47fb171b0a1ba322125e7cedebf5af9c5831c319bbc4f8f087cb63322bee3": { + "query": "\n SELECT files.id, files.url, files.filename FROM files\n WHERE files.version_id = $1\n ", "describe": { "columns": [ { @@ -196,38 +594,13 @@ }, { "ordinal": 1, - "name": "mod_id", - "type_info": "Int8" + "name": "url", + "type_info": "Varchar" }, { "ordinal": 2, - "name": "name", + "name": "filename", "type_info": "Varchar" - }, - { - "ordinal": 3, - "name": "version_number", - "type_info": "Varchar" - }, - { - "ordinal": 4, - "name": "changelog_url", - "type_info": "Varchar" - }, - { - "ordinal": 5, - "name": "date_published", - "type_info": "Timestamptz" - }, - { - "ordinal": 6, - "name": "downloads", - "type_info": "Int4" - }, - { - "ordinal": 7, - "name": "release_channel", - "type_info": "Int4" } ], "parameters": { @@ -236,17 +609,48 @@ ] }, "nullable": [ - false, - false, - false, - false, - true, false, false, false ] } }, + "a647c282a276b63f36d2d8a253c32d0f627cea9cab8eb1b32b39875536bdfcbb": { + "query": "\n DELETE FROM mods_categories\n WHERE joining_mod_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + } + }, + "b0e3d1c70b87bb54819e3fac04b684a9b857aeedb4dcb7cb400c2af0dbb12922": { + "query": "\n DELETE FROM teams\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + } + }, + "b903ac4e686ef85ba28d698c668da07860e7f276b261d8f2cebb74e73b094970": { + "query": "\n DELETE FROM hashes\n WHERE EXISTS(\n SELECT 1 FROM files WHERE\n (files.version_id = $1) AND\n (hashes.file_id = files.id)\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + } + }, "b9399840dbbf807a03d69b7fcb3bd479ef20920ab1e3c91706a1c2c7089f48e7": { "query": "\n INSERT INTO teams (id)\n VALUES ($1)\n ", "describe": { @@ -294,6 +698,58 @@ "nullable": [] } }, + "bec1612d4929d143bc5d6860a57cc036c5ab23e69d750ca5791c620297953c50": { + "query": "\n SELECT team_id FROM mods WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "team_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + } + }, + "bee1abe8313d17a56d93b06a31240e338c3973bc7a7374799ced3df5e38d3134": { + "query": "\n DELETE FROM game_versions_versions gvv\n WHERE gvv.joining_version_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + } + }, + "bee1e8b7f3588c6b0534443775f3d0d66d960e96a5ae8422aa96a69238f375a4": { + "query": "\n INSERT INTO game_versions (version)\n VALUES ($1)\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Varchar" + ] + }, + "nullable": [ + false + ] + } + }, "c0899dcff4d7bc1ba3e953e5099210316bff2f98e6ab77ba84bc612eac4bce0a": { "query": "\n SELECT gv.version FROM versions\n INNER JOIN game_versions_versions gvv ON gvv.joining_version_id=versions.id\n INNER JOIN game_versions gv ON gvv.game_version_id=gv.id\n WHERE versions.mod_id = $1\n ", "describe": { @@ -314,6 +770,46 @@ ] } }, + "c1fddbf97350871b79cb0c235b1f7488c6616b7c1dfbde76a712fd57e91ba158": { + "query": "\n SELECT id FROM game_versions\n WHERE version = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + } + }, + "c64c487b56a25b252ff070fe03a7416e84260df8a6f938a018cc768598e9435b": { + "query": "\n SELECT category FROM categories\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "category", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false + ] + } + }, "c82eb1b059b62444ab1d17e5a0bd7ef8acea4b05c6f3576c07d20c4ca7635a11": { "query": "\n INSERT INTO dependencies (dependent_id, dependency_id)\n VALUES ($1, $2)\n ", "describe": { @@ -327,6 +823,26 @@ "nullable": [] } }, + "c9d63ed46799db7c30a7e917d97a5d4b2b78b0234cce49e136fa57526b38c1ca": { + "query": "\n SELECT EXISTS(SELECT 1 FROM versions WHERE id = $1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + } + }, "cb57ae673f1a7e50cc319efddb9bdc82e2251596bcf85aea52e8def343e423b8": { "query": "\n INSERT INTO hashes (file_id, algorithm, hash)\n VALUES ($1, $2, $3)\n ", "describe": { @@ -341,6 +857,24 @@ "nullable": [] } }, + "cc8b672c2733bfd110ed3361c6f477b185b530228c7206cb641dbaa40e41ea9f": { + "query": "\n SELECT loader FROM loaders\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "loader", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + } + }, "ccd913bb2f3006ffe881ce2fc4ef1e721d18fe2eed6ac62627046c955129610c": { "query": "SELECT EXISTS(SELECT 1 FROM files WHERE id=$1)", "describe": { @@ -361,6 +895,38 @@ ] } }, + "d12bc07adb4dc8147d0ddccd72a4f23ed38cd31d7db3d36ebbe2c9b627130f0b": { + "query": "\n DELETE FROM team_members\n WHERE team_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + } + }, + "d1866ecc161c3fe3fbe094289510e99b17de563957e1f824c347c1e6ac40c40c": { + "query": "\n SELECT loader FROM loaders\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "loader", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false + ] + } + }, "d6453e50041b5521fa9e919a9162e533bb9426f8c584d98474c6ad414db715c8": { "query": "SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1)", "describe": { @@ -381,24 +947,68 @@ ] } }, - "e0e1671ae27b7ade3e9fa340e9f98b5388f51412fe892f904f31deb40634a0e0": { - "query": "SELECT EXISTS(SELECT 1 FROM versions WHERE version_number=$1)", + "d8b4e7e382c77a05395124d5a6a27cccb687d0e2c31b76d49b03aa364d099d42": { + "query": "\n DELETE FROM files\n WHERE files.version_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + } + }, + "deb81673526789bca38d39e64303f61d2a63febfdfb68136e58517af9f7792bc": { + "query": "\n SELECT category FROM mods_categories\n INNER JOIN categories ON joining_category_id = id\n WHERE joining_mod_id = $1\n ", "describe": { "columns": [ { "ordinal": 0, - "name": "exists", - "type_info": "Bool" + "name": "category", + "type_info": "Varchar" } ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + } + }, + "e35fa345b43725309b976efffbc8f9e20a62a5e90a86a82a77b55c39c168d2de": { + "query": "\n SELECT id FROM versions\n WHERE mod_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + } + }, + "e673006d1355fa91ba5739d7cf569eec5e1ec501f7b1dc2b431f0b1c25ac07d5": { + "query": "\n DELETE FROM game_versions\n WHERE version = $1\n ", + "describe": { + "columns": [], "parameters": { "Left": [ "Text" ] }, - "nullable": [ - null - ] + "nullable": [] } }, "e7d0a64a08df6783c942f2fcadd94dd45f8d96ad3d3736e52ce90f68d396cdab": { @@ -460,6 +1070,26 @@ "nullable": [] } }, + "ebf2d1fbcd12816799b60be6e8dec606eadd96edc26a840a411b44a19dc0497c": { + "query": "\n SELECT loaders.loader FROM versions\n INNER JOIN loaders_versions lv ON lv.version_id = versions.id\n INNER JOIN loaders ON loaders.id = lv.loader_id\n WHERE versions.mod_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "loader", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + } + }, "efe1bc80203f608226fa33e44654b681cc4430cec63bf7cf09b5281ff8c1c437": { "query": "\n SELECT m.id, m.title, m.description, m.downloads, m.icon_url, m.body_url, m.published FROM mods m\n ", "describe": { @@ -514,6 +1144,66 @@ ] } }, + "f0db9d8606ccc2196a9cfafe0e7090dab42bf790f25e0469b8947fac1cf043d5": { + "query": "\n SELECT version FROM game_versions\n WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false + ] + } + }, + "f0dd4e10e7c5c4c27ee84be6010919a1b23cb9438ff869c1902849874c75a4af": { + "query": "\n SELECT loaders.loader FROM loaders\n INNER JOIN loaders_versions ON loaders.id = loaders_versions.loader_id\n WHERE loaders_versions.version_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "loader", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + } + }, + "f80ca292323952d10dbd26d3453ced5c12bdd1b71dcd3cb3ade4c7d4dc3590f6": { + "query": "\n SELECT gv.version FROM game_versions_versions gvv\n INNER JOIN game_versions gv ON gvv.game_version_id=gv.id\n WHERE gvv.joining_version_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + } + }, "fb6178b27856ff583039a974173efe5d6be4e347b6cc1d4904cf750a40d1b77f": { "query": "\n SELECT dependency_id id FROM dependencies\n WHERE dependent_id = $1\n ", "describe": { diff --git a/src/database/models/categories.rs b/src/database/models/categories.rs new file mode 100644 index 00000000..2f11738a --- /dev/null +++ b/src/database/models/categories.rs @@ -0,0 +1,396 @@ +use super::ids::*; +use super::DatabaseError; +use futures::TryStreamExt; + +pub struct Loader { + pub id: LoaderId, + pub loader: String, +} + +pub struct GameVersion { + pub id: GameVersionId, + pub version: String, +} + +pub struct Category { + pub id: CategoryId, + pub category: String, +} + +pub struct CategoryBuilder<'a> { + pub name: Option<&'a str>, +} + +impl Category { + pub fn builder() -> CategoryBuilder<'static> { + CategoryBuilder { name: None } + } + + pub async fn get_id<'a, E>(name: &str, exec: E) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + if !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err(DatabaseError::InvalidIdentifier(name.to_string())); + } + + let result = sqlx::query!( + " + SELECT id FROM categories + WHERE category = $1 + ", + name + ) + .fetch_optional(exec) + .await?; + + Ok(result.map(|r| CategoryId(r.id))) + } + + pub async fn get_name<'a, E>(id: CategoryId, exec: E) -> Result + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT category FROM categories + WHERE id = $1 + ", + id as CategoryId + ) + .fetch_one(exec) + .await?; + + Ok(result.category) + } + + pub async fn list<'a, E>(exec: E) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT category FROM categories + " + ) + .fetch_many(exec) + .try_filter_map(|e| async { Ok(e.right().map(|c| c.category)) }) + .try_collect::>() + .await?; + + Ok(result) + } + + pub async fn remove<'a, E>(name: &str, exec: E) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + use sqlx::Done; + + let result = sqlx::query!( + " + DELETE FROM categories + WHERE category = $1 + ", + name + ) + .execute(exec) + .await?; + + if result.rows_affected() == 0 { + // Nothing was deleted + Ok(None) + } else { + Ok(Some(())) + } + } +} + +impl<'a> CategoryBuilder<'a> { + /// The name of the category. Must be ASCII alphanumeric or `-`/`_` + pub fn name(mut self, name: &'a str) -> Result, DatabaseError> { + if name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + Ok(Self { name: Some(name) }) + } else { + Err(DatabaseError::InvalidIdentifier(name.to_string())) + } + } + + pub async fn insert<'b, E>(self, exec: E) -> Result + where + E: sqlx::Executor<'b, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + INSERT INTO categories (category) + VALUES ($1) + RETURNING id + ", + self.name + ) + .fetch_one(exec) + .await?; + + Ok(CategoryId(result.id)) + } +} + +pub struct LoaderBuilder<'a> { + pub name: Option<&'a str>, +} + +impl Loader { + pub fn builder() -> LoaderBuilder<'static> { + LoaderBuilder { name: None } + } + + pub async fn get_id<'a, E>(name: &str, exec: E) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + if !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + return Err(DatabaseError::InvalidIdentifier(name.to_string())); + } + + let result = sqlx::query!( + " + SELECT id FROM loaders + WHERE loader = $1 + ", + name + ) + .fetch_optional(exec) + .await?; + + Ok(result.map(|r| LoaderId(r.id))) + } + + pub async fn get_name<'a, E>(id: LoaderId, exec: E) -> Result + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT loader FROM loaders + WHERE id = $1 + ", + id as LoaderId + ) + .fetch_one(exec) + .await?; + + Ok(result.loader) + } + + pub async fn list<'a, E>(exec: E) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT loader FROM loaders + " + ) + .fetch_many(exec) + .try_filter_map(|e| async { Ok(e.right().map(|c| c.loader)) }) + .try_collect::>() + .await?; + + Ok(result) + } + + // TODO: remove loaders with mods using them + pub async fn remove<'a, E>(name: &str, exec: E) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + use sqlx::Done; + + let result = sqlx::query!( + " + DELETE FROM loaders + WHERE loader = $1 + ", + name + ) + .execute(exec) + .await?; + + if result.rows_affected() == 0 { + // Nothing was deleted + Ok(None) + } else { + Ok(Some(())) + } + } +} + +impl<'a> LoaderBuilder<'a> { + /// The name of the loader. Must be ASCII alphanumeric or `-`/`_` + pub fn name(mut self, name: &'a str) -> Result, DatabaseError> { + if name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_') + { + Ok(Self { name: Some(name) }) + } else { + Err(DatabaseError::InvalidIdentifier(name.to_string())) + } + } + + pub async fn insert<'b, E>(self, exec: E) -> Result + where + E: sqlx::Executor<'b, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + INSERT INTO loaders (loader) + VALUES ($1) + RETURNING id + ", + self.name + ) + .fetch_one(exec) + .await?; + + Ok(LoaderId(result.id)) + } +} + +pub struct GameVersionBuilder<'a> { + pub version: Option<&'a str>, +} + +impl GameVersion { + pub fn builder() -> GameVersionBuilder<'static> { + GameVersionBuilder { version: None } + } + + pub async fn get_id<'a, E>( + version: &str, + exec: E, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + if !version + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c)) + { + return Err(DatabaseError::InvalidIdentifier(version.to_string())); + } + + let result = sqlx::query!( + " + SELECT id FROM game_versions + WHERE version = $1 + ", + version + ) + .fetch_optional(exec) + .await?; + + Ok(result.map(|r| GameVersionId(r.id))) + } + + pub async fn get_name<'a, E>(id: VersionId, exec: E) -> Result + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT version FROM game_versions + WHERE id = $1 + ", + id as VersionId + ) + .fetch_one(exec) + .await?; + + Ok(result.version) + } + + pub async fn list<'a, E>(exec: E) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT version FROM game_versions + " + ) + .fetch_many(exec) + .try_filter_map(|e| async { Ok(e.right().map(|c| c.version)) }) + .try_collect::>() + .await?; + + Ok(result) + } + + pub async fn remove<'a, E>(name: &str, exec: E) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + use sqlx::Done; + + let result = sqlx::query!( + " + DELETE FROM game_versions + WHERE version = $1 + ", + name + ) + .execute(exec) + .await?; + + if result.rows_affected() == 0 { + // Nothing was deleted + Ok(None) + } else { + Ok(Some(())) + } + } +} + +impl<'a> GameVersionBuilder<'a> { + /// The game version. Spaces must be replaced with '_' for it to be valid + pub fn version(mut self, version: &'a str) -> Result, DatabaseError> { + if version + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c)) + { + Ok(Self { + version: Some(version), + }) + } else { + Err(DatabaseError::InvalidIdentifier(version.to_string())) + } + } + + pub async fn insert<'b, E>(self, exec: E) -> Result + where + E: sqlx::Executor<'b, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + INSERT INTO game_versions (version) + VALUES ($1) + RETURNING id + ", + self.version + ) + .fetch_one(exec) + .await?; + + Ok(GameVersionId(result.id)) + } +} diff --git a/src/database/models/ids.rs b/src/database/models/ids.rs index 93e8c500..e2a3bacc 100644 --- a/src/database/models/ids.rs +++ b/src/database/models/ids.rs @@ -1,5 +1,5 @@ use super::DatabaseError; -use crate::models::ids::random_base62; +use crate::models::ids::random_base62_rng; use sqlx_macros::Type; const ID_RETRY_COUNT: usize = 20; @@ -9,8 +9,10 @@ macro_rules! generate_ids { $vis async fn $function_name( con: &mut sqlx::Transaction<'_, sqlx::Postgres>, ) -> Result<$return_type, DatabaseError> { + use rand::Rng; + let mut rng = rand::thread_rng(); let length = $id_length; - let mut id = random_base62(length); + let mut id = random_base62_rng(&mut rng, length); let mut retry_count = 0; // Check if ID is unique @@ -20,7 +22,7 @@ macro_rules! generate_ids { .await?; if results.exists.unwrap_or(true) { - id = random_base62(length); + id = random_base62_rng(&mut rng, length); } else { break; } diff --git a/src/database/models/mod.rs b/src/database/models/mod.rs index 758b7e82..67e8e618 100644 --- a/src/database/models/mod.rs +++ b/src/database/models/mod.rs @@ -3,6 +3,7 @@ use thiserror::Error; +pub mod categories; pub mod ids; pub mod mod_item; pub mod team_item; @@ -22,4 +23,9 @@ pub enum DatabaseError { DatabaseError(#[from] sqlx::error::Error), #[error("Error while trying to generate random ID")] RandomIdError, + #[error( + "Invalid identifier: Category/version names must contain only ASCII \ + alphanumeric characters or '_-'." + )] + InvalidIdentifier(String), } diff --git a/src/database/models/mod_item.rs b/src/database/models/mod_item.rs index 3de0e41c..c5856a40 100644 --- a/src/database/models/mod_item.rs +++ b/src/database/models/mod_item.rs @@ -105,4 +105,175 @@ impl Mod { Ok(()) } + + pub async fn get<'a, 'b, E>(id: ModId, executor: E) -> Result, sqlx::error::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT title, description, downloads, + icon_url, body_url, published, + issues_url, source_url, wiki_url, + team_id + FROM mods + WHERE id = $1 + ", + id as ModId, + ) + .fetch_optional(executor) + .await?; + + if let Some(row) = result { + Ok(Some(Mod { + id, + team_id: TeamId(row.team_id), + title: row.title, + description: row.description, + downloads: row.downloads, + body_url: row.body_url, + icon_url: row.icon_url, + published: row.published, + issues_url: row.issues_url, + source_url: row.source_url, + wiki_url: row.wiki_url, + })) + } else { + Ok(None) + } + } + + pub async fn remove_full<'a, 'b, E>( + id: ModId, + exec: E, + ) -> Result, sqlx::error::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + let result = sqlx::query!( + " + SELECT team_id FROM mods WHERE id = $1 + ", + id as ModId, + ) + .fetch_optional(exec) + .await?; + + let team_id: TeamId = if let Some(id) = result { + TeamId(id.team_id) + } else { + return Ok(None); + }; + + sqlx::query!( + " + DELETE FROM mods_categories + WHERE joining_mod_id = $1 + ", + id as ModId, + ) + .execute(exec) + .await?; + + use futures::TryStreamExt; + let versions: Vec = sqlx::query!( + " + SELECT id FROM versions + WHERE mod_id = $1 + ", + id as ModId, + ) + .fetch_many(exec) + .try_filter_map(|e| async { Ok(e.right().map(|c| VersionId(c.id))) }) + .try_collect::>() + .await?; + + for version in versions { + super::Version::remove_full(version, exec).await?; + } + + sqlx::query!( + " + DELETE FROM mods + WHERE id = $1 + ", + id as ModId, + ) + .execute(exec) + .await?; + + sqlx::query!( + " + DELETE FROM team_members + WHERE team_id = $1 + ", + team_id as TeamId, + ) + .execute(exec) + .await?; + + sqlx::query!( + " + DELETE FROM teams + WHERE id = $1 + ", + team_id as TeamId, + ) + .execute(exec) + .await?; + + Ok(Some(())) + } + + pub async fn get_full<'a, 'b, E>( + id: ModId, + executor: E, + ) -> Result, sqlx::error::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + let result = Self::get(id, executor).await?; + if let Some(inner) = result { + use futures::TryStreamExt; + let categories: Vec = sqlx::query!( + " + SELECT category FROM mods_categories + INNER JOIN categories ON joining_category_id = id + WHERE joining_mod_id = $1 + ", + id as ModId, + ) + .fetch_many(executor) + .try_filter_map(|e| async { Ok(e.right().map(|c| c.category)) }) + .try_collect::>() + .await?; + + let versions: Vec = sqlx::query!( + " + SELECT id FROM versions + WHERE mod_id = $1 + ", + id as ModId, + ) + .fetch_many(executor) + .try_filter_map(|e| async { Ok(e.right().map(|c| VersionId(c.id))) }) + .try_collect::>() + .await?; + + Ok(Some(QueryMod { + inner, + categories, + versions, + })) + } else { + Ok(None) + } + } +} + +pub struct QueryMod { + pub inner: Mod, + + pub categories: Vec, + pub versions: Vec, } diff --git a/src/database/models/version_item.rs b/src/database/models/version_item.rs index 037db9ae..770a96b2 100644 --- a/src/database/models/version_item.rs +++ b/src/database/models/version_item.rs @@ -1,3 +1,4 @@ +use super::categories::{GameVersion, Loader}; use super::ids::*; use super::DatabaseError; @@ -173,7 +174,118 @@ impl Version { Ok(()) } - pub async fn get_dependencies<'a, E>(&self, exec: E) -> Result, sqlx::Error> + // TODO: someone verify this + pub async fn remove_full<'a, E>(id: VersionId, exec: E) -> Result, sqlx::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + use sqlx::Done; + + let result = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM versions WHERE id = $1) + ", + id as VersionId, + ) + .fetch_one(exec) + .await?; + + if !result.exists.unwrap_or(false) { + return Ok(None); + } + + sqlx::query!( + " + DELETE FROM game_versions_versions gvv + WHERE gvv.joining_version_id = $1 + ", + id as VersionId, + ) + .execute(exec) + .await?; + + sqlx::query!( + " + DELETE FROM loaders_versions + WHERE loaders_versions.version_id = $1 + ", + id as VersionId, + ) + .execute(exec) + .await?; + + use futures::TryStreamExt; + + let mut files = sqlx::query!( + " + SELECT files.id, files.url, files.filename FROM files + WHERE files.version_id = $1 + ", + id as VersionId, + ) + .fetch_many(exec) + .try_filter_map(|e| async { + Ok(e.right().map(|c| VersionFile { + id: FileId(c.id), + version_id: id, + url: c.url, + filename: c.filename, + })) + }) + .try_collect::>() + .await?; + + for file in files { + // TODO: store backblaze id in database so that we can delete the files here + // For now, we can't delete the files since we don't have the backblaze id + log::warn!( + "Can't delete version file id: {} (url: {}, name: {})", + file.id.0, + file.url, + file.filename + ) + } + + sqlx::query!( + " + DELETE FROM hashes + WHERE EXISTS( + SELECT 1 FROM files WHERE + (files.version_id = $1) AND + (hashes.file_id = files.id) + ) + ", + id as VersionId + ) + .execute(exec) + .await?; + + sqlx::query!( + " + DELETE FROM files + WHERE files.version_id = $1 + ", + id as VersionId, + ) + .execute(exec) + .await?; + + sqlx::query!( + " + DELETE FROM versions WHERE id = $1 + ", + id as VersionId, + ) + .execute(exec) + .await?; + + Ok(Some(())) + } + + pub async fn get_dependencies<'a, E>( + id: VersionId, + exec: E, + ) -> Result, sqlx::Error> where E: sqlx::Executor<'a, Database = sqlx::Postgres>, { @@ -184,7 +296,7 @@ impl Version { SELECT dependency_id id FROM dependencies WHERE dependent_id = $1 ", - self.id as VersionId, + id as VersionId, ) .fetch_many(exec) .try_filter_map(|e| async { Ok(e.right().map(|v| VersionId(v.id))) }) @@ -193,20 +305,177 @@ impl Version { Ok(vec) } + + pub async fn get_mod_versions<'a, E>( + mod_id: ModId, + exec: E, + ) -> Result, sqlx::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + use futures::stream::TryStreamExt; + + let vec = sqlx::query!( + " + SELECT id FROM versions + WHERE mod_id = $1 + ", + mod_id as ModId, + ) + .fetch_many(exec) + .try_filter_map(|e| async { Ok(e.right().map(|v| VersionId(v.id))) }) + .try_collect::>() + .await?; + + Ok(vec) + } + + pub async fn get<'a, 'b, E>( + id: VersionId, + executor: E, + ) -> Result, sqlx::error::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT v.mod_id, v.name, v.version_number, + v.changelog_url, v.date_published, v.downloads, + v.release_channel + FROM versions v + WHERE v.id = $1 + ", + id as VersionId, + ) + .fetch_optional(executor) + .await?; + + if let Some(row) = result { + Ok(Some(Version { + id, + mod_id: ModId(row.mod_id), + name: row.name, + version_number: row.version_number, + changelog_url: row.changelog_url, + date_published: row.date_published, + downloads: row.downloads, + release_channel: ChannelId(row.release_channel), + })) + } else { + Ok(None) + } + } + + pub async fn get_full<'a, 'b, E>( + id: VersionId, + executor: E, + ) -> Result, sqlx::error::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + let result = sqlx::query!( + " + SELECT v.mod_id, v.name, v.version_number, + v.changelog_url, v.date_published, v.downloads, + release_channels.channel + FROM versions v + INNER JOIN release_channels ON v.release_channel = release_channels.id + WHERE v.id = $1 + ", + id as VersionId, + ) + .fetch_optional(executor) + .await?; + + if let Some(row) = result { + use futures::TryStreamExt; + use sqlx::Row; + + let game_versions: Vec = sqlx::query!( + " + SELECT gv.version FROM game_versions_versions gvv + INNER JOIN game_versions gv ON gvv.game_version_id=gv.id + WHERE gvv.joining_version_id = $1 + ", + id as VersionId, + ) + .fetch_many(executor) + .try_filter_map(|e| async { Ok(e.right().map(|c| c.version)) }) + .try_collect::>() + .await?; + + let loaders: Vec = sqlx::query!( + " + SELECT loaders.loader FROM loaders + INNER JOIN loaders_versions ON loaders.id = loaders_versions.loader_id + WHERE loaders_versions.version_id = $1 + ", + id as VersionId, + ) + .fetch_many(executor) + .try_filter_map(|e| async { Ok(e.right().map(|c| c.loader)) }) + .try_collect::>() + .await?; + + let mut files = sqlx::query!( + " + SELECT files.id, files.url, files.filename FROM files + WHERE files.version_id = $1 + ", + id as VersionId, + ) + .fetch_many(executor) + .try_filter_map(|e| async { + Ok(e.right().map(|c| QueryFile { + id: FileId(c.id), + url: c.url, + filename: c.filename, + hashes: std::collections::HashMap::new(), + })) + }) + .try_collect::>() + .await?; + + for file in files.iter_mut() { + let mut files = sqlx::query!( + " + SELECT hashes.algorithm, hashes.hash FROM hashes + WHERE hashes.file_id = $1 + ", + file.id as FileId + ) + .fetch_many(executor) + .try_filter_map(|e| async { Ok(e.right().map(|c| (c.algorithm, c.hash))) }) + .try_collect::)>>() + .await?; + + file.hashes.extend(files); + } + + Ok(Some(QueryVersion { + id, + mod_id: ModId(row.mod_id), + name: row.name, + version_number: row.version_number, + changelog_url: row.changelog_url, + date_published: row.date_published, + downloads: row.downloads, + + release_channel: row.channel, + files: Vec::::new(), + loaders, + game_versions, + })) + } else { + Ok(None) + } + } } pub struct ReleaseChannel { pub id: ChannelId, pub channel: String, } -pub struct Loader { - pub id: LoaderId, - pub loader: String, -} -pub struct GameVersion { - pub id: GameVersionId, - pub version: String, -} pub struct VersionFile { pub id: FileId, @@ -221,7 +490,24 @@ pub struct FileHash { pub hash: Vec, } -pub struct Category { - pub id: CategoryId, - pub category: String, +pub struct QueryVersion { + pub id: VersionId, + pub mod_id: ModId, + pub name: String, + pub version_number: String, + pub changelog_url: Option, + pub date_published: chrono::DateTime, + pub downloads: i32, + + pub release_channel: String, + pub files: Vec, + pub game_versions: Vec, + pub loaders: Vec, +} + +pub struct QueryFile { + pub id: FileId, + pub url: String, + pub filename: String, + pub hashes: std::collections::HashMap>, } diff --git a/src/main.rs b/src/main.rs index e490a7d0..7e53d5db 100644 --- a/src/main.rs +++ b/src/main.rs @@ -176,10 +176,11 @@ async fn main() -> std::io::Result<()> { .data(file_host.clone()) .data(indexing_queue.clone()) .service(routes::index_get) - .service(routes::mod_search) - .service(routes::mod_create) - .service(routes::version_create) - .service(routes::upload_file_to_version) + .service( + web::scope("/api/v1/") + .configure(routes::tags_config) + .configure(routes::mods_config), + ) .default_service(web::get().to(routes::not_found)) }) .bind(dotenv::var("BIND_ADDR").unwrap())? diff --git a/src/models/ids.rs b/src/models/ids.rs index b80a2ec0..f40585d6 100644 --- a/src/models/ids.rs +++ b/src/models/ids.rs @@ -171,7 +171,7 @@ pub mod base62_impl { fn parse_base62(string: &str) -> Result { let mut num: u64 = 0; - for c in string.chars().rev() { + for c in string.chars() { let next_digit; if c.is_ascii_digit() { next_digit = (c as u8 - b'0') as u64; diff --git a/src/models/mods.rs b/src/models/mods.rs index 9acb375e..96dd5bcc 100644 --- a/src/models/mods.rs +++ b/src/models/mods.rs @@ -81,20 +81,13 @@ pub struct Version { /// A single mod file, with a url for the file and the file's hash #[derive(Serialize, Deserialize)] pub struct VersionFile { - /// A list of hashes of the file - pub hashes: Vec, + /// A map of hashes of the file. The key is the hashing algorithm + /// and the value is the string version of the hash. + pub hashes: std::collections::HashMap, /// A direct link to the file for downloading it. pub url: String, -} - -/// A hash of a mod's file -#[derive(Serialize, Deserialize)] -pub struct FileHash { - // TODO: decide specific algorithms - /// The hashing algorithm used for this hash; could be "md5", "sha1", etc - pub algorithm: String, - /// The file hash, using the specified algorithm - pub hash: String, + /// A direct link to the file for downloading it. + pub filename: String, } #[derive(Serialize, Deserialize, Clone)] diff --git a/src/routes/mod.rs b/src/routes/mod.rs index 8b332cba..b1a71093 100644 --- a/src/routes/mod.rs +++ b/src/routes/mod.rs @@ -1,12 +1,64 @@ +use actix_web::web; + mod index; mod mod_creation; mod mods; mod not_found; +mod tags; mod version_creation; +mod versions; + +pub use tags::config as tags_config; pub use self::index::index_get; -pub use self::mod_creation::mod_create; -pub use self::mods::mod_search; pub use self::not_found::not_found; -pub use self::version_creation::upload_file_to_version; -pub use self::version_creation::version_create; + +pub fn mods_config(cfg: &mut web::ServiceConfig) { + cfg.service(mods::mod_search); + cfg.service(mod_creation::mod_create); + + cfg.service( + web::scope("mod") + .service(mods::mod_get) + .service(mods::mod_delete) + .service(web::scope("{mod_id}").configure(versions_config)), + ); +} + +pub fn versions_config(cfg: &mut web::ServiceConfig) { + cfg.service(versions::version_list) + .service(version_creation::version_create) + .service( + web::scope("version") + .service(versions::version_get) + .service(versions::version_delete) + .service( + web::scope("{version_id}").service(version_creation::upload_file_to_version), + ), + ); +} + +#[derive(thiserror::Error, Debug)] +pub enum ApiError { + #[error("Internal server error")] + DatabaseError(#[from] crate::database::models::DatabaseError), +} + +impl actix_web::ResponseError for ApiError { + fn status_code(&self) -> actix_web::http::StatusCode { + match self { + ApiError::DatabaseError(..) => actix_web::http::StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> actix_web::web::HttpResponse { + actix_web::web::HttpResponse::build(self.status_code()).json( + crate::models::error::ApiError { + error: match self { + ApiError::DatabaseError(..) => "database_error", + }, + description: &self.to_string(), + }, + ) + } +} diff --git a/src/routes/mod_creation.rs b/src/routes/mod_creation.rs index 176ccccf..0ffa4362 100644 --- a/src/routes/mod_creation.rs +++ b/src/routes/mod_creation.rs @@ -36,6 +36,12 @@ pub enum CreateError { InvalidIconFormat(String), #[error("Error with multipart data: {0}")] InvalidInput(String), + #[error("Invalid game version: {0}")] + InvalidGameVersion(String), + #[error("Invalid loader: {0}")] + InvalidLoader(String), + #[error("Invalid category: {0}")] + InvalidCategory(String), } impl actix_web::ResponseError for CreateError { @@ -50,6 +56,9 @@ impl actix_web::ResponseError for CreateError { CreateError::MissingValueError(..) => StatusCode::BAD_REQUEST, CreateError::InvalidIconFormat(..) => StatusCode::BAD_REQUEST, CreateError::InvalidInput(..) => StatusCode::BAD_REQUEST, + CreateError::InvalidGameVersion(..) => StatusCode::BAD_REQUEST, + CreateError::InvalidLoader(..) => StatusCode::BAD_REQUEST, + CreateError::InvalidCategory(..) => StatusCode::BAD_REQUEST, } } @@ -65,6 +74,9 @@ impl actix_web::ResponseError for CreateError { CreateError::MissingValueError(..) => "invalid_input", CreateError::InvalidIconFormat(..) => "invalid_input", CreateError::InvalidInput(..) => "invalid_input", + CreateError::InvalidGameVersion(..) => "invalid_input", + CreateError::InvalidLoader(..) => "invalid_input", + CreateError::InvalidCategory(..) => "invalid_input", }, description: &self.to_string(), }) @@ -112,7 +124,7 @@ pub async fn undo_uploads( Ok(()) } -#[post("api/v1/mod")] +#[post("mod")] pub async fn mod_create( payload: Multipart, client: Data, @@ -256,6 +268,22 @@ async fn mod_create_inner( VersionType::Alpha => models::ChannelId(5), }; + let mut game_versions = Vec::with_capacity(version_data.game_versions.len()); + for v in &version_data.game_versions { + let id = models::categories::GameVersion::get_id(&v.0, &mut *transaction) + .await? + .ok_or_else(|| CreateError::InvalidGameVersion(v.0.clone()))?; + game_versions.push(id); + } + + let mut loaders = Vec::with_capacity(version_data.loaders.len()); + for l in &version_data.loaders { + let id = models::categories::Loader::get_id(&l.0, &mut *transaction) + .await? + .ok_or_else(|| CreateError::InvalidLoader(l.0.clone()))?; + loaders.push(id); + } + let version = models::version_item::VersionBuilder { version_id: version_id.into(), mod_id: mod_id.into(), @@ -268,9 +296,8 @@ async fn mod_create_inner( .iter() .map(|x| (*x).into()) .collect::>(), - // TODO: add game_versions and loaders info - game_versions: vec![], - loaders: vec![], + game_versions, + loaders, release_channel, }; @@ -329,6 +356,14 @@ async fn mod_create_inner( ))); }; + let mut categories = Vec::with_capacity(create_data.categories.len()); + for category in &create_data.categories { + let id = models::categories::Category::get_id(&category, &mut *transaction) + .await? + .ok_or_else(|| CreateError::InvalidCategory(category.clone()))?; + categories.push(id); + } + let body_url = format!("data/{}/body.md", mod_id); let upload_data = file_host @@ -367,8 +402,7 @@ async fn mod_create_inner( source_url: create_data.source_url, wiki_url: create_data.wiki_url, - // TODO: convert `create_data.categories` from Vec to Vec - categories: Vec::new(), + categories, initial_versions: created_versions, }; diff --git a/src/routes/mods.rs b/src/routes/mods.rs index ad50a9f4..346bfdca 100644 --- a/src/routes/mods.rs +++ b/src/routes/mods.rs @@ -1,11 +1,68 @@ +use super::ApiError; +use crate::database; +use crate::models; use crate::models::mods::SearchRequest; use crate::search::{search_for_mod, SearchError}; -use actix_web::{get, web, HttpResponse}; +use actix_web::{delete, get, web, HttpResponse}; +use sqlx::PgPool; -#[get("api/v1/mod")] +#[get("mod")] pub async fn mod_search( web::Query(info): web::Query, ) -> Result { let results = search_for_mod(&info).await?; Ok(HttpResponse::Ok().json(results)) } + +#[get("{id}")] +pub async fn mod_get( + info: web::Path<(models::ids::ModId,)>, + pool: web::Data, +) -> Result { + let id = info.0; + let mod_data = database::models::Mod::get_full(id.into(), &**pool) + .await + .map_err(|e| ApiError::DatabaseError(e.into()))?; + + if let Some(data) = mod_data { + let m = data.inner; + let response = models::mods::Mod { + id: m.id.into(), + team: m.team_id.into(), + title: m.title, + description: m.description, + body_url: m.body_url, + published: m.published, + + downloads: m.downloads as u32, + categories: data.categories, + versions: data.versions.into_iter().map(|v| v.into()).collect(), + icon_url: m.icon_url, + issues_url: m.issues_url, + source_url: m.source_url, + wiki_url: m.wiki_url, + }; + Ok(HttpResponse::Ok().json(response)) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +// TODO: This really needs auth +// TODO: The mod remains in meilisearch's index until the index is deleted +#[delete("{id}")] +pub async fn mod_delete( + info: web::Path<(models::ids::ModId,)>, + pool: web::Data, +) -> Result { + let id = info.0; + let result = database::models::Mod::remove_full(id.into(), &**pool) + .await + .map_err(|e| ApiError::DatabaseError(e.into()))?; + + if result.is_some() { + Ok(HttpResponse::Ok().body("")) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} diff --git a/src/routes/tags.rs b/src/routes/tags.rs new file mode 100644 index 00000000..06e2d082 --- /dev/null +++ b/src/routes/tags.rs @@ -0,0 +1,153 @@ +use super::ApiError; +use crate::database::models; +use actix_web::{delete, get, put, web, HttpResponse}; +use models::categories::{Category, GameVersion, Loader}; +use sqlx::PgPool; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("/tag/") + .service(category_list) + .service(category_create) + .service(category_delete) + .service(loader_list) + .service(loader_create) + .service(loader_delete) + .service(game_version_list) + .service(game_version_create) + .service(game_version_delete), + ); +} + +// TODO: searching / filtering? Could be used to implement a live +// searching category list +#[get("category")] +pub async fn category_list(pool: web::Data) -> Result { + let results = Category::list(&**pool).await?; + Ok(HttpResponse::Ok().json(results)) +} + +// At some point this may take more info, but it should be able to +// remain idempotent +// TODO: don't fail if category already exists +#[put("category/{name}")] +pub async fn category_create( + pool: web::Data, + category: web::Path<(String,)>, +) -> Result { + let name = category.into_inner().0; + + let _id = Category::builder().name(&name)?.insert(&**pool).await?; + + Ok(HttpResponse::Ok().body("")) +} + +#[delete("category/{name}")] +pub async fn category_delete( + pool: web::Data, + category: web::Path<(String,)>, +) -> Result { + let name = category.into_inner().0; + let mut transaction = pool.begin().await.map_err(models::DatabaseError::from)?; + + let result = Category::remove(&name, &mut transaction).await?; + + transaction + .commit() + .await + .map_err(models::DatabaseError::from)?; + + if result.is_some() { + Ok(HttpResponse::Ok().body("")) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +#[get("loader")] +pub async fn loader_list(pool: web::Data) -> Result { + let results = Loader::list(&**pool).await?; + Ok(HttpResponse::Ok().json(results)) +} + +// At some point this may take more info, but it should be able to +// remain idempotent +// TODO: don't fail if loader already exists +#[put("loader/{name}")] +pub async fn loader_create( + pool: web::Data, + loader: web::Path<(String,)>, +) -> Result { + let name = loader.into_inner().0; + + let _id = Loader::builder().name(&name)?.insert(&**pool).await?; + + Ok(HttpResponse::Ok().body("")) +} + +#[delete("loader/{name}")] +pub async fn loader_delete( + pool: web::Data, + loader: web::Path<(String,)>, +) -> Result { + let name = loader.into_inner().0; + let mut transaction = pool.begin().await.map_err(models::DatabaseError::from)?; + + let result = Loader::remove(&name, &mut transaction).await?; + + transaction + .commit() + .await + .map_err(models::DatabaseError::from)?; + + if result.is_some() { + Ok(HttpResponse::Ok().body("")) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +#[get("game_version")] +pub async fn game_version_list(pool: web::Data) -> Result { + let results = GameVersion::list(&**pool).await?; + Ok(HttpResponse::Ok().json(results)) +} + +// At some point this may take more info, but it should be able to +// remain idempotent +#[put("game_version/{name}")] +pub async fn game_version_create( + pool: web::Data, + game_version: web::Path<(String,)>, +) -> Result { + let name = game_version.into_inner().0; + + let _id = GameVersion::builder() + .version(&name)? + .insert(&**pool) + .await?; + + Ok(HttpResponse::Ok().body("")) +} + +#[delete("game_version/{name}")] +pub async fn game_version_delete( + pool: web::Data, + game_version: web::Path<(String,)>, +) -> Result { + let name = game_version.into_inner().0; + let mut transaction = pool.begin().await.map_err(models::DatabaseError::from)?; + + let result = GameVersion::remove(&name, &mut transaction).await?; + + transaction + .commit() + .await + .map_err(models::DatabaseError::from)?; + + if result.is_some() { + Ok(HttpResponse::Ok().body("")) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} diff --git a/src/routes/version_creation.rs b/src/routes/version_creation.rs index a3a20324..38470eed 100644 --- a/src/routes/version_creation.rs +++ b/src/routes/version_creation.rs @@ -14,7 +14,6 @@ use sqlx::postgres::PgPool; #[derive(Serialize, Deserialize, Clone)] pub struct InitialVersionData { - pub mod_id: ModId, pub file_parts: Vec, pub version_number: String, pub version_title: String, @@ -30,8 +29,10 @@ struct InitialFileData { // TODO: hashes? } -#[post("api/v1/version")] +// under `/api/v1/mod/{mod_id}` +#[post("version")] pub async fn version_create( + url_data: actix_web::web::Path<(ModId,)>, payload: Multipart, client: Data, file_host: Data>, @@ -39,11 +40,14 @@ pub async fn version_create( let mut transaction = client.begin().await?; let mut uploaded_files = Vec::new(); + let mod_id = url_data.into_inner().0.into(); + let result = version_create_inner( payload, &mut transaction, &***file_host, &mut uploaded_files, + mod_id, ) .await; @@ -69,6 +73,7 @@ async fn version_create_inner( transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, file_host: &dyn FileHost, uploaded_files: &mut Vec, + mod_id: models::ModId, ) -> Result { let cdn_url = dotenv::var("CDN_URL")?; @@ -94,12 +99,9 @@ async fn version_create_inner( initial_version_data = Some(version_create_data); let version_create_data = initial_version_data.as_ref().unwrap(); - // TODO: get mod_id from path (POST `/api/v1/mod/{mod_id}/version`) - let mod_id: ModId = version_create_data.mod_id; - let results = sqlx::query!( "SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1)", - models::ModId::from(mod_id) as models::ModId + mod_id as models::ModId ) .fetch_one(&mut *transaction) .await?; @@ -113,7 +115,7 @@ async fn version_create_inner( let results = sqlx::query!( "SELECT EXISTS(SELECT 1 FROM versions WHERE (version_number=$1) AND (mod_id=$2))", version_create_data.version_number, - models::ModId::from(mod_id) as models::ModId, + mod_id as models::ModId, ) .fetch_one(&mut *transaction) .await?; @@ -125,7 +127,11 @@ async fn version_create_inner( } let version_id: VersionId = models::generate_version_id(transaction).await?.into(); - let body_url = format!("data/{}/changelogs/{}/body.md", mod_id, version_id); + let body_url = format!( + "data/{}/changelogs/{}/body.md", + ModId::from(mod_id), + version_id + ); let uploaded_text = file_host .upload_file( @@ -149,7 +155,7 @@ async fn version_create_inner( version_builder = Some(VersionBuilder { version_id: version_id.into(), - mod_id: mod_id.into(), + mod_id, name: version_create_data.version_title.clone(), version_number: version_create_data.version_number.clone(), changelog_url: Some(format!("{}/{}", cdn_url, body_url)), @@ -246,16 +252,19 @@ async fn version_create_inner( hashes: file .hashes .iter() - .map(|hash| crate::models::mods::FileHash { - algorithm: hash.algorithm.clone(), - // This is a hack since the hashes are currently stored as ASCII - // in the database, but represented here as a Vec. At some - // point we need to change the hash to be the real bytes in the - // database and add more processing here. - hash: String::from_utf8(hash.hash.clone()).unwrap(), + .map(|hash| { + ( + hash.algorithm.clone(), + // This is a hack since the hashes are currently stored as ASCII + // in the database, but represented here as a Vec. At some + // point we need to change the hash to be the real bytes in the + // database and add more processing here. + String::from_utf8(hash.hash.clone()).unwrap(), + ) }) .collect(), url: file.url.clone(), + filename: file.filename.clone(), }) .collect::>(), dependencies: version_data_safe.dependencies, @@ -270,9 +279,10 @@ async fn version_create_inner( // TODO: file deletion, listing, etc -#[post("api/v1/version/{version_id}/file")] +// under /api/v1/mod/{mod_id}/version/{version_id} +#[post("file")] pub async fn upload_file_to_version( - url_data: actix_web::web::Path<(VersionId,)>, + url_data: actix_web::web::Path<(ModId, VersionId)>, payload: Multipart, client: Data, file_host: Data>, @@ -280,7 +290,9 @@ pub async fn upload_file_to_version( let mut transaction = client.begin().await?; let mut uploaded_files = Vec::new(); - let version_id = models::VersionId::from(url_data.into_inner().0); + let data = url_data.into_inner(); + let mod_id = models::ModId::from(data.0); + let version_id = models::VersionId::from(data.1); let result = upload_file_to_version_inner( payload, @@ -288,6 +300,7 @@ pub async fn upload_file_to_version( &***file_host, &mut uploaded_files, version_id, + mod_id, ) .await; @@ -314,6 +327,7 @@ async fn upload_file_to_version_inner( file_host: &dyn FileHost, uploaded_files: &mut Vec, version_id: models::VersionId, + mod_id: models::ModId, ) -> Result { let cdn_url = dotenv::var("CDN_URL")?; @@ -339,6 +353,12 @@ async fn upload_file_to_version_inner( )); } }; + if version.mod_id as u64 != mod_id.0 as u64 { + return Err(CreateError::InvalidInput( + "An invalid version id was supplied".to_string(), + )); + } + let mod_id = ModId(version.mod_id as u64); let version_number = version.version_number; diff --git a/src/routes/versions.rs b/src/routes/versions.rs new file mode 100644 index 00000000..a04d4a67 --- /dev/null +++ b/src/routes/versions.rs @@ -0,0 +1,131 @@ +use super::ApiError; +use crate::database; +use crate::models; +use actix_web::{delete, get, web, HttpResponse}; +use sqlx::PgPool; + +// TODO: this needs filtering, and a better response type +// Currently it only gives a list of ids, which have to be +// requested manually. This route could give a list of the +// ids as well as the supported versions and loaders, or +// other info that is needed for selecting the right version. +#[get("version")] +pub async fn version_list( + info: web::Path<(models::ids::ModId,)>, + pool: web::Data, +) -> Result { + let id = info.0.into(); + + let mod_exists = sqlx::query!( + "SELECT EXISTS(SELECT 1 FROM mods WHERE id = $1)", + id as database::models::ModId, + ) + .fetch_one(&**pool) + .await + .map_err(|e| ApiError::DatabaseError(e.into()))? + .exists; + + if mod_exists.unwrap_or(false) { + let mod_data = database::models::Version::get_mod_versions(id, &**pool) + .await + .map_err(|e| ApiError::DatabaseError(e.into()))?; + + let response = mod_data + .into_iter() + .map(|v| v.into()) + .collect::>(); + + Ok(HttpResponse::Ok().json(response)) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +#[get("{version_id}")] +pub async fn version_get( + info: web::Path<(models::ids::ModId, models::ids::VersionId)>, + pool: web::Data, +) -> Result { + let id = info.1; + let version_data = database::models::Version::get_full(id.into(), &**pool) + .await + .map_err(|e| ApiError::DatabaseError(e.into()))?; + + if let Some(data) = version_data { + use models::mods::VersionType; + + if models::ids::ModId::from(data.mod_id) != info.0 { + // Version doesn't belong to that mod + return Ok(HttpResponse::NotFound().body("")); + } + + let response = models::mods::Version { + id: data.id.into(), + mod_id: data.mod_id.into(), + + name: data.name, + version_number: data.version_number, + changelog_url: data.changelog_url, + date_published: data.date_published, + downloads: data.downloads as u32, + version_type: match data.release_channel.as_str() { + "release" => VersionType::Release, + "beta" => VersionType::Beta, + "alpha" => VersionType::Alpha, + _ => VersionType::Alpha, + }, + + files: data + .files + .into_iter() + .map(|f| { + models::mods::VersionFile { + url: f.url, + filename: f.filename, + // FIXME: Hashes are currently stored as an ascii byte slice instead + // of as an actual byte array in the database + hashes: f + .hashes + .into_iter() + .map(|(k, v)| Some((k, String::from_utf8(v).ok()?))) + .collect::>() + .unwrap_or_else(Default::default), + } + }) + .collect(), + dependencies: Vec::new(), // TODO: dependencies + game_versions: data + .game_versions + .into_iter() + .map(models::mods::GameVersion) + .collect(), + loaders: data + .loaders + .into_iter() + .map(models::mods::ModLoader) + .collect(), + }; + Ok(HttpResponse::Ok().json(response)) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} + +// TODO: This really needs auth +#[delete("{version_id}")] +pub async fn version_delete( + info: web::Path<(models::ids::ModId, models::ids::VersionId)>, + pool: web::Data, +) -> Result { + // TODO: check if the mod exists and matches the version id + let id = info.1; + let result = database::models::Version::remove_full(id.into(), &**pool) + .await + .map_err(|e| ApiError::DatabaseError(e.into()))?; + + if result.is_some() { + Ok(HttpResponse::Ok().body("")) + } else { + Ok(HttpResponse::NotFound().body("")) + } +} diff --git a/src/search/indexing/curseforge_import.rs b/src/search/indexing/curseforge_import.rs index 436fbbc4..0404469a 100644 --- a/src/search/indexing/curseforge_import.rs +++ b/src/search/indexing/curseforge_import.rs @@ -142,7 +142,7 @@ pub async fn index_curseforge( } } - if mod_categories.contains(&"fabric".to_owned()) { + if mod_categories.iter().any(|e| e == "fabric") { using_fabric = true; } @@ -154,7 +154,8 @@ pub async fn index_curseforge( mod_categories.push(String::from("forge")); } if using_fabric { - mod_categories.push(String::from("fabric")); + // The only way this could happen is if "fabric" is already a category + // mod_categories.push(String::from("fabric")); } let mut mod_attachments = curseforge_mod.attachments; diff --git a/src/search/indexing/local_import.rs b/src/search/indexing/local_import.rs index b6155a7a..b0ccf2ed 100644 --- a/src/search/indexing/local_import.rs +++ b/src/search/indexing/local_import.rs @@ -34,7 +34,22 @@ pub async fn index_local(pool: PgPool) -> Result, IndexingE .try_collect::>() .await?; - let categories = sqlx::query!( + // TODO: only loaders for recent versions? For mods that have moved from forge to fabric + let loaders: Vec = sqlx::query!( + " + SELECT loaders.loader FROM versions + INNER JOIN loaders_versions lv ON lv.version_id = versions.id + INNER JOIN loaders ON loaders.id = lv.loader_id + WHERE versions.mod_id = $1 + ", + result.id + ) + .fetch_many(&pool) + .try_filter_map(|e| async { Ok(e.right().map(|c| c.loader)) }) + .try_collect::>() + .await?; + + let mut categories = sqlx::query!( " SELECT c.category FROM mods_categories mc @@ -48,6 +63,8 @@ pub async fn index_local(pool: PgPool) -> Result, IndexingE .try_collect::>() .await?; + categories.extend(loaders); + let mut icon_url = "".to_string(); if let Some(url) = result.icon_url {