diff --git a/.env.example b/.env.example index 2c4cf7b..a56aa47 100644 --- a/.env.example +++ b/.env.example @@ -20,4 +20,8 @@ DISCORD_WEBHOOK_URL= MAX_MOD_FILESIZE_MB=250 # Globally disables download counting, in the event of abuse -DISABLE_DOWNLOAD_COUNTS=0 \ No newline at end of file +DISABLE_DOWNLOAD_COUNTS=0 + +# Path where uploaded files are stored on disk (default: ./storage) +# The reverse proxy must serve {STORAGE_PATH}/submission_attachments/ at /storage/submission-attachments/ +STORAGE_PATH=./storage \ No newline at end of file diff --git a/.sqlx/query-0411ef36ab03892ea4fcf4bb8e7ac1a75139bf9937a76d542e0fc3361fe156d1.json b/.sqlx/query-0411ef36ab03892ea4fcf4bb8e7ac1a75139bf9937a76d542e0fc3361fe156d1.json new file mode 100644 index 0000000..0bb81ef --- /dev/null +++ b/.sqlx/query-0411ef36ab03892ea4fcf4bb8e7ac1a75139bf9937a76d542e0fc3361fe156d1.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM mod_version_submission_comments WHERE submission_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + null + ] + }, + "hash": "0411ef36ab03892ea4fcf4bb8e7ac1a75139bf9937a76d542e0fc3361fe156d1" +} diff --git a/.sqlx/query-0519d0d7c6d97ffbff763ba2d1c85b2bc796c64f42f15f078a5639f717aa21b9.json b/.sqlx/query-0519d0d7c6d97ffbff763ba2d1c85b2bc796c64f42f15f078a5639f717aa21b9.json new file mode 100644 index 0000000..0ceae2a --- /dev/null +++ b/.sqlx/query-0519d0d7c6d97ffbff763ba2d1c85b2bc796c64f42f15f078a5639f717aa21b9.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, comment_id, filename, created_at\n FROM mod_version_submission_comment_attachments\n WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "comment_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "filename", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "0519d0d7c6d97ffbff763ba2d1c85b2bc796c64f42f15f078a5639f717aa21b9" +} diff --git a/.sqlx/query-120ac6102a436b096d442dc5e9c0af4628f2b5803a59b2c28c6052250d60bef9.json b/.sqlx/query-120ac6102a436b096d442dc5e9c0af4628f2b5803a59b2c28c6052250d60bef9.json new file mode 100644 index 0000000..7ed59aa --- /dev/null +++ b/.sqlx/query-120ac6102a436b096d442dc5e9c0af4628f2b5803a59b2c28c6052250d60bef9.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n mod_version_id, locked, locked_by,\n created_at, updated_at\n FROM mod_version_submissions\n WHERE mod_version_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_version_id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "locked", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "locked_by", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + false, + true, + false, + false + ] + }, + "hash": "120ac6102a436b096d442dc5e9c0af4628f2b5803a59b2c28c6052250d60bef9" +} diff --git a/.sqlx/query-1ee95124185a80b20fffcc2ca64084ef1440d4a166a1296bedf9529395272486.json b/.sqlx/query-1ee95124185a80b20fffcc2ca64084ef1440d4a166a1296bedf9529395272486.json new file mode 100644 index 0000000..5b67d13 --- /dev/null +++ b/.sqlx/query-1ee95124185a80b20fffcc2ca64084ef1440d4a166a1296bedf9529395272486.json @@ -0,0 +1,54 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n id, submission_id, comment, author_id,\n created_at, updated_at\n FROM mod_version_submission_comments\n WHERE submission_id = $1\n ORDER BY id DESC\n LIMIT $2 OFFSET $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "submission_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "comment", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "author_id", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int4", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true + ] + }, + "hash": "1ee95124185a80b20fffcc2ca64084ef1440d4a166a1296bedf9529395272486" +} diff --git a/.sqlx/query-4ad2b61d2fa7a7edc9ec5ec68eaed41b993407ae32bf0e90fbdd2c8ca3ea9d22.json b/.sqlx/query-4ad2b61d2fa7a7edc9ec5ec68eaed41b993407ae32bf0e90fbdd2c8ca3ea9d22.json new file mode 100644 index 0000000..4d84f0d --- /dev/null +++ b/.sqlx/query-4ad2b61d2fa7a7edc9ec5ec68eaed41b993407ae32bf0e90fbdd2c8ca3ea9d22.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO mod_version_submission_comment_attachments (comment_id, filename)\n VALUES ($1, $2)\n RETURNING id, comment_id, filename, created_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "comment_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "filename", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "4ad2b61d2fa7a7edc9ec5ec68eaed41b993407ae32bf0e90fbdd2c8ca3ea9d22" +} diff --git a/.sqlx/query-66da5cd3367182b9d560fe1f1fac8ef8aa6dd3425c5c609b6e0e2aa7cbdb2b0c.json b/.sqlx/query-66da5cd3367182b9d560fe1f1fac8ef8aa6dd3425c5c609b6e0e2aa7cbdb2b0c.json new file mode 100644 index 0000000..4c06ced --- /dev/null +++ b/.sqlx/query-66da5cd3367182b9d560fe1f1fac8ef8aa6dd3425c5c609b6e0e2aa7cbdb2b0c.json @@ -0,0 +1,48 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE mod_version_submissions\n SET locked = $1, locked_by = $2, updated_at = NOW()\n WHERE mod_version_id = $3\n RETURNING mod_version_id, locked, locked_by, created_at, updated_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_version_id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "locked", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "locked_by", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Bool", + "Int4", + "Int4" + ] + }, + "nullable": [ + false, + false, + true, + false, + false + ] + }, + "hash": "66da5cd3367182b9d560fe1f1fac8ef8aa6dd3425c5c609b6e0e2aa7cbdb2b0c" +} diff --git a/.sqlx/query-75645a63e093cdaadb0ba84844e6299e3e19eee7b530be498c609e6cd6fc2b33.json b/.sqlx/query-75645a63e093cdaadb0ba84844e6299e3e19eee7b530be498c609e6cd6fc2b33.json new file mode 100644 index 0000000..0b4f393 --- /dev/null +++ b/.sqlx/query-75645a63e093cdaadb0ba84844e6299e3e19eee7b530be498c609e6cd6fc2b33.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, comment_id, filename, created_at\n FROM mod_version_submission_comment_attachments\n WHERE comment_id = $1\n ORDER BY id ASC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "comment_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "filename", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "75645a63e093cdaadb0ba84844e6299e3e19eee7b530be498c609e6cd6fc2b33" +} diff --git a/.sqlx/query-7a9ffa7b59f3550e2bd38c756b9cc3566512dc2273dc3b72ba1ba5c2d281aeb9.json b/.sqlx/query-7a9ffa7b59f3550e2bd38c756b9cc3566512dc2273dc3b72ba1ba5c2d281aeb9.json new file mode 100644 index 0000000..568b54b --- /dev/null +++ b/.sqlx/query-7a9ffa7b59f3550e2bd38c756b9cc3566512dc2273dc3b72ba1ba5c2d281aeb9.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM mod_version_submission_comment_attachments WHERE filename = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "7a9ffa7b59f3550e2bd38c756b9cc3566512dc2273dc3b72ba1ba5c2d281aeb9" +} diff --git a/.sqlx/query-8affc6971c1e33b14f3696d0bd9daf4b82d907eba99837083e21909ea9c195a7.json b/.sqlx/query-8affc6971c1e33b14f3696d0bd9daf4b82d907eba99837083e21909ea9c195a7.json new file mode 100644 index 0000000..2ca4f75 --- /dev/null +++ b/.sqlx/query-8affc6971c1e33b14f3696d0bd9daf4b82d907eba99837083e21909ea9c195a7.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n id,\n username,\n display_name,\n verified,\n admin,\n github_user_id as github_id\n FROM developers\n WHERE id = ANY($1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "verified", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "admin", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "github_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int4Array" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "8affc6971c1e33b14f3696d0bd9daf4b82d907eba99837083e21909ea9c195a7" +} diff --git a/.sqlx/query-8c1f2dd19652bb6c393d83c6443bc4cda1f108a749cc953adb9d25a2fffa4072.json b/.sqlx/query-8c1f2dd19652bb6c393d83c6443bc4cda1f108a749cc953adb9d25a2fffa4072.json new file mode 100644 index 0000000..4f049ef --- /dev/null +++ b/.sqlx/query-8c1f2dd19652bb6c393d83c6443bc4cda1f108a749cc953adb9d25a2fffa4072.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE mod_version_submission_comments\n SET comment = $1, updated_at = NOW()\n WHERE id = $2\n RETURNING id, submission_id, comment, author_id, created_at, updated_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "submission_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "comment", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "author_id", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true + ] + }, + "hash": "8c1f2dd19652bb6c393d83c6443bc4cda1f108a749cc953adb9d25a2fffa4072" +} diff --git a/.sqlx/query-91bc425cc6b35915fc00d39b1f127118f0a35841049fc0914add705241d471f6.json b/.sqlx/query-91bc425cc6b35915fc00d39b1f127118f0a35841049fc0914add705241d471f6.json new file mode 100644 index 0000000..4f45f9b --- /dev/null +++ b/.sqlx/query-91bc425cc6b35915fc00d39b1f127118f0a35841049fc0914add705241d471f6.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM mod_version_submission_comment_attachments WHERE comment_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "91bc425cc6b35915fc00d39b1f127118f0a35841049fc0914add705241d471f6" +} diff --git a/.sqlx/query-9eebe36dd30154f9ca5943404eff10f6cbb224587556161b25507d5e56cdbb46.json b/.sqlx/query-9eebe36dd30154f9ca5943404eff10f6cbb224587556161b25507d5e56cdbb46.json new file mode 100644 index 0000000..5b67292 --- /dev/null +++ b/.sqlx/query-9eebe36dd30154f9ca5943404eff10f6cbb224587556161b25507d5e56cdbb46.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM mod_version_submission_comment_attachments WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "9eebe36dd30154f9ca5943404eff10f6cbb224587556161b25507d5e56cdbb46" +} diff --git a/.sqlx/query-bad99b175dfe2ffccfc71b69175b2593d77d4d24d6e2d9ba052ba4bf1cc6489d.json b/.sqlx/query-bad99b175dfe2ffccfc71b69175b2593d77d4d24d6e2d9ba052ba4bf1cc6489d.json new file mode 100644 index 0000000..f983d45 --- /dev/null +++ b/.sqlx/query-bad99b175dfe2ffccfc71b69175b2593d77d4d24d6e2d9ba052ba4bf1cc6489d.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, submission_id, comment, author_id, created_at, updated_at\n FROM mod_version_submission_comments\n WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "submission_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "comment", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "author_id", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true + ] + }, + "hash": "bad99b175dfe2ffccfc71b69175b2593d77d4d24d6e2d9ba052ba4bf1cc6489d" +} diff --git a/.sqlx/query-bc0b5bdd2259f360b41ff48d7fc7bc3b731d8ecc313548af8f9afcd523fa6443.json b/.sqlx/query-bc0b5bdd2259f360b41ff48d7fc7bc3b731d8ecc313548af8f9afcd523fa6443.json new file mode 100644 index 0000000..9a6bda4 --- /dev/null +++ b/.sqlx/query-bc0b5bdd2259f360b41ff48d7fc7bc3b731d8ecc313548af8f9afcd523fa6443.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO mod_version_submissions (mod_version_id)\n VALUES ($1)\n RETURNING mod_version_id, locked, locked_by, created_at, updated_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_version_id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "locked", + "type_info": "Bool" + }, + { + "ordinal": 2, + "name": "locked_by", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + false, + true, + false, + false + ] + }, + "hash": "bc0b5bdd2259f360b41ff48d7fc7bc3b731d8ecc313548af8f9afcd523fa6443" +} diff --git a/.sqlx/query-ddcfdb20c8c93a41da225a937478a56617000567bfd04ed8f9be0e501f438937.json b/.sqlx/query-ddcfdb20c8c93a41da225a937478a56617000567bfd04ed8f9be0e501f438937.json new file mode 100644 index 0000000..644bfe3 --- /dev/null +++ b/.sqlx/query-ddcfdb20c8c93a41da225a937478a56617000567bfd04ed8f9be0e501f438937.json @@ -0,0 +1,54 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO mod_version_submission_comments (submission_id, author_id, comment)\n VALUES ($1, $2, $3)\n RETURNING id, submission_id, comment, author_id, created_at, updated_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "submission_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "comment", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "author_id", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int4", + "Int4", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true + ] + }, + "hash": "ddcfdb20c8c93a41da225a937478a56617000567bfd04ed8f9be0e501f438937" +} diff --git a/.sqlx/query-ea5cf95e89891cf0650305824775fa8cffd29bd82f9ee848b95b11b1f9f03777.json b/.sqlx/query-ea5cf95e89891cf0650305824775fa8cffd29bd82f9ee848b95b11b1f9f03777.json new file mode 100644 index 0000000..ad6b709 --- /dev/null +++ b/.sqlx/query-ea5cf95e89891cf0650305824775fa8cffd29bd82f9ee848b95b11b1f9f03777.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM mod_version_submission_comments WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "ea5cf95e89891cf0650305824775fa8cffd29bd82f9ee848b95b11b1f9f03777" +} diff --git a/Cargo.lock b/Cargo.lock index 5a95c59..0e729a4 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -27,7 +27,7 @@ checksum = "daa239b93927be1ff123eebada5a3ff23e89f0124ccb8609234e5103d5a5ae6d" dependencies = [ "actix-utils", "actix-web", - "derive_more", + "derive_more 2.1.1", "futures-util", "log", "once_cell", @@ -49,7 +49,7 @@ dependencies = [ "brotli", "bytes", "bytestring", - "derive_more", + "derive_more 2.1.1", "encoding_rs", "flate2", "foldhash", @@ -83,6 +83,44 @@ dependencies = [ "syn", ] +[[package]] +name = "actix-multipart" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5118a26dee7e34e894f7e85aa0ee5080ae4c18bf03c0e30d49a80e418f00a53" +dependencies = [ + "actix-multipart-derive", + "actix-utils", + "actix-web", + "derive_more 0.99.20", + "futures-core", + "futures-util", + "httparse", + "local-waker", + "log", + "memchr", + "mime", + "rand 0.8.5", + "serde", + "serde_json", + "serde_plain", + "tempfile", + "tokio", +] + +[[package]] +name = "actix-multipart-derive" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11eb847f49a700678ea2fa73daeb3208061afa2b9d1a8527c03390f4c4a1c6b" +dependencies = [ + "darling", + "parse-size", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "actix-router" version = "0.5.3" @@ -164,7 +202,7 @@ dependencies = [ "bytestring", "cfg-if", "cookie", - "derive_more", + "derive_more 2.1.1", "encoding_rs", "foldhash", "futures-core", @@ -265,6 +303,19 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ammonia" +version = "4.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" +dependencies = [ + "cssparser", + "html5ever", + "maplit", + "tendril", + "url", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -777,6 +828,12 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + [[package]] name = "convert_case" version = "0.10.0" @@ -914,6 +971,64 @@ dependencies = [ "typenum", ] +[[package]] +name = "cssparser" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn", +] + +[[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn", +] + [[package]] name = "deflate64" version = "0.1.10" @@ -951,6 +1066,19 @@ dependencies = [ "syn", ] +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case 0.4.0", + "proc-macro2", + "quote", + "rustc_version", + "syn", +] + [[package]] name = "derive_more" version = "2.1.1" @@ -966,7 +1094,7 @@ version = "2.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" dependencies = [ - "convert_case", + "convert_case 0.10.0", "proc-macro2", "quote", "rustc_version", @@ -1009,6 +1137,21 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "dunce" version = "1.0.5" @@ -1116,6 +1259,12 @@ dependencies = [ "zune-inflate", ] +[[package]] +name = "fastrand" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be" + [[package]] name = "fax" version = "0.2.6" @@ -1200,6 +1349,16 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + [[package]] name = "futures" version = "0.3.31" @@ -1315,8 +1474,11 @@ name = "geode-index" version = "0.51.3" dependencies = [ "actix-cors", + "actix-multipart", "actix-web", + "ammonia", "anyhow", + "bytes", "chrono", "clap", "dotenvy", @@ -1473,6 +1635,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "html5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4" +dependencies = [ + "log", + "markup5ever", + "match_token", +] + [[package]] name = "http" version = "0.2.12" @@ -1701,6 +1874,12 @@ dependencies = [ "zerovec", ] +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + [[package]] name = "idna" version = "1.1.0" @@ -1958,6 +2137,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.8.1" @@ -2014,7 +2199,7 @@ dependencies = [ "anyhow", "arc-swap", "chrono", - "derive_more", + "derive_more 2.1.1", "fnv", "humantime", "libc", @@ -2059,6 +2244,40 @@ dependencies = [ "sha2", ] +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "markup5ever" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.35.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "maybe-rayon" version = "0.1.1" @@ -2320,6 +2539,12 @@ dependencies = [ "windows-link", ] +[[package]] +name = "parse-size" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "487f2ccd1e17ce8c1bfab3a65c89525af41cfad4c8659021a1e9a2aacd73b89b" + [[package]] name = "paste" version = "1.0.15" @@ -2357,6 +2582,58 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros", + "phf_shared", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared", + "rand 0.8.5", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher", +] + [[package]] name = "pin-project-lite" version = "0.2.16" @@ -2445,6 +2722,12 @@ dependencies = [ "zerocopy", ] +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + [[package]] name = "proc-macro2" version = "1.0.106" @@ -2877,6 +3160,19 @@ dependencies = [ "semver", ] +[[package]] +name = "rustix" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + [[package]] name = "rustls" version = "0.23.36" @@ -3070,6 +3366,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_plain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" +dependencies = [ + "serde", +] + [[package]] name = "serde_urlencoded" version = "0.7.1" @@ -3171,6 +3476,12 @@ dependencies = [ "quote", ] +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + [[package]] name = "slab" version = "0.4.11" @@ -3429,6 +3740,31 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -3489,6 +3825,30 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" +[[package]] +name = "tempfile" +version = "3.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0136791f7c95b1f6dd99f9cc786b91bb81c3800b639b3478e561ddb7be95e5f1" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -3859,6 +4219,12 @@ dependencies = [ "serde", ] +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + [[package]] name = "utf8_iter" version = "1.0.4" @@ -4081,6 +4447,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "webpki-root-certs" version = "1.0.5" diff --git a/Cargo.toml b/Cargo.toml index 8207d94..bcea255 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,8 @@ version = "0.51.3" edition = "2024" [dependencies] -image = { version = "0.25", features = ["png"] } +image = { version = "0.25", features = ["png", "jpeg", "webp"] } +actix-multipart = "0.7" actix-web = "4.10" anyhow = "1.0" dotenvy = "0.15" @@ -43,3 +44,5 @@ thiserror = "2.0.12" moka = { version = "0.12.13", features = ["future"] } utoipa = { version = "5.4.0", features = ["actix_extras", "chrono", "uuid"] } utoipa-swagger-ui = { version = "9.0.2", features = ["actix-web"] } +bytes = "1.11" +ammonia = "4" diff --git a/migrations/20260310175835_create_mod_version_submissions_table.down.sql b/migrations/20260310175835_create_mod_version_submissions_table.down.sql new file mode 100644 index 0000000..81adb2d --- /dev/null +++ b/migrations/20260310175835_create_mod_version_submissions_table.down.sql @@ -0,0 +1,4 @@ +-- Add down migration script here + +DROP TABLE IF EXISTS mod_version_submission_comments; +DROP TABLE IF EXISTS mod_version_submissions; \ No newline at end of file diff --git a/migrations/20260310175835_create_mod_version_submissions_table.up.sql b/migrations/20260310175835_create_mod_version_submissions_table.up.sql new file mode 100644 index 0000000..6e44208 --- /dev/null +++ b/migrations/20260310175835_create_mod_version_submissions_table.up.sql @@ -0,0 +1,25 @@ +-- Add up migration script here + +CREATE TABLE mod_version_submissions ( + mod_version_id INT NOT NULL PRIMARY KEY, + locked BOOLEAN NOT NULL DEFAULT FALSE, + locked_by INT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + FOREIGN KEY (mod_version_id) REFERENCES mod_versions(id) ON DELETE CASCADE, + FOREIGN KEY (locked_by) REFERENCES developers(id) ON DELETE SET NULL +); + +CREATE TABLE mod_version_submission_comments ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + submission_id INT NOT NULL, + author_id INT NOT NULL, + comment TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ, + FOREIGN KEY (submission_id) REFERENCES mod_version_submissions(mod_version_id) ON DELETE CASCADE, + FOREIGN KEY (author_id) REFERENCES developers(id) ON DELETE RESTRICT +); + +CREATE INDEX idx_submission_comments_submission_id + ON mod_version_submission_comments(submission_id); diff --git a/migrations/20260312205413_create_submission_comment_attachments.down.sql b/migrations/20260312205413_create_submission_comment_attachments.down.sql new file mode 100644 index 0000000..42513aa --- /dev/null +++ b/migrations/20260312205413_create_submission_comment_attachments.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +DROP TABLE IF EXISTS mod_version_submission_comment_attachments; diff --git a/migrations/20260312205413_create_submission_comment_attachments.up.sql b/migrations/20260312205413_create_submission_comment_attachments.up.sql new file mode 100644 index 0000000..6b6ea1d --- /dev/null +++ b/migrations/20260312205413_create_submission_comment_attachments.up.sql @@ -0,0 +1,16 @@ +-- Add up migration script here +CREATE TABLE mod_version_submission_comment_attachments +( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + comment_id BIGINT NOT NULL, + filename TEXT NOT NULL, -- SHA-256 hex of WebP content; file on disk: {STORAGE_PATH}/submission_attachments/{filename} + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + FOREIGN KEY (comment_id) REFERENCES mod_version_submission_comments (id) ON DELETE CASCADE +); + +CREATE INDEX idx_submission_attachments_comment_id + ON mod_version_submission_comment_attachments (comment_id); + +-- For fast COUNT lookups on delete (deduplication check) +CREATE INDEX idx_submission_attachments_filename + ON mod_version_submission_comment_attachments (filename); diff --git a/src/config.rs b/src/config.rs index 3353e71..2213af9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -21,6 +21,7 @@ pub struct AppData { max_download_mb: u32, port: u16, debug: bool, + storage_path: String, mods_cache: Cache>>, } @@ -53,6 +54,7 @@ pub async fn build_config() -> anyhow::Result { .unwrap_or("250".to_string()) .parse::() .unwrap_or(250); + let storage_path = dotenvy::var("STORAGE_PATH").unwrap_or("./storage".to_string()); let mods_cache = Cache::builder() .max_capacity(128) .time_to_idle(Duration::from_mins(5)) @@ -72,6 +74,7 @@ pub async fn build_config() -> anyhow::Result { max_download_mb, port, debug, + storage_path, mods_cache, }) } @@ -123,6 +126,10 @@ impl AppData { self.debug } + pub fn storage_path(&self) -> &str { + &self.storage_path + } + pub fn mods_cache(&self) -> &Cache>> { &self.mods_cache } diff --git a/src/database/repository/developers.rs b/src/database/repository/developers.rs index 18eae63..c8037e0 100644 --- a/src/database/repository/developers.rs +++ b/src/database/repository/developers.rs @@ -139,6 +139,32 @@ pub async fn get_one(id: i32, conn: &mut PgConnection) -> Result Result, DatabaseError> { + if ids.is_empty() { + return Ok(vec![]); + } + sqlx::query_as!( + Developer, + "SELECT + id, + username, + display_name, + verified, + admin, + github_user_id as github_id + FROM developers + WHERE id = ANY($1)", + ids + ) + .fetch_all(&mut *conn) + .await + .inspect_err(|e| log::error!("Failed to fetch developers by id: {e}")) + .map_err(|e| e.into()) +} + pub async fn get_one_by_username( username: &str, conn: &mut PgConnection, diff --git a/src/database/repository/mod.rs b/src/database/repository/mod.rs index 1073d21..7253922 100644 --- a/src/database/repository/mod.rs +++ b/src/database/repository/mod.rs @@ -10,6 +10,7 @@ pub mod mod_gd_versions; pub mod mod_links; pub mod mod_tags; pub mod mod_version_statuses; +pub mod mod_version_submissions; pub mod mod_versions; pub mod mods; pub mod refresh_tokens; diff --git a/src/database/repository/mod_version_submissions.rs b/src/database/repository/mod_version_submissions.rs new file mode 100644 index 0000000..dc30f73 --- /dev/null +++ b/src/database/repository/mod_version_submissions.rs @@ -0,0 +1,274 @@ +use crate::database::DatabaseError; +use crate::types::models::mod_version_submission::{ + ModVersionSubmissionAttachmentRow, ModVersionSubmissionCommentRow, ModVersionSubmissionRow, +}; +use sqlx::{Error, PgConnection}; + +pub async fn get_for_mod_version( + id: i32, + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query_as!( + ModVersionSubmissionRow, + "SELECT + mod_version_id, locked, locked_by, + created_at, updated_at + FROM mod_version_submissions + WHERE mod_version_id = $1", + id + ) + .fetch_optional(conn) + .await + .inspect_err(|e| log::error!("mod_version_submissions::get_for_mod_versions failed: {e}")) + .map_err(|e| e.into()) +} + +pub async fn create( + mod_version_id: i32, + conn: &mut PgConnection, +) -> Result { + sqlx::query_as!( + ModVersionSubmissionRow, + "INSERT INTO mod_version_submissions (mod_version_id) + VALUES ($1) + RETURNING mod_version_id, locked, locked_by, created_at, updated_at", + mod_version_id + ) + .fetch_one(conn) + .await + .inspect_err(|e| log::error!("mod_version_submissions::create failed: {e}")) + .map_err(|e| e.into()) +} + +pub async fn set_locked( + mod_version_id: i32, + locked: bool, + locked_by: Option, + conn: &mut PgConnection, +) -> Result { + sqlx::query_as!( + ModVersionSubmissionRow, + "UPDATE mod_version_submissions + SET locked = $1, locked_by = $2, updated_at = NOW() + WHERE mod_version_id = $3 + RETURNING mod_version_id, locked, locked_by, created_at, updated_at", + locked, + locked_by, + mod_version_id + ) + .fetch_one(conn) + .await + .inspect_err(|e| log::error!("mod_version_submissions::set_locked failed: {e}")) + .map_err(|e| e.into()) +} + +pub async fn get_paginated_comments_for_submission( + id: i32, + page: i64, + per_page: i64, + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query_as!( + ModVersionSubmissionCommentRow, + "SELECT + id, submission_id, comment, author_id, + created_at, updated_at + FROM mod_version_submission_comments + WHERE submission_id = $1 + ORDER BY id DESC + LIMIT $2 OFFSET $3", + id, + per_page, + (page - 1) * per_page + ) + .fetch_all(conn) + .await + .inspect_err(|e| log::error!("mod_version_submissions::get_paginated_items_for_submission failed: {e}")) + .map_err(|e| e.into()) +} + +pub async fn count_comments_for_submission( + id: i32, + conn: &mut PgConnection, +) -> Result { + sqlx::query_scalar!( + "SELECT COUNT(*) FROM mod_version_submission_comments WHERE submission_id = $1", + id + ) + .fetch_one(conn) + .await + .inspect_err(|e| log::error!("mod_version_submissions::count_comments_for_submission failed: {e}")) + .map(|c| c.unwrap_or(0)) + .map_err(|e| e.into()) +} + +pub async fn create_comment( + submission_id: i32, + author_id: i32, + comment: &str, + conn: &mut PgConnection, +) -> Result { + sqlx::query_as!( + ModVersionSubmissionCommentRow, + "INSERT INTO mod_version_submission_comments (submission_id, author_id, comment) + VALUES ($1, $2, $3) + RETURNING id, submission_id, comment, author_id, created_at, updated_at", + submission_id, + author_id, + comment + ) + .fetch_one(conn) + .await + .inspect_err(|e| log::error!("mod_version_submissions::create_comment failed: {e}")) + .map_err(|e| e.into()) +} + +pub async fn get_comment( + comment_id: i64, + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query_as!( + ModVersionSubmissionCommentRow, + "SELECT id, submission_id, comment, author_id, created_at, updated_at + FROM mod_version_submission_comments + WHERE id = $1", + comment_id + ) + .fetch_optional(conn) + .await + .inspect_err(|e| log::error!("mod_version_submissions::get_comment failed: {e}")) + .map_err(|e| e.into()) +} + +pub async fn update_comment( + comment_id: i64, + new_text: &str, + conn: &mut PgConnection, +) -> Result { + sqlx::query_as!( + ModVersionSubmissionCommentRow, + "UPDATE mod_version_submission_comments + SET comment = $1, updated_at = NOW() + WHERE id = $2 + RETURNING id, submission_id, comment, author_id, created_at, updated_at", + new_text, + comment_id + ) + .fetch_one(conn) + .await + .inspect_err(|e| log::error!("mod_version_submissions::update_comment failed: {e}")) + .map_err(|e| e.into()) +} + +pub async fn delete_comment( + comment_id: i64, + conn: &mut PgConnection, +) -> Result { + let result = sqlx::query!( + "DELETE FROM mod_version_submission_comments WHERE id = $1", + comment_id + ) + .execute(conn) + .await + .inspect_err(|e| log::error!("mod_version_submissions::delete_comment failed: {e}"))?; + Ok(result.rows_affected() > 0) +} + +pub async fn count_attachments_for_comment( + comment_id: i64, + conn: &mut PgConnection, +) -> Result { + sqlx::query_scalar!( + "SELECT COUNT(*) FROM mod_version_submission_comment_attachments WHERE comment_id = $1", + comment_id + ) + .fetch_one(conn) + .await + .inspect_err(|e| log::error!("mod_version_submissions::count_attachments_for_comment failed: {e}")) + .map(|c| c.unwrap_or(0)) + .map_err(|e| e.into()) +} + +pub async fn create_attachment( + comment_id: i64, + filename: &str, + conn: &mut PgConnection, +) -> Result { + sqlx::query_as!( + ModVersionSubmissionAttachmentRow, + "INSERT INTO mod_version_submission_comment_attachments (comment_id, filename) + VALUES ($1, $2) + RETURNING id, comment_id, filename, created_at", + comment_id, + filename + ) + .fetch_one(conn) + .await + .inspect_err(|e| log::error!("mod_version_submissions::create_attachment failed: {e}")) + .map_err(|e| e.into()) +} + +pub async fn get_attachments_for_comment( + comment_id: i64, + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query_as!( + ModVersionSubmissionAttachmentRow, + "SELECT id, comment_id, filename, created_at + FROM mod_version_submission_comment_attachments + WHERE comment_id = $1 + ORDER BY id ASC", + comment_id + ) + .fetch_all(conn) + .await + .inspect_err(|e| log::error!("mod_version_submissions::get_attachments_for_comment failed: {e}")) + .map_err(|e: Error| e.into()) +} + +pub async fn get_attachment( + attachment_id: i64, + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query_as!( + ModVersionSubmissionAttachmentRow, + "SELECT id, comment_id, filename, created_at + FROM mod_version_submission_comment_attachments + WHERE id = $1", + attachment_id + ) + .fetch_optional(conn) + .await + .inspect_err(|e| log::error!("mod_version_submissions::get_attachment failed: {e}")) + .map_err(|e| e.into()) +} + +pub async fn delete_attachment( + attachment_id: i64, + conn: &mut PgConnection, +) -> Result { + let result = sqlx::query!( + "DELETE FROM mod_version_submission_comment_attachments WHERE id = $1", + attachment_id + ) + .execute(conn) + .await + .inspect_err(|e| log::error!("mod_version_submissions::delete_attachment failed: {e}"))?; + Ok(result.rows_affected() > 0) +} + +pub async fn count_references_to_filename( + filename: &str, + conn: &mut PgConnection, +) -> Result { + sqlx::query_scalar!( + "SELECT COUNT(*) FROM mod_version_submission_comment_attachments WHERE filename = $1", + filename + ) + .fetch_one(conn) + .await + .inspect_err(|e| log::error!("mod_version_submissions::count_references_to_filename failed: {e}")) + .map(|c| c.unwrap_or(0)) + .map_err(|e| e.into()) +} + diff --git a/src/endpoints/mod.rs b/src/endpoints/mod.rs index b7a00e1..ca92142 100644 --- a/src/endpoints/mod.rs +++ b/src/endpoints/mod.rs @@ -13,6 +13,7 @@ pub mod mods; pub mod stats; pub mod tags; pub mod deprecations; +pub mod mod_version_submissions; #[derive(thiserror::Error, Debug)] pub enum ApiError { diff --git a/src/endpoints/mod_version_submissions.rs b/src/endpoints/mod_version_submissions.rs new file mode 100644 index 0000000..31dfa04 --- /dev/null +++ b/src/endpoints/mod_version_submissions.rs @@ -0,0 +1,731 @@ +use super::ApiError; +use crate::config::AppData; +use crate::database::repository::{developers, mod_version_submissions, mod_versions, mods}; +use crate::extractors::auth::Auth; +use crate::types::api::{ApiResponse, PaginatedData}; +use crate::types::models::mod_version_submission::{ + CreateCommentPayload, ModVersionSubmission, ModVersionSubmissionAttachment, + ModVersionSubmissionComment, UpdateCommentPayload, UpdateSubmissionPayload, +}; +use actix_multipart::Multipart; +use actix_web::{HttpResponse, Responder, delete, get, post, put, web}; +use futures::StreamExt; +use serde::Deserialize; +use sqlx::PgConnection; +use std::collections::HashMap; +use utoipa::IntoParams; + +fn sanitize_comment(raw: &str) -> String { + ammonia::Builder::default() + .tags(std::collections::HashSet::new()) + .clean(raw) + .to_string() + .trim() + .to_string() +} + +#[derive(Deserialize, IntoParams)] +struct SubmissionPath { + id: String, + version: String, +} + +#[derive(Deserialize, IntoParams)] +struct CommentPath { + id: String, + version: String, + comment_id: i64, +} + +#[derive(Deserialize, IntoParams)] +struct AttachmentPath { + id: String, + version: String, + comment_id: i64, + attachment_id: i64, +} + +#[derive(Deserialize, IntoParams)] +struct CommentsQuery { + page: Option, + per_page: Option, +} + +/// Resolve a mod-version's numeric id from its string version tag, and +/// return both it and the verified-mod id. +async fn resolve_version_id( + mod_id: &str, + version: &str, + pool: &mut PgConnection, +) -> Result { + let ver = mod_versions::get_by_version_str(mod_id, version, pool) + .await? + .ok_or_else(|| ApiError::NotFound(format!("Version {} not found", version)))?; + Ok(ver.id) +} + +/// Get the submission for a mod version +#[utoipa::path( + get, + path = "/v1/mods/{id}/versions/{version}/submission", + tag = "mod_version_submissions", + params(SubmissionPath), + responses( + (status = 200, description = "Submission details", body = inline(ApiResponse)), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Mod, version, or submission not found"), + ), + security(("bearer_token" = [])) +)] +#[get("v1/mods/{id}/versions/{version}/submission")] +pub async fn get_submission( + path: web::Path, + data: web::Data, + auth: Auth, +) -> Result { + auth.developer()?; + + let mut pool = data.db().acquire().await?; + + if !mods::exists(&path.id, &mut pool).await? { + return Err(ApiError::NotFound(format!("Mod {} not found", path.id))); + } + + let version_id = resolve_version_id(&path.id, &path.version, &mut pool).await?; + + let row = mod_version_submissions::get_for_mod_version(version_id, &mut pool) + .await? + .ok_or_else(|| ApiError::NotFound("Submission not found".into()))?; + + let locked_by = match row.locked_by { + Some(dev_id) => Some( + developers::get_one(dev_id, &mut pool) + .await? + .ok_or_else(|| ApiError::InternalError("Locked-by developer not found".into()))?, + ), + None => None, + }; + + Ok(web::Json(ApiResponse { + error: "".into(), + payload: row.into_submission(locked_by), + })) +} + +/// Update (lock / unlock) a submission (admin only) +#[utoipa::path( + put, + path = "/v1/mods/{id}/versions/{version}/submission", + tag = "mod_version_submissions", + params(SubmissionPath), + request_body = UpdateSubmissionPayload, + responses( + (status = 200, description = "Submission updated", body = inline(ApiResponse)), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Mod, version, or submission not found"), + ), + security(("bearer_token" = [])) +)] +#[put("v1/mods/{id}/versions/{version}/submission")] +pub async fn update_submission( + path: web::Path, + data: web::Data, + payload: web::Json, + auth: Auth, +) -> Result { + let dev = auth.developer()?; + auth.check_admin()?; + let mut pool = data.db().acquire().await?; + + if !mods::exists(&path.id, &mut pool).await? { + return Err(ApiError::NotFound(format!("Mod {} not found", path.id))); + } + + let version_id = resolve_version_id(&path.id, &path.version, &mut pool).await?; + + mod_version_submissions::get_for_mod_version(version_id, &mut pool) + .await? + .ok_or_else(|| ApiError::NotFound("Submission not found".into()))?; + + let locked_by_id = if payload.locked { Some(dev.id) } else { None }; + + let row = + mod_version_submissions::set_locked(version_id, payload.locked, locked_by_id, &mut pool) + .await?; + + let locked_by = if payload.locked { + Some(dev.clone()) + } else { + None + }; + + Ok(web::Json(ApiResponse { + error: "".into(), + payload: row.into_submission(locked_by), + })) +} + +/// List comments for a mod version submission +#[utoipa::path( + get, + path = "/v1/mods/{id}/versions/{version}/submission/comments", + tag = "mod_version_submissions", + params(SubmissionPath, CommentsQuery), + responses( + (status = 200, description = "List of comments", body = inline(ApiResponse>)), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Mod, version, or submission not found"), + ), + security(("bearer_token" = [])) +)] +#[get("v1/mods/{id}/versions/{version}/submission/comments")] +pub async fn get_comments( + path: web::Path, + data: web::Data, + query: web::Query, + auth: Auth, +) -> Result { + auth.developer()?; + + let mut pool = data.db().acquire().await?; + + if !mods::exists(&path.id, &mut pool).await? { + return Err(ApiError::NotFound(format!("Mod {} not found", path.id))); + } + + let version_id = resolve_version_id(&path.id, &path.version, &mut pool).await?; + + mod_version_submissions::get_for_mod_version(version_id, &mut pool) + .await? + .ok_or_else(|| ApiError::NotFound("Submission not found".into()))?; + + let page = query.page.unwrap_or(1).max(1); + let per_page = query.per_page.unwrap_or(20).clamp(1, 100); + + let count = + mod_version_submissions::count_comments_for_submission(version_id, &mut pool).await?; + let rows = mod_version_submissions::get_paginated_comments_for_submission( + version_id, page, per_page, &mut pool, + ) + .await?; + + let author_ids: Vec = { + let mut ids: Vec = rows.iter().map(|r| r.author_id).collect(); + ids.sort_unstable(); + ids.dedup(); + ids + }; + + let authors_map = developers::get_many_by_id(&author_ids, &mut pool) + .await? + .into_iter() + .map(|dev| (dev.id, dev)) + .collect::>(); + + let comments = rows + .into_iter() + .map(|row| { + let author = authors_map + .get(&row.author_id) + .cloned() + .ok_or_else(|| ApiError::InternalError("Author not found".into()))?; + Ok(row.into_comment(author)) + }) + .collect::, ApiError>>()?; + + Ok(web::Json(ApiResponse { + error: "".into(), + payload: PaginatedData { + data: comments, + count, + }, + })) +} + +/// Add a comment to a mod version submission +#[utoipa::path( + post, + path = "/v1/mods/{id}/versions/{version}/submission/comments", + tag = "mod_version_submissions", + params(SubmissionPath), + request_body = CreateCommentPayload, + responses( + (status = 201, description = "Comment created", body = inline(ApiResponse)), + (status = 400, description = "Bad request - locked submission or empty comment"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Mod, version, or submission not found"), + ), + security(("bearer_token" = [])) +)] +#[post("v1/mods/{id}/versions/{version}/submission/comments")] +pub async fn create_comment( + path: web::Path, + data: web::Data, + payload: web::Json, + auth: Auth, +) -> Result { + let dev = auth.developer()?; + + let comment_text = sanitize_comment(&payload.comment); + if comment_text.is_empty() { + return Err(ApiError::BadRequest("Comment must not be empty".into())); + } + if comment_text.len() > 1000 { + return Err(ApiError::BadRequest( + "Comment must not exceed 1000 characters".into(), + )); + } + + let mut pool = data.db().acquire().await?; + + if !mods::exists(&path.id, &mut pool).await? { + return Err(ApiError::NotFound(format!("Mod {} not found", path.id))); + } + + let version_id = resolve_version_id(&path.id, &path.version, &mut pool).await?; + + let submission = mod_version_submissions::get_for_mod_version(version_id, &mut pool) + .await? + .ok_or_else(|| ApiError::NotFound("Submission not found".into()))?; + + if submission.locked { + return Err(ApiError::BadRequest( + "Submission is locked; no new comments allowed".into(), + )); + } + + // Only the mod developers (or admins) may comment + if !dev.admin && !developers::has_access_to_mod(dev.id, &path.id, &mut pool).await? { + return Err(ApiError::Authorization); + } + + let row = mod_version_submissions::create_comment(version_id, dev.id, &comment_text, &mut pool) + .await?; + + Ok(HttpResponse::Created().json(ApiResponse { + error: "".into(), + payload: row.into_comment(dev), + })) +} + +/// Update a comment on a mod version submission +#[utoipa::path( + put, + path = "/v1/mods/{id}/versions/{version}/submission/comments/{comment_id}", + tag = "mod_version_submissions", + params(CommentPath), + request_body = UpdateCommentPayload, + responses( + (status = 200, description = "Comment updated", body = inline(ApiResponse)), + (status = 400, description = "Bad request – locked submission or empty comment"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden – may only edit own comments (admins can edit any)"), + (status = 404, description = "Mod, version, submission, or comment not found"), + ), + security(("bearer_token" = [])) +)] +#[put("v1/mods/{id}/versions/{version}/submission/comments/{comment_id}")] +pub async fn update_comment( + path: web::Path, + data: web::Data, + payload: web::Json, + auth: Auth, +) -> Result { + let dev = auth.developer()?; + + let comment_text = sanitize_comment(&payload.comment); + if comment_text.is_empty() { + return Err(ApiError::BadRequest("Comment must not be empty".into())); + } + if comment_text.len() > 1000 { + return Err(ApiError::BadRequest( + "Comment must not exceed 1000 characters".into(), + )); + } + + let mut pool = data.db().acquire().await?; + + if !mods::exists(&path.id, &mut pool).await? { + return Err(ApiError::NotFound(format!("Mod {} not found", path.id))); + } + + let version_id = resolve_version_id(&path.id, &path.version, &mut pool).await?; + + let submission = mod_version_submissions::get_for_mod_version(version_id, &mut pool) + .await? + .ok_or_else(|| ApiError::NotFound("Submission not found".into()))?; + + if submission.locked { + return Err(ApiError::BadRequest( + "Submission is locked; comments cannot be edited".into(), + )); + } + + let comment_row = mod_version_submissions::get_comment(path.comment_id, &mut pool) + .await? + .ok_or_else(|| ApiError::NotFound(format!("Comment {} not found", path.comment_id)))?; + + if comment_row.submission_id != version_id { + return Err(ApiError::NotFound(format!( + "Comment {} does not belong to this submission", + path.comment_id + ))); + } + + if !dev.admin && comment_row.author_id != dev.id { + return Err(ApiError::Authorization); + } + + let updated_row = + mod_version_submissions::update_comment(path.comment_id, &comment_text, &mut pool).await?; + + let author = developers::get_one(updated_row.author_id, &mut pool) + .await? + .ok_or_else(|| ApiError::InternalError("Author not found".into()))?; + + Ok(web::Json(ApiResponse { + error: "".into(), + payload: updated_row.into_comment(author), + })) +} + +/// Delete a comment on a mod version submission +#[utoipa::path( + delete, + path = "/v1/mods/{id}/versions/{version}/submission/comments/{comment_id}", + tag = "mod_version_submissions", + params(CommentPath), + responses( + (status = 204, description = "Comment deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden – may only delete own comments (admins can delete any)"), + (status = 404, description = "Mod, version, submission, or comment not found"), + ), + security(("bearer_token" = [])) +)] +#[delete("v1/mods/{id}/versions/{version}/submission/comments/{comment_id}")] +pub async fn delete_comment( + path: web::Path, + data: web::Data, + auth: Auth, +) -> Result { + let dev = auth.developer()?; + + let mut pool = data.db().acquire().await?; + + if !mods::exists(&path.id, &mut pool).await? { + return Err(ApiError::NotFound(format!("Mod {} not found", path.id))); + } + + let version_id = resolve_version_id(&path.id, &path.version, &mut pool).await?; + + let submission = mod_version_submissions::get_for_mod_version(version_id, &mut pool) + .await? + .ok_or_else(|| ApiError::NotFound("Submission not found".into()))?; + + if submission.locked && !dev.admin { + return Err(ApiError::BadRequest( + "Submission is locked; comments cannot be deleted".into(), + )); + } + + let comment_row = mod_version_submissions::get_comment(path.comment_id, &mut pool) + .await? + .ok_or_else(|| ApiError::NotFound(format!("Comment {} not found", path.comment_id)))?; + + if comment_row.submission_id != version_id { + return Err(ApiError::NotFound(format!( + "Comment {} does not belong to this submission", + path.comment_id + ))); + } + + if !dev.admin && comment_row.author_id != dev.id { + return Err(ApiError::Authorization); + } + + mod_version_submissions::delete_comment(path.comment_id, &mut pool).await?; + + Ok(HttpResponse::NoContent()) +} + +/// List attachments for a submission comment +#[utoipa::path( + get, + path = "/v1/mods/{id}/versions/{version}/submission/comments/{comment_id}/attachments", + tag = "mod_version_submissions", + params(CommentPath), + responses( + (status = 200, description = "List of attachments", body = inline(ApiResponse>)), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Mod, version, submission, or comment not found"), + ), + security(("bearer_token" = [])) +)] +#[get("v1/mods/{id}/versions/{version}/submission/comments/{comment_id}/attachments")] +pub async fn get_attachments( + path: web::Path, + data: web::Data, + auth: Auth, +) -> Result { + auth.developer()?; + + let mut pool = data.db().acquire().await?; + + if !mods::exists(&path.id, &mut pool).await? { + return Err(ApiError::NotFound(format!("Mod {} not found", path.id))); + } + + let version_id = resolve_version_id(&path.id, &path.version, &mut pool).await?; + + mod_version_submissions::get_for_mod_version(version_id, &mut pool) + .await? + .ok_or_else(|| ApiError::NotFound("Submission not found".into()))?; + + let comment_row = mod_version_submissions::get_comment(path.comment_id, &mut pool) + .await? + .ok_or_else(|| ApiError::NotFound(format!("Comment {} not found", path.comment_id)))?; + + if comment_row.submission_id != version_id { + return Err(ApiError::NotFound(format!( + "Comment {} does not belong to this submission", + path.comment_id + ))); + } + + let rows = + mod_version_submissions::get_attachments_for_comment(path.comment_id, &mut pool).await?; + + let app_url = data.app_url().to_string(); + let attachments: Vec = rows + .into_iter() + .map(|r| r.into_attachment(&app_url)) + .collect(); + + Ok(web::Json(ApiResponse { + error: "".into(), + payload: attachments, + })) +} + +/// Upload attachments to a submission comment +#[utoipa::path( + post, + path = "/v1/mods/{id}/versions/{version}/submission/comments/{comment_id}/attachments", + tag = "mod_version_submissions", + params(CommentPath), + responses( + (status = 201, description = "Attachments uploaded", body = inline(ApiResponse>)), + (status = 400, description = "Bad request - no images, file too large, or attachment limit exceeded"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Mod, version, submission, or comment not found"), + ), + security(("bearer_token" = [])) +)] +#[post("v1/mods/{id}/versions/{version}/submission/comments/{comment_id}/attachments")] +pub async fn upload_attachments( + path: web::Path, + data: web::Data, + mut multipart: Multipart, + auth: Auth, +) -> Result { + let dev = auth.developer()?; + + let mut pool = data.db().acquire().await?; + + if !mods::exists(&path.id, &mut pool).await? { + return Err(ApiError::NotFound(format!("Mod {} not found", path.id))); + } + + let version_id = resolve_version_id(&path.id, &path.version, &mut pool).await?; + + let submission = mod_version_submissions::get_for_mod_version(version_id, &mut pool) + .await? + .ok_or_else(|| ApiError::NotFound("Submission not found".into()))?; + + if submission.locked && !dev.admin { + return Err(ApiError::BadRequest( + "Submission is locked; attachments cannot be uploaded".into(), + )); + } + + let comment_row = mod_version_submissions::get_comment(path.comment_id, &mut pool) + .await? + .ok_or_else(|| ApiError::NotFound(format!("Comment {} not found", path.comment_id)))?; + + if comment_row.submission_id != version_id { + return Err(ApiError::NotFound(format!( + "Comment {} does not belong to this submission", + path.comment_id + ))); + } + + if !dev.admin + && !developers::has_access_to_mod(dev.id, &path.id, &mut pool).await? + && comment_row.author_id != dev.id + { + return Err(ApiError::Authorization); + } + + // Collect all `image` fields from the multipart stream + const MAX_BYTES: usize = 5 * 1024 * 1024; + let mut images: Vec = Vec::new(); + while let Some(field) = multipart.next().await { + let mut field = field.map_err(|e| ApiError::BadRequest(e.to_string()))?; + if field.name() != Some("image") { + continue; + } + let mut buf = bytes::BytesMut::new(); + while let Some(chunk) = field.next().await { + let chunk = chunk.map_err(|e| ApiError::BadRequest(e.to_string()))?; + buf.extend_from_slice(&chunk); + if buf.len() > MAX_BYTES { + return Err(ApiError::BadRequest("Image exceeds 5 MB limit".into())); + } + } + images.push(buf.freeze()); + } + + if images.is_empty() { + return Err(ApiError::BadRequest( + "At least one image field is required".into(), + )); + } + + let existing = + mod_version_submissions::count_attachments_for_comment(path.comment_id, &mut pool).await?; + if existing + images.len() as i64 > 5 { + return Err(ApiError::BadRequest(format!( + "Comment already has {} attachment(s); adding {} would exceed the limit of 5", + existing, + images.len() + ))); + } + + let storage_path = data.storage_path().to_string(); + + // Decode → encode WebP → hash, all in a blocking thread + let processed: Vec<(String, Vec)> = + tokio::task::spawn_blocking(move || -> Result)>, ApiError> { + images + .into_iter() + .map(|raw| { + let img = image::load_from_memory(&raw) + .map_err(|e| ApiError::BadRequest(format!("Invalid image: {e}")))?; + let mut webp_bytes: Vec = Vec::new(); + img.write_to( + &mut std::io::Cursor::new(&mut webp_bytes), + image::ImageFormat::WebP, + ) + .map_err(|e| ApiError::InternalError(format!("WebP encode failed: {e}")))?; + let filename = format!("{}.webp", sha256::digest(&webp_bytes)); + Ok((filename, webp_bytes)) + }) + .collect() + }) + .await + .map_err(|e| ApiError::InternalError(format!("Task join error: {e}")))??; + + // Write files and insert DB rows + let attachments_dir = format!("{}/submission_attachments", storage_path); + let app_url = data.app_url().to_string(); + let mut result = Vec::with_capacity(processed.len()); + for (filename, webp_bytes) in processed { + let file_path = format!("{}/{}", attachments_dir, filename); + if !std::path::Path::new(&file_path).exists() { + tokio::fs::write(&file_path, &webp_bytes) + .await + .map_err(|e| ApiError::InternalError(format!("Failed to write file: {e}")))?; + } + let row = mod_version_submissions::create_attachment(path.comment_id, &filename, &mut pool) + .await?; + result.push(row.into_attachment(&app_url)); + } + + Ok(HttpResponse::Created().json(ApiResponse { + error: "".into(), + payload: result, + })) +} + +/// Delete an attachment from a submission comment +#[utoipa::path( + delete, + path = "/v1/mods/{id}/versions/{version}/submission/comments/{comment_id}/attachments/{attachment_id}", + tag = "mod_version_submissions", + params(AttachmentPath), + responses( + (status = 204, description = "Attachment deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Mod, version, submission, comment, or attachment not found"), + ), + security(("bearer_token" = [])) +)] +#[delete( + "v1/mods/{id}/versions/{version}/submission/comments/{comment_id}/attachments/{attachment_id}" +)] +pub async fn delete_attachment( + path: web::Path, + data: web::Data, + auth: Auth, +) -> Result { + let dev = auth.developer()?; + + let mut pool = data.db().acquire().await?; + + if !mods::exists(&path.id, &mut pool).await? { + return Err(ApiError::NotFound(format!("Mod {} not found", path.id))); + } + + let version_id = resolve_version_id(&path.id, &path.version, &mut pool).await?; + + mod_version_submissions::get_for_mod_version(version_id, &mut pool) + .await? + .ok_or_else(|| ApiError::NotFound("Submission not found".into()))?; + + let comment_row = mod_version_submissions::get_comment(path.comment_id, &mut pool) + .await? + .ok_or_else(|| ApiError::NotFound(format!("Comment {} not found", path.comment_id)))?; + + if comment_row.submission_id != version_id { + return Err(ApiError::NotFound(format!( + "Comment {} does not belong to this submission", + path.comment_id + ))); + } + + if !dev.admin && comment_row.author_id != dev.id { + return Err(ApiError::Authorization); + } + + let attachment = mod_version_submissions::get_attachment(path.attachment_id, &mut pool) + .await? + .ok_or_else(|| { + ApiError::NotFound(format!("Attachment {} not found", path.attachment_id)) + })?; + + if attachment.comment_id != path.comment_id { + return Err(ApiError::NotFound(format!( + "Attachment {} does not belong to this comment", + path.attachment_id + ))); + } + + let filename = attachment.filename.clone(); + mod_version_submissions::delete_attachment(path.attachment_id, &mut pool).await?; + + let remaining = + mod_version_submissions::count_references_to_filename(&filename, &mut pool).await?; + if remaining == 0 { + let file_path = format!( + "{}/submission_attachments/{}", + data.storage_path(), + filename + ); + tokio::fs::remove_file(&file_path).await.ok(); + } + + Ok(HttpResponse::NoContent()) +} diff --git a/src/endpoints/mod_versions.rs b/src/endpoints/mod_versions.rs index 487fcc4..285d2f2 100644 --- a/src/endpoints/mod_versions.rs +++ b/src/endpoints/mod_versions.rs @@ -8,7 +8,7 @@ use utoipa::{ToSchema, IntoParams}; use crate::config::AppData; use crate::database::repository::{ dependencies, developers, incompatibilities, mod_downloads, mod_gd_versions, mod_links, - mod_tags, mod_versions, mods, + mod_tags, mod_version_submissions, mod_versions, mods, }; use crate::endpoints::ApiError; use crate::events::mod_created::{ @@ -437,6 +437,11 @@ pub async fn create_version( mods::update_with_json_moved(the_mod, json, &mut tx).await?; } + + if !make_accepted { + mod_version_submissions::create(version.id, &mut tx).await?; + } + tx.commit().await?; if make_accepted { diff --git a/src/endpoints/mods.rs b/src/endpoints/mods.rs index 9bc5c93..7050c4c 100644 --- a/src/endpoints/mods.rs +++ b/src/endpoints/mods.rs @@ -6,7 +6,7 @@ use crate::database::repository::mod_links; use crate::database::repository::mod_tags; use crate::database::repository::mod_versions; use crate::database::repository::mods; -use crate::database::repository::{dependencies, deprecations}; +use crate::database::repository::{dependencies, deprecations, mod_version_submissions}; use crate::endpoints::ApiError; use crate::events::mod_feature::ModFeaturedEvent; use crate::extractors::auth::Auth; @@ -271,6 +271,11 @@ pub async fn create( the_mod.developers = developers::get_all_for_mod(&the_mod.id, &mut tx).await?; the_mod.versions.insert(0, version); + + // First version is always pending, so always open a submission for review + let first_version = the_mod.versions.first().unwrap(); + mod_version_submissions::create(first_version.id, &mut tx).await?; + tx.commit().await?; for i in &mut the_mod.versions { diff --git a/src/main.rs b/src/main.rs index 32ac1f7..402bb16 100644 --- a/src/main.rs +++ b/src/main.rs @@ -36,6 +36,10 @@ async fn main() -> anyhow::Result<()> { log::info!("Running migrations"); sqlx::migrate!("./migrations").run(app_data.db()).await?; + let attachments_dir = format!("{}/submission_attachments", app_data.storage_path()); + std::fs::create_dir_all(&attachments_dir) + .map_err(|e| anyhow::anyhow!("Failed to create storage directory: {e}"))?; + let port = app_data.port(); let debug = app_data.debug(); @@ -69,6 +73,15 @@ async fn main() -> anyhow::Result<()> { .service(endpoints::mod_versions::download_version) .service(endpoints::mod_versions::create_version) .service(endpoints::mod_versions::update_version) + .service(endpoints::mod_version_submissions::get_submission) + .service(endpoints::mod_version_submissions::update_submission) + .service(endpoints::mod_version_submissions::get_comments) + .service(endpoints::mod_version_submissions::create_comment) + .service(endpoints::mod_version_submissions::update_comment) + .service(endpoints::mod_version_submissions::delete_comment) + .service(endpoints::mod_version_submissions::get_attachments) + .service(endpoints::mod_version_submissions::upload_attachments) + .service(endpoints::mod_version_submissions::delete_attachment) .service(endpoints::deprecations::index) .service(endpoints::deprecations::store) .service(endpoints::deprecations::update) diff --git a/src/openapi.rs b/src/openapi.rs index 6db8861..6c97673 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -16,6 +16,15 @@ use crate::{endpoints, types}; endpoints::mod_versions::download_version, endpoints::mod_versions::create_version, endpoints::mod_versions::update_version, + endpoints::mod_version_submissions::get_submission, + endpoints::mod_version_submissions::update_submission, + endpoints::mod_version_submissions::get_comments, + endpoints::mod_version_submissions::create_comment, + endpoints::mod_version_submissions::update_comment, + endpoints::mod_version_submissions::delete_comment, + endpoints::mod_version_submissions::get_attachments, + endpoints::mod_version_submissions::upload_attachments, + endpoints::mod_version_submissions::delete_attachment, endpoints::deprecations::index, endpoints::deprecations::store, endpoints::deprecations::update, @@ -72,6 +81,12 @@ use crate::{endpoints, types}; types::models::mod_link::ModLinks, types::models::loader_version::LoaderVersion, types::models::gd_version_alias::GDVersionAlias, + types::models::mod_version_submission::ModVersionSubmission, + types::models::mod_version_submission::ModVersionSubmissionComment, + types::models::mod_version_submission::UpdateSubmissionPayload, + types::models::mod_version_submission::CreateCommentPayload, + types::models::mod_version_submission::UpdateCommentPayload, + types::models::mod_version_submission::ModVersionSubmissionAttachment, endpoints::mods::IndexSortType, endpoints::developers::SimpleDevMod, endpoints::developers::SimpleDevModVersion, @@ -80,6 +95,7 @@ use crate::{endpoints, types}; tags( (name = "mods", description = "Mod management endpoints"), (name = "mod_versions", description = "Mod version management endpoints"), + (name = "mod_version_submissions", description = "Mod version submission and review endpoints"), (name = "deprecations", description = "Mod deprecation management endpoints"), (name = "developers", description = "Developer management endpoints"), (name = "tags", description = "Tag management endpoints"), diff --git a/src/types/models/mod.rs b/src/types/models/mod.rs index f9719dc..ac1abd9 100644 --- a/src/types/models/mod.rs +++ b/src/types/models/mod.rs @@ -1,14 +1,15 @@ pub mod dependency; +pub mod deprecations; pub mod developer; +pub mod gd_version_alias; pub mod github_login_attempt; pub mod incompatibility; +pub mod loader_version; pub mod mod_entity; pub mod mod_gd_version; pub mod mod_link; pub mod mod_version; pub mod mod_version_status; +pub mod mod_version_submission; pub mod stats; pub mod tag; -pub mod loader_version; -pub mod gd_version_alias; -pub mod deprecations; diff --git a/src/types/models/mod_version_submission.rs b/src/types/models/mod_version_submission.rs new file mode 100644 index 0000000..b06e5d0 --- /dev/null +++ b/src/types/models/mod_version_submission.rs @@ -0,0 +1,114 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; +use crate::types::models::developer::Developer; + +#[derive(Serialize, ToSchema, Debug, Clone)] +pub struct ModVersionSubmission { + pub mod_version_id: i32, + pub locked: bool, + pub locked_by: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +pub struct ModVersionSubmissionRow { + pub mod_version_id: i32, + pub locked: bool, + pub locked_by: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl ModVersionSubmissionRow { + pub fn into_submission(self, locked_by: Option) -> ModVersionSubmission { + ModVersionSubmission { + mod_version_id: self.mod_version_id, + locked: self.locked, + locked_by, + created_at: self.created_at, + updated_at: self.updated_at, + } + } +} + +#[derive(Serialize, ToSchema, Debug, Clone)] +pub struct ModVersionSubmissionComment { + pub id: i64, + pub submission_id: i32, + pub comment: String, + pub author: Developer, + pub created_at: DateTime, + pub updated_at: Option>, +} + +pub struct ModVersionSubmissionCommentRow { + pub id: i64, + pub submission_id: i32, + pub comment: String, + pub author_id: i32, + pub created_at: DateTime, + pub updated_at: Option>, +} + +impl ModVersionSubmissionCommentRow { + pub fn into_comment(self, author: Developer) -> ModVersionSubmissionComment { + ModVersionSubmissionComment { + id: self.id, + submission_id: self.submission_id, + comment: self.comment, + author, + created_at: self.created_at, + updated_at: self.updated_at, + } + } +} + +#[derive(Serialize, ToSchema, Debug, Clone)] +pub struct ModVersionSubmissionAttachment { + pub id: i64, + pub comment_id: i64, + pub url: String, + pub created_at: DateTime, +} + +pub struct ModVersionSubmissionAttachmentRow { + pub id: i64, + pub comment_id: i64, + pub filename: String, + pub created_at: DateTime, +} + +impl ModVersionSubmissionAttachmentRow { + pub fn into_attachment(self, app_url: &str) -> ModVersionSubmissionAttachment { + ModVersionSubmissionAttachment { + id: self.id, + comment_id: self.comment_id, + url: format!( + "{}/storage/submission-attachments/{}", + app_url, self.filename + ), + created_at: self.created_at, + } + } +} + +#[derive(Deserialize, ToSchema)] +pub struct UpdateSubmissionPayload { + pub locked: bool, +} + +#[derive(Deserialize, ToSchema)] +pub struct CreateCommentPayload { + #[schema(max_length = 1000)] + /// Plain text comment; HTML tags are stripped + pub comment: String, +} + +#[derive(Deserialize, ToSchema)] +pub struct UpdateCommentPayload { + #[schema(max_length = 1000)] + /// Plain text comment; HTML tags are stripped + pub comment: String, +} +