diff --git a/.github/workflows/labrinth-docker.yml b/.github/workflows/labrinth-docker.yml new file mode 100644 index 000000000..d967b3bab --- /dev/null +++ b/.github/workflows/labrinth-docker.yml @@ -0,0 +1,46 @@ +name: docker-build + +on: + push: + branches: [ "main" ] + paths: + - .github/workflows/labrinth-docker.yml + - 'apps/labrinth/**' + pull_request: + types: [ opened, synchronize ] + paths: + - .github/workflows/labrinth-docker.yml + - 'apps/labrinth/**' + merge_group: + types: [ checks_requested ] + +jobs: + docker: + runs-on: ubuntu-latest + defaults: + run: + working-directory: ./apps/labrinth + steps: + - name: Checkout + uses: actions/checkout@v2 + - name: Fetch docker metadata + id: docker_meta + uses: docker/metadata-action@v3 + with: + images: ghcr.io/modrinth/labrinth + - + name: Login to GitHub Images + uses: docker/login-action@v1 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + - + name: Build and push + id: docker_build + uses: docker/build-push-action@v2 + with: + context: ./apps/labrinth + push: ${{ github.event_name != 'pull_request' }} + tags: ${{ steps.docker_meta.outputs.tags }} + labels: ${{ steps.docker_meta.outputs.labels }} \ No newline at end of file diff --git a/.github/workflows/app-release.yml b/.github/workflows/theseus-release.yml similarity index 100% rename from .github/workflows/app-release.yml rename to .github/workflows/theseus-release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/turbo-ci.yml similarity index 85% rename from .github/workflows/ci.yml rename to .github/workflows/turbo-ci.yml index cea7e1593..cd5dfa705 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/turbo-ci.yml @@ -62,9 +62,19 @@ jobs: - name: Build run: pnpm build + env: + SQLX_OFFLINE: true - name: Lint run: pnpm lint + env: + SQLX_OFFLINE: true + + - name: Start docker compose + run: docker compose up -d - name: Test run: pnpm test + env: + SQLX_OFFLINE: true + DATABASE_URL: postgresql://labrinth:labrinth@localhost/postgres diff --git a/Cargo.lock b/Cargo.lock index 997bc3cbf..ddc5ad543 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,295 @@ # It is not intended for manual editing. version = 3 +[[package]] +name = "actix-codec" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f7b0a21988c1bf877cf4759ef5ddaac04c1c9fe808c9142ecb78ba97d97a28a" +dependencies = [ + "bitflags 2.6.0", + "bytes", + "futures-core", + "futures-sink", + "memchr", + "pin-project-lite", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "actix-cors" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9e772b3bcafe335042b5db010ab7c09013dad6eac4915c91d8d50902769f331" +dependencies = [ + "actix-utils", + "actix-web", + "derive_more", + "futures-util", + "log", + "once_cell", + "smallvec", +] + +[[package]] +name = "actix-files" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0773d59061dedb49a8aed04c67291b9d8cf2fe0b60130a381aab53c6dd86e9be" +dependencies = [ + "actix-http", + "actix-service", + "actix-utils", + "actix-web", + "bitflags 2.6.0", + "bytes", + "derive_more", + "futures-core", + "http-range", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "v_htmlescape", +] + +[[package]] +name = "actix-http" +version = "3.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d48f96fc3003717aeb9856ca3d02a8c7de502667ad76eeacd830b48d2e91fac4" +dependencies = [ + "actix-codec", + "actix-rt", + "actix-service", + "actix-utils", + "ahash 0.8.11", + "base64 0.22.1", + "bitflags 2.6.0", + "brotli", + "bytes", + "bytestring", + "derive_more", + "encoding_rs", + "flate2", + "futures-core", + "h2 0.3.26", + "http 0.2.12", + "httparse", + "httpdate", + "itoa 1.0.11", + "language-tags", + "local-channel", + "mime", + "percent-encoding", + "pin-project-lite", + "rand 0.8.5", + "sha1 0.10.6", + "smallvec", + "tokio", + "tokio-util", + "tracing", + "zstd 0.13.2", +] + +[[package]] +name = "actix-macros" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01ed3140b2f8d422c68afa1ed2e85d996ea619c988ac834d255db32138655cb" +dependencies = [ + "quote", + "syn 2.0.79", +] + +[[package]] +name = "actix-multipart" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d974dd6c4f78d102d057c672dcf6faa618fafa9df91d44f9c466688fc1275a3a" +dependencies = [ + "actix-multipart-derive", + "actix-utils", + "actix-web", + "bytes", + "derive_more", + "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.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a0a77f836d869f700e5b47ac7c3c8b9c8bc82e4aec861954c6198abee3ebd4d" +dependencies = [ + "darling 0.20.10", + "parse-size", + "proc-macro2", + "quote", + "syn 2.0.79", +] + +[[package]] +name = "actix-router" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13d324164c51f63867b57e73ba5936ea151b8a41a1d23d1031eeb9f70d0236f8" +dependencies = [ + "bytestring", + "cfg-if", + "http 0.2.12", + "regex", + "regex-lite", + "serde", + "tracing", +] + +[[package]] +name = "actix-rt" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24eda4e2a6e042aa4e55ac438a2ae052d3b5da0ecf83d7411e1a368946925208" +dependencies = [ + "actix-macros", + "futures-core", + "tokio", +] + +[[package]] +name = "actix-server" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca2549781d8dd6d75c40cf6b6051260a2cc2f3c62343d761a969a0640646894" +dependencies = [ + "actix-rt", + "actix-service", + "actix-utils", + "futures-core", + "futures-util", + "mio 1.0.2", + "socket2", + "tokio", + "tracing", +] + +[[package]] +name = "actix-service" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b894941f818cfdc7ccc4b9e60fa7e53b5042a2e8567270f9147d5591893373a" +dependencies = [ + "futures-core", + "paste", + "pin-project-lite", +] + +[[package]] +name = "actix-utils" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a1dcdff1466e3c2488e1cb5c36a71822750ad43839937f85d2f4d9f8b705d8" +dependencies = [ + "local-waker", + "pin-project-lite", +] + +[[package]] +name = "actix-web" +version = "4.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9180d76e5cc7ccbc4d60a506f2c727730b154010262df5b910eb17dbe4b8cb38" +dependencies = [ + "actix-codec", + "actix-http", + "actix-macros", + "actix-router", + "actix-rt", + "actix-server", + "actix-service", + "actix-utils", + "actix-web-codegen", + "ahash 0.8.11", + "bytes", + "bytestring", + "cfg-if", + "cookie", + "derive_more", + "encoding_rs", + "futures-core", + "futures-util", + "impl-more", + "itoa 1.0.11", + "language-tags", + "log", + "mime", + "once_cell", + "pin-project-lite", + "regex", + "regex-lite", + "serde", + "serde_json", + "serde_urlencoded", + "smallvec", + "socket2", + "time", + "url", +] + +[[package]] +name = "actix-web-codegen" +version = "4.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f591380e2e68490b5dfaf1dd1aa0ebe78d84ba7067078512b4ea6e4492d622b8" +dependencies = [ + "actix-router", + "proc-macro2", + "quote", + "syn 2.0.79", +] + +[[package]] +name = "actix-web-prom" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76743e67d4e7efa9fc2ac7123de0dd7b2ca592668e19334f1d81a3b077afc6ac" +dependencies = [ + "actix-web", + "futures-core", + "log", + "pin-project-lite", + "prometheus", + "regex", + "strfmt", +] + +[[package]] +name = "actix-ws" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "535aec173810be3ca6f25dd5b4d431ae7125d62000aa3cbae1ec739921b02cf3" +dependencies = [ + "actix-codec", + "actix-http", + "actix-web", + "futures-core", + "tokio", +] + [[package]] name = "addr2line" version = "0.24.1" @@ -11,6 +300,12 @@ dependencies = [ "gimli", ] +[[package]] +name = "adler" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f26201604c87b1e01bd3d98f8d5d9a8fcbb815e8cedb41ffccbeb4bf593a35fe" + [[package]] name = "adler2" version = "2.0.0" @@ -28,6 +323,17 @@ dependencies = [ "cpufeatures", ] +[[package]] +name = "ahash" +version = "0.7.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891477e0c6a8957309ee5c45a6368af3ae14bb510732d2684ffa19af310920f9" +dependencies = [ + "getrandom 0.2.15", + "once_cell", + "version_check", +] + [[package]] name = "ahash" version = "0.8.11" @@ -35,6 +341,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e89da841a80418a9b391ebaea17f5c112ffaaa96f621d2c285b5174da76b9011" dependencies = [ "cfg-if", + "getrandom 0.2.15", "once_cell", "version_check", "zerocopy", @@ -100,6 +407,30 @@ dependencies = [ "derive_arbitrary", ] +[[package]] +name = "arc-swap" +version = "1.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f7f8c3906b62b754cd5326047894316021dcfe5a194c8ea52bdd94934a3457" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash 0.5.0", +] + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "ascii" version = "1.1.0" @@ -129,12 +460,23 @@ version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "20cd0e2e25ea8e5f7e9df04578dc6cf5c83577fd09b1a46aaf5c85e1c33f2a7e" dependencies = [ - "event-listener", + "event-listener 5.3.1", "event-listener-strategy", "futures-core", "pin-project-lite", ] +[[package]] +name = "async-channel" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81953c529336010edd6d8e358f886d9581267795c61b19475b71314bffa46d35" +dependencies = [ + "concurrent-queue", + "event-listener 2.5.3", + "futures-core", +] + [[package]] name = "async-channel" version = "2.3.1" @@ -174,8 +516,8 @@ checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec" dependencies = [ "async-task", "concurrent-queue", - "fastrand", - "futures-lite", + "fastrand 2.1.1", + "futures-lite 2.3.0", "slab", ] @@ -187,7 +529,7 @@ checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a" dependencies = [ "async-lock", "blocking", - "futures-lite", + "futures-lite 2.3.0", ] [[package]] @@ -200,9 +542,9 @@ dependencies = [ "cfg-if", "concurrent-queue", "futures-io", - "futures-lite", + "futures-lite 2.3.0", "parking", - "polling", + "polling 3.7.3", "rustix", "slab", "tracing", @@ -215,7 +557,7 @@ version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff6e472cdea888a4bd64f342f09b3f50e1886d32afe8df3d663c01140b811b18" dependencies = [ - "event-listener", + "event-listener 5.3.1", "event-listener-strategy", "pin-project-lite", ] @@ -226,15 +568,15 @@ version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63255f1dc2381611000436537bbedfe83183faa303a5a0edaf191edef06526bb" dependencies = [ - "async-channel", + "async-channel 2.3.1", "async-io", "async-lock", "async-signal", "async-task", "blocking", "cfg-if", - "event-listener", - "futures-lite", + "event-listener 5.3.1", + "futures-lite 2.3.0", "rustix", "tracing", ] @@ -268,6 +610,31 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "async-stripe" +version = "0.37.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2f14b5943a52cf051bbbbb68538e93a69d1e291934174121e769f4b181113f5" +dependencies = [ + "chrono", + "futures-util", + "hex", + "hmac 0.12.1", + "http-types", + "hyper 0.14.31", + "hyper-rustls 0.24.2", + "serde", + "serde_json", + "serde_path_to_error", + "serde_qs 0.10.1", + "sha2 0.10.8", + "smart-default", + "smol_str", + "thiserror", + "tokio", + "uuid 0.8.2", +] + [[package]] name = "async-task" version = "4.7.1" @@ -297,7 +664,7 @@ dependencies = [ "pin-project-lite", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.0", "tungstenite", "webpki-roots", ] @@ -311,7 +678,7 @@ dependencies = [ "async-compression", "chrono", "crc32fast", - "futures-lite", + "futures-lite 2.3.0", "pin-project", "thiserror", "tokio", @@ -357,21 +724,61 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] -name = "autocfg" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" - -[[package]] -name = "backtrace" -version = "0.3.74" +name = "attohttpc" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fcf00bc6d5abb29b5f97e3c61a90b6d3caa12f3faf897d4a3e3607c050a35a7" +dependencies = [ + "http 0.2.12", + "log", + "native-tls", + "serde", + "serde_json", + "url", +] + +[[package]] +name = "autocfg" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ace50bade8e6234aa140d9a2f552bbee1db4d353f69b8217bc503490fc1a9f26" + +[[package]] +name = "aws-creds" +version = "0.34.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3776743bb68d4ad02ba30ba8f64373f1be4e082fe47651767171ce75bb2f6cf5" +dependencies = [ + "attohttpc", + "dirs 4.0.0", + "log", + "quick-xml 0.26.0", + "rust-ini 0.18.0", + "serde", + "thiserror", + "time", + "url", +] + +[[package]] +name = "aws-region" +version = "0.25.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9aed3f9c7eac9be28662fdb3b0f4d1951e812f7c64fed4f0327ba702f459b3b" +dependencies = [ + "thiserror", +] + +[[package]] +name = "backtrace" +version = "0.3.74" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8d82cb332cdfaed17ae235a638438ac4d4839913cc2af585c3c6746e8f8bee1a" dependencies = [ "addr2line", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.8.0", "object", "rustc-demangle", "windows-targets 0.52.6", @@ -383,6 +790,18 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf" +[[package]] +name = "base32" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23ce669cd6c8588f79e15cf450314f9638f967fc5770ff1c7c1deb0925ea7cfa" + +[[package]] +name = "base64" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" + [[package]] name = "base64" version = "0.21.7" @@ -401,6 +820,36 @@ version = "1.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8c3c1a368f70d6cf7302d78f8f7093da241fb8e8807c05cc9e51a125895a6d5b" +[[package]] +name = "bincode" +version = "1.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f45e9417d87227c7a56d22e471c6206462cba514c7590c09aff4cf6d1ddcad" +dependencies = [ + "serde", +] + +[[package]] +name = "bit-set" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0700ddab506f33b20a03b13996eccd309a48e5ff77d0d95926aa0210fb4e95f1" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "349f9b6a179ed607305526ca489b34ad0a41aed5f7980fa90eb03160b69598fb" + +[[package]] +name = "bit_field" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61" + [[package]] name = "bitflags" version = "1.3.2" @@ -416,12 +865,42 @@ dependencies = [ "serde", ] +[[package]] +name = "bitvec" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest 0.10.7", +] + [[package]] name = "block" version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0d8c1fef690941d3e7788d328517591fecc684c084084702d6ff1641e993699a" +[[package]] +name = "block-buffer" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4152116fd6e9dadb291ae18fc1ec3575ed6d84c29642d97890f4b4a3417297e4" +dependencies = [ + "generic-array", +] + [[package]] name = "block-buffer" version = "0.10.4" @@ -446,13 +925,37 @@ version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" dependencies = [ - "async-channel", + "async-channel 2.3.1", "async-task", "futures-io", - "futures-lite", + "futures-lite 2.3.0", "piper", ] +[[package]] +name = "borsh" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6362ed55def622cddc70a4746a68554d7b687713770de539e59a739b249f8ed" +dependencies = [ + "borsh-derive", + "cfg_aliases", +] + +[[package]] +name = "borsh-derive" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3ef8005764f53cd4dca619f5bf64cafd4664dada50ece25e4d81de54c80cc0b" +dependencies = [ + "once_cell", + "proc-macro-crate 3.2.0", + "proc-macro2", + "quote", + "syn 2.0.79", + "syn_derive", +] + [[package]] name = "brotli" version = "6.0.0" @@ -491,6 +994,28 @@ version = "3.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" +[[package]] +name = "bytecheck" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23cdc57ce23ac53c931e88a43d06d070a6fd142f2617be5855eb75efc9beb1c2" +dependencies = [ + "bytecheck_derive", + "ptr_meta", + "simdutf8", +] + +[[package]] +name = "bytecheck_derive" +version = "0.6.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3db406d29fbcd95542e92559bed4d8ad92636d1ca8b3b72ede10b4bcc010e659" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "bytemuck" version = "1.18.0" @@ -503,6 +1028,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.7.2" @@ -512,6 +1043,15 @@ dependencies = [ "serde", ] +[[package]] +name = "bytestring" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74d80203ea6b29df88012294f62733de21cfeab47f17b41af3a38bc30a03ee72" +dependencies = [ + "bytes", +] + [[package]] name = "bzip2" version = "0.4.4" @@ -600,6 +1140,12 @@ dependencies = [ "toml 0.8.19", ] +[[package]] +name = "castaway" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2698f953def977c68f935bb0dfa959375ad4638570e969e2f1e9f433cbf1af6" + [[package]] name = "cc" version = "1.1.22" @@ -611,6 +1157,15 @@ dependencies = [ "shlex", ] +[[package]] +name = "censor" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d41e3b9fdbb9b3edc10dc66a06dc255822f699c432e19403fb966e6d60e0dec4" +dependencies = [ + "once_cell", +] + [[package]] name = "cesu8" version = "1.1.0" @@ -665,6 +1220,16 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "chumsky" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8eebd66744a15ded14960ab4ccdbfb51ad3b81f51f3f04a80adac98c985396c9" +dependencies = [ + "hashbrown 0.14.5", + "stacker", +] + [[package]] name = "cipher" version = "0.4.4" @@ -675,6 +1240,51 @@ dependencies = [ "inout", ] +[[package]] +name = "clickhouse" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0875e527e299fc5f4faba42870bf199a39ab0bb2dbba1b8aef0a2151451130f" +dependencies = [ + "bstr", + "bytes", + "clickhouse-derive", + "clickhouse-rs-cityhash-sys", + "futures", + "hyper 0.14.31", + "hyper-tls 0.5.0", + "lz4", + "sealed", + "serde", + "static_assertions", + "thiserror", + "time", + "tokio", + "url", + "uuid 1.10.0", +] + +[[package]] +name = "clickhouse-derive" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18af5425854858c507eec70f7deb4d5d8cec4216fcb086283a78872387281ea5" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals 0.26.0", + "syn 1.0.109", +] + +[[package]] +name = "clickhouse-rs-cityhash-sys" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4baf9d4700a28d6cb600e17ed6ae2b43298a5245f1f76b4eab63027ebfd592b9" +dependencies = [ + "cc", +] + [[package]] name = "cocoa" version = "0.25.0" @@ -735,6 +1345,21 @@ dependencies = [ "objc", ] +[[package]] +name = "color-thief" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6460d760cf38ce67c9e0318f896538820acc54f2d0a3bfc5b2c557211066c98" +dependencies = [ + "rgb", +] + +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.7" @@ -742,7 +1367,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" dependencies = [ "bytes", + "futures-core", "memchr", + "pin-project-lite", + "tokio", + "tokio-util", ] [[package]] @@ -760,7 +1389,7 @@ version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e1f83fc076bd6dd27517eacdf25fef6c4dfe5f1d7448bafaaf3a26f13b5e4eb" dependencies = [ - "encode_unicode", + "encode_unicode 0.3.6", "lazy_static", "libc", "unicode-width", @@ -799,12 +1428,38 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "245097e9a4535ee1e3e3931fcfcd55a796a44c643e8596ff6566d68f09b87bbc" +[[package]] +name = "constant_time_eq" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21a53c0a4d288377e7415b53dcfc3c04da5cdc2cc95c8d5ac178b58f0b861ad6" + [[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.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "cookie" +version = "0.16.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e859cd57d0710d9e06c381b550c06e76992472a8c6d527aecd2fc673dcc231fb" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -983,6 +1638,16 @@ dependencies = [ "typenum", ] +[[package]] +name = "crypto-mac" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25fab6889090c8133f3deb8f73ba3c65a7f456f66436fc012a1b1e272b1e103e" +dependencies = [ + "generic-array", + "subtle", +] + [[package]] name = "cssparser" version = "0.27.2" @@ -1010,6 +1675,27 @@ dependencies = [ "syn 2.0.79", ] +[[package]] +name = "csv" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac574ff4d437a7b5ad237ef331c17ccca63c46479e5b5453eb8e10bb99a759fe" +dependencies = [ + "csv-core", + "itoa 1.0.11", + "ryu", + "serde", +] + +[[package]] +name = "csv-core" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efa2b3d7902f4b634a20cae3c9c4e6209dc4779feb6863329607560143efa70" +dependencies = [ + "memchr", +] + [[package]] name = "ctor" version = "0.2.8" @@ -1020,6 +1706,37 @@ dependencies = [ "syn 2.0.79", ] +[[package]] +name = "curl" +version = "0.4.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9fb4d13a1be2b58f14d60adba57c9834b78c62fd86c3e76a148f732686e9265" +dependencies = [ + "curl-sys", + "libc", + "openssl-probe", + "openssl-sys", + "schannel", + "socket2", + "windows-sys 0.52.0", +] + +[[package]] +name = "curl-sys" +version = "0.4.77+curl-8.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f469e8a5991f277a208224f6c7ad72ecb5f986e36d09ae1f2c1bb9259478a480" +dependencies = [ + "cc", + "libc", + "libnghttp2-sys", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", + "windows-sys 0.52.0", +] + [[package]] name = "daedalus" version = "0.2.3" @@ -1033,41 +1750,89 @@ dependencies = [ "thiserror", ] +[[package]] +name = "darling" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b750cb3417fd1b327431a470f388520309479ab0bf5e323505daf0290cd3850" +dependencies = [ + "darling_core 0.14.4", + "darling_macro 0.14.4", +] + [[package]] name = "darling" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6f63b86c8a8826a49b8c21f08a2d07338eec8d900540f8630dc76284be802989" dependencies = [ - "darling_core", - "darling_macro", + "darling_core 0.20.10", + "darling_macro 0.20.10", ] [[package]] name = "darling_core" -version = "0.20.10" +version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" dependencies = [ "fnv", "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.10.0", + "syn 1.0.109", +] + +[[package]] +name = "darling_core" +version = "0.20.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95133861a8032aaea082871032f5815eb9e98cef03fa916ab4500513994df9e5" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.11.1", "syn 2.0.79", ] +[[package]] +name = "darling_macro" +version = "0.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4aab4dbc9f7611d8b55048a3a16d2d010c2c8334e46304b40ac1cc14bf3b48e" +dependencies = [ + "darling_core 0.14.4", + "quote", + "syn 1.0.109", +] + [[package]] name = "darling_macro" version = "0.20.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d336a2a514f6ccccaa3e09b02d41d35330c07ddf03a62165fcec10bb561c7806" dependencies = [ - "darling_core", + "darling_core 0.20.10", "quote", "syn 2.0.79", ] +[[package]] +name = "dashmap" +version = "5.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "978747c1d849a7d2ee5e8adc0159961c48fb7e5db2f06af6723b80123bb53856" +dependencies = [ + "cfg-if", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + [[package]] name = "dashmap" version = "6.1.0" @@ -1100,6 +1865,46 @@ dependencies = [ "winapi", ] +[[package]] +name = "deadpool" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6541a3916932fe57768d4be0b1ffb5ec7cbf74ca8c903fdfd5c0fe8aa958f0ed" +dependencies = [ + "deadpool-runtime", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-redis" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfae6799b68a735270e4344ee3e834365f707c72da09c9a8bb89b45cc3351395" +dependencies = [ + "deadpool", + "redis", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" +dependencies = [ + "tokio", +] + +[[package]] +name = "debugid" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef552e6f588e446098f6ba40d89ac146c8c7b64aade83c051ee00bb5d2bc18d" +dependencies = [ + "serde", + "uuid 1.10.0", +] + [[package]] name = "deflate64" version = "0.1.9" @@ -1127,6 +1932,17 @@ dependencies = [ "serde", ] +[[package]] +name = "derive-new" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d150dea618e920167e5973d70ae6ece4385b7164e0d799fe7c122dd0a5d912ad" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", +] + [[package]] name = "derive_arbitrary" version = "1.3.2" @@ -1138,38 +1954,87 @@ dependencies = [ "syn 2.0.79", ] +[[package]] +name = "derive_builder" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d67778784b508018359cbc8696edb3db78160bab2c2a28ba7f56ef6932997f8" +dependencies = [ + "derive_builder_macro", +] + +[[package]] +name = "derive_builder_core" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c11bdc11a0c47bc7d37d582b5285da6849c96681023680b906673c5707af7b0f" +dependencies = [ + "darling 0.14.4", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "derive_builder_macro" +version = "0.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebcda35c7a396850a55ffeac740804b40ffec779b98fffbb1738f4033f0ee79e" +dependencies = [ + "derive_builder_core", + "syn 1.0.109", +] + [[package]] name = "derive_more" version = "0.99.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5f33878137e4dafd7fa914ad4e259e18a4e8e532b9617a2d0150262bf53abfce" dependencies = [ - "convert_case", + "convert_case 0.4.0", "proc-macro2", "quote", "rustc_version", "syn 2.0.79", ] +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", +] + [[package]] name = "digest" version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "const-oid", "crypto-common", "subtle", ] +[[package]] +name = "dirs" +version = "4.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059" +dependencies = [ + "dirs-sys 0.3.7", +] + [[package]] name = "dirs" version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" dependencies = [ - "dirs-sys", + "dirs-sys 0.4.1", ] [[package]] @@ -1182,6 +2047,17 @@ dependencies = [ "dirs-sys-next", ] +[[package]] +name = "dirs-sys" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "dirs-sys" version = "0.4.1" @@ -1258,6 +2134,12 @@ dependencies = [ "syn 2.0.79", ] +[[package]] +name = "dlv-list" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0688c2a7f92e427f44895cd63841bff7b29f8d7a1648b9e7e07a4a365b2e1257" + [[package]] name = "dlv-list" version = "0.5.2" @@ -1316,7 +2198,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", @@ -1340,7 +2222,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", @@ -1352,6 +2234,22 @@ dependencies = [ "zeroize", ] +[[package]] +name = "email-encoding" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60d1d33cdaede7e24091f039632eb5d3c7469fe5b066a985281a34fc70fa317f" +dependencies = [ + "base64 0.22.1", + "memchr", +] + +[[package]] +name = "email_address" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e079f19b08ca6239f47f8ba8509c11cf3ea30095831f7fed61441475edd8c449" + [[package]] name = "embed-resource" version = "2.5.0" @@ -1363,7 +2261,7 @@ dependencies = [ "rustc_version", "toml 0.8.19", "vswhom", - "winreg", + "winreg 0.52.0", ] [[package]] @@ -1378,6 +2276,12 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a357d28ed41a50f9c765dbfe56cbc04a64e53e5fc58ba79fbc34c10ef3df831f" +[[package]] +name = "encode_unicode" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" + [[package]] name = "encoding_rs" version = "0.8.34" @@ -1414,6 +2318,19 @@ dependencies = [ "syn 2.0.79", ] +[[package]] +name = "env_logger" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd405aab171cb85d6735e5c8d9db038c17d3ca007a4d2c25f337935c3d90580" +dependencies = [ + "humantime", + "is-terminal", + "log", + "regex", + "termcolor", +] + [[package]] name = "equivalent" version = "1.0.1" @@ -1451,6 +2368,12 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "event-listener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0206175f82b8d6bf6652ff7d71a1e27fd2e4efde587fd368662814d6ec1d9ce0" + [[package]] name = "event-listener" version = "5.3.1" @@ -1468,10 +2391,45 @@ version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0f214dc438f977e6d4e3500aaa277f5ad94ca83fbbd9b1a15713ce2344ccc5a1" dependencies = [ - "event-listener", + "event-listener 5.3.1", "pin-project-lite", ] +[[package]] +name = "exr" +version = "1.72.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "887d93f60543e9a9362ef8a21beedd0a833c5d9610e18c67abe15a5963dcb1a4" +dependencies = [ + "bit_field", + "flume", + "half", + "lebe", + "miniz_oxide 0.7.4", + "rayon-core", + "smallvec", + "zune-inflate", +] + +[[package]] +name = "fancy-regex" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b95f7c0680e4142284cf8b22c14a476e87d61b004a3a0861872b32ef7ead40a2" +dependencies = [ + "bit-set", + "regex", +] + +[[package]] +name = "fastrand" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e51093e27b0797c359783294ca4f0a911c270184cb10f85783b118614a1501be" +dependencies = [ + "instant", +] + [[package]] name = "fastrand" version = "2.1.1" @@ -1519,6 +2477,18 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "findshlibs" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40b9e59cd0f7e0806cca4be089683ecb6434e602038df21fe6bf6711b2f07f64" +dependencies = [ + "cc", + "lazy_static", + "libc", + "winapi", +] + [[package]] name = "flate2" version = "1.0.34" @@ -1526,7 +2496,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1b589b4dc103969ad3cf85c950899926ec64300a1a46d76c03a6072957036f0" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.8.0", ] [[package]] @@ -1546,7 +2516,7 @@ checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181" dependencies = [ "futures-core", "futures-sink", - "spin", + "spin 0.9.8", ] [[package]] @@ -1615,6 +2585,12 @@ dependencies = [ "libc", ] +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + [[package]] name = "futf" version = "0.1.5" @@ -1684,13 +2660,28 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" +[[package]] +name = "futures-lite" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49a9d51ce47660b1e808d3c990b4709f2f415d928835a17dfd16991515c46bce" +dependencies = [ + "fastrand 1.9.0", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + [[package]] name = "futures-lite" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52527eb5074e35e9339c6b4e8d12600c7128b68fb25dcb9fa9dec18f7c25f3a5" dependencies = [ - "fastrand", + "fastrand 2.1.1", "futures-core", "futures-io", "parking", @@ -1720,6 +2711,12 @@ version = "0.3.30" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" +[[package]] +name = "futures-timer" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f288b0a4f20f9a56b5d1da57e2227c661b7b16168e2f72365f57b63326e29b24" + [[package]] name = "futures-util" version = "0.3.30" @@ -1889,6 +2886,16 @@ dependencies = [ "wasi 0.11.0+wasi-snapshot-preview1", ] +[[package]] +name = "gif" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gimli" version = "0.31.0" @@ -1991,6 +2998,26 @@ dependencies = [ "system-deps", ] +[[package]] +name = "governor" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68a7f542ee6b35af73b06abc0dad1c1bae89964e4e253bc4b587b91c9637867b" +dependencies = [ + "cfg-if", + "dashmap 5.5.3", + "futures", + "futures-timer", + "no-std-compat", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.8.5", + "smallvec", + "spinning_top", +] + [[package]] name = "group" version = "0.13.0" @@ -2054,6 +3081,25 @@ dependencies = [ "syn 2.0.79", ] +[[package]] +name = "h2" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81fe527a889e1532da5c525686d96d4c2e74cdd345badf8dfef9f6b39dd5f5e8" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap 2.5.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "h2" version = "0.4.6" @@ -2065,7 +3111,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.1.0", "indexmap 2.5.0", "slab", "tokio", @@ -2073,11 +3119,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dd08c532ae367adf81c312a4580bc67f1d0fe8bc9c460520283f4c0ff277888" +dependencies = [ + "cfg-if", + "crunchy", +] + [[package]] name = "hashbrown" version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +dependencies = [ + "ahash 0.7.8", +] [[package]] name = "hashbrown" @@ -2085,7 +3144,7 @@ version = "0.14.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" dependencies = [ - "ahash", + "ahash 0.8.11", "allocator-api2", ] @@ -2098,6 +3157,15 @@ dependencies = [ "hashbrown 0.14.5", ] +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] + [[package]] name = "heck" version = "0.4.1" @@ -2134,7 +3202,17 @@ version = "0.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7" dependencies = [ - "hmac", + "hmac 0.12.1", +] + +[[package]] +name = "hmac" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a2a2320eb7ec0ebe8da8f744d7812d9fc4cb4d09344ac01898dbcb6a20ae69b" +dependencies = [ + "crypto-mac", + "digest 0.9.0", ] [[package]] @@ -2143,7 +3221,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -2156,8 +3234,19 @@ dependencies = [ ] [[package]] -name = "html5ever" -version = "0.26.0" +name = "hostname" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9c7c7c8ac16c798734b8a24560c1362120597c40d5e1459f09498f8f6c8f2ba" +dependencies = [ + "cfg-if", + "libc", + "windows 0.52.0", +] + +[[package]] +name = "html5ever" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bea68cab48b8459f17cf1c944c67ddc572d272d9f2b274140f223ecb1da4a3b7" dependencies = [ @@ -2169,6 +3258,17 @@ dependencies = [ "syn 1.0.109", ] +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa 1.0.11", +] + [[package]] name = "http" version = "1.1.0" @@ -2180,6 +3280,17 @@ dependencies = [ "itoa 1.0.11", ] +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + [[package]] name = "http-body" version = "1.0.1" @@ -2187,7 +3298,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.1.0", ] [[package]] @@ -2198,8 +3309,8 @@ checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ "bytes", "futures-util", - "http", - "http-body", + "http 1.1.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -2209,12 +3320,69 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" +[[package]] +name = "http-types" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9b187a72d63adbfba487f48095306ac823049cb504ee195541e91c7775f5ad" +dependencies = [ + "anyhow", + "async-channel 1.9.0", + "base64 0.13.1", + "futures-lite 1.13.0", + "http 0.2.12", + "infer 0.2.3", + "pin-project-lite", + "rand 0.7.3", + "serde", + "serde_json", + "serde_qs 0.8.5", + "serde_urlencoded", + "url", +] + [[package]] name = "httparse" version = "1.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fcc0b4a115bf80b728eb8ea024ad5bd707b615bfed49e0665b6e0f86fd082d9" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "humantime" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a3a5bfb195931eeb336b2a7b4d761daec841b97f947d34394601737a7bba5e4" + +[[package]] +name = "hyper" +version = "0.14.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c08302e8fa335b151b788c775ff56e7a03ae64ff85c548ee820fecb70356e85" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2 0.3.26", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa 1.0.11", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", + "want", +] + [[package]] name = "hyper" version = "1.4.1" @@ -2224,9 +3392,9 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "h2", - "http", - "http-body", + "h2 0.4.6", + "http 1.1.0", + "http-body 1.0.1", "httparse", "itoa 1.0.11", "pin-project-lite", @@ -2235,6 +3403,22 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.24.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec3efd23720e2049821a693cbc7e65ea87c72f1c58ff2f9522ff332b1491e590" +dependencies = [ + "futures-util", + "http 0.2.12", + "hyper 0.14.31", + "log", + "rustls 0.21.12", + "rustls-native-certs", + "tokio", + "tokio-rustls 0.24.1", +] + [[package]] name = "hyper-rustls" version = "0.27.3" @@ -2242,17 +3426,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" dependencies = [ "futures-util", - "http", - "hyper", + "http 1.1.0", + "hyper 1.4.1", "hyper-util", - "rustls", + "rustls 0.23.13", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.0", "tower-service", "webpki-roots", ] +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.31", + "native-tls", + "tokio", + "tokio-native-tls", +] + [[package]] name = "hyper-tls" version = "0.6.0" @@ -2261,7 +3458,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.4.1", "hyper-util", "native-tls", "tokio", @@ -2278,9 +3475,9 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.1.0", + "http-body 1.0.1", + "hyper 1.4.1", "pin-project-lite", "socket2", "tokio", @@ -2321,12 +3518,140 @@ dependencies = [ "png", ] +[[package]] +name = "icu_collections" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db2fa452206ebee18c4b5c2274dbf1de17008e874b4dc4f0aea9d01ca79e4526" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locid" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13acbb8371917fc971be86fc8057c41a64b521c184808a698c02acc242dbf637" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_locid_transform" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01d11ac35de8e40fdeda00d9e1e9d92525f3f9d887cdd7aa81d727596788b54e" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_locid_transform_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_locid_transform_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdc8ff3388f852bede6b579ad4e978ab004f139284d7b28715f773507b946f6e" + +[[package]] +name = "icu_normalizer" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19ce3e0da2ec68599d193c93d088142efd7f9c5d6fc9b803774855747dc6a84f" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "utf16_iter", + "utf8_iter", + "write16", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8cafbf7aa791e9b22bec55a167906f9e1215fd475cd22adfcf660e03e989516" + +[[package]] +name = "icu_properties" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93d6020766cfc6302c15dbbc9c8778c37e62c14427cb7f6e601d849e092aeef5" +dependencies = [ + "displaydoc", + "icu_collections", + "icu_locid_transform", + "icu_properties_data", + "icu_provider", + "tinystr", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a8effbc3dd3e4ba1afa8ad918d5684b8868b3b26500753effea8d2eed19569" + +[[package]] +name = "icu_provider" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ed421c8a8ef78d3e2dbc98a973be2f3770cb42b606e3ab18d6237c4dfde68d9" +dependencies = [ + "displaydoc", + "icu_locid", + "icu_provider_macros", + "stable_deref_trait", + "tinystr", + "writeable", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_provider_macros" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ec89e9337638ecdc08744df490b221a7399bf8d164eb52a665454e60e075ad6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", +] + [[package]] name = "ident_case" version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" +[[package]] +name = "idna" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d20d6b07bfbc108882d88ed8e37d39636dcc260e15e30c45e6ba089610b917c" +dependencies = [ + "unicode-bidi", + "unicode-normalization", +] + [[package]] name = "idna" version = "0.5.0" @@ -2337,6 +3662,59 @@ dependencies = [ "unicode-normalization", ] +[[package]] +name = "idna" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd69211b9b519e98303c015e21a007e293db403b6c85b9b124e133d25e242cdd" +dependencies = [ + "icu_normalizer", + "icu_properties", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "if_chain" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb56e1aa765b4b4f3aadfab769793b7087bb03a4ea4920644a6d238e2df5b9ed" + +[[package]] +name = "image" +version = "0.24.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5690139d2f55868e080017335e4b94cb7414274c74f1669c84fb5feba2c9f69d" +dependencies = [ + "bytemuck", + "byteorder", + "color_quant", + "exr", + "gif", + "jpeg-decoder", + "num-traits", + "png", + "qoi", + "tiff", +] + +[[package]] +name = "image" +version = "0.25.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d97eb9a8e0cd5b76afea91d7eecd5cf8338cd44ced04256cf1f800474b227c52" +dependencies = [ + "bytemuck", + "byteorder-lite", + "num-traits", +] + +[[package]] +name = "impl-more" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aae21c3177a27788957044151cc2800043d127acaa460a47ebb9b84dfa2c6aa0" + [[package]] name = "indexmap" version = "1.9.3" @@ -2372,6 +3750,12 @@ dependencies = [ "unicode-width", ] +[[package]] +name = "infer" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "64e9829a50b42bb782c1df523f78d332fe371b10c661e78b7a3c34b0198e9fac" + [[package]] name = "infer" version = "0.16.0" @@ -2425,6 +3809,15 @@ version = "2.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "187674a687eed5fe42285b40c6291f9a01517d415fad1c3cbc6a9f778af7fcd4" +[[package]] +name = "ipnetwork" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf466541e9d546596ee94f9f69590f89473455f88372423e0008fc1a7daf100e" +dependencies = [ + "serde", +] + [[package]] name = "is-docker" version = "0.2.0" @@ -2434,6 +3827,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "is-terminal" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "261f68e344040fbd0edea105bef17c66edf46f984ddb1115b775ce31be948f4b" +dependencies = [ + "hermit-abi 0.4.0", + "libc", + "windows-sys 0.52.0", +] + [[package]] name = "is-wsl" version = "0.4.0" @@ -2444,6 +3848,51 @@ dependencies = [ "once_cell", ] +[[package]] +name = "isahc" +version = "1.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "334e04b4d781f436dc315cb1e7515bd96826426345d498149e4bde36b67f8ee9" +dependencies = [ + "async-channel 1.9.0", + "castaway", + "crossbeam-utils", + "curl", + "curl-sys", + "encoding_rs", + "event-listener 2.5.3", + "futures-lite 1.13.0", + "http 0.2.12", + "log", + "mime", + "once_cell", + "polling 2.8.0", + "slab", + "sluice", + "tracing", + "tracing-futures", + "url", + "waker-fn", +] + +[[package]] +name = "iso8601" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924e5d73ea28f59011fec52a0d12185d496a9b075d360657aed2a5707f701153" +dependencies = [ + "nom", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + [[package]] name = "itertools" version = "0.11.0" @@ -2453,6 +3902,15 @@ dependencies = [ "either", ] +[[package]] +name = "itertools" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba291022dbbd398a455acf126c1e341954079855bc60dfdda641363bd6922569" +dependencies = [ + "either", +] + [[package]] name = "itoa" version = "0.4.8" @@ -2489,17 +3947,37 @@ dependencies = [ ] [[package]] -name = "jni" -version = "0.21.1" +name = "jemalloc-sys" +version = "0.5.4+5.3.0-patched" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "ac6c1946e1cea1788cbfde01c993b52a10e2da07f4bac608228d1bed20bfebf2" dependencies = [ - "cesu8", - "cfg-if", - "combine", - "jni-sys", - "log", - "thiserror", + "cc", + "libc", +] + +[[package]] +name = "jemallocator" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0de374a9f8e63150e6f5e8a60cc14c668226d7a347d8aee1a45766e3c4dd3bc" +dependencies = [ + "jemalloc-sys", + "libc", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys", + "log", + "thiserror", "walkdir", "windows-sys 0.45.0", ] @@ -2519,6 +3997,15 @@ dependencies = [ "libc", ] +[[package]] +name = "jpeg-decoder" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" +dependencies = [ + "rayon", +] + [[package]] name = "js-sys" version = "0.3.70" @@ -2551,6 +4038,18 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonwebtoken" +version = "8.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6971da4d9c3aa03c3d8f3ff0f4155b534aad021292003895a469716b2a230378" +dependencies = [ + "base64 0.21.7", + "ring 0.16.20", + "serde", + "serde_json", +] + [[package]] name = "keyboard-types" version = "0.7.0" @@ -2595,13 +4094,132 @@ dependencies = [ "selectors", ] +[[package]] +name = "labrinth" +version = "2.7.0" +dependencies = [ + "actix-cors", + "actix-files", + "actix-http", + "actix-multipart", + "actix-rt", + "actix-web", + "actix-web-prom", + "actix-ws", + "argon2", + "async-stripe", + "async-trait", + "base64 0.21.7", + "bitflags 2.6.0", + "bytes", + "censor", + "chrono", + "clickhouse", + "color-thief", + "dashmap 5.5.3", + "deadpool-redis", + "derive-new", + "dotenvy", + "env_logger", + "flate2", + "futures", + "futures-timer", + "futures-util", + "governor", + "hex", + "hmac 0.11.0", + "hyper 0.14.31", + "hyper-tls 0.5.0", + "image 0.24.9", + "itertools 0.12.1", + "jemallocator", + "json-patch", + "lazy_static", + "lettre", + "log", + "maxminddb", + "meilisearch-sdk", + "murmur2", + "rand 0.8.5", + "rand_chacha 0.3.1", + "redis", + "regex", + "reqwest 0.11.27", + "rust-s3", + "rust_decimal", + "rust_iso3166", + "rusty-money", + "sentry", + "sentry-actix", + "serde", + "serde_json", + "serde_with", + "sha1 0.6.1", + "sha2 0.9.9", + "spdx", + "sqlx", + "tar", + "thiserror", + "tokio", + "tokio-stream", + "totp-rs", + "url", + "urlencoding", + "uuid 1.10.0", + "validator", + "webp", + "woothee", + "xml-rs", + "yaserde", + "yaserde_derive", + "zip 0.6.6", + "zxcvbn", +] + +[[package]] +name = "language-tags" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4345964bb142484797b161f473a503a434de77149dd8c7427788c6e13379388" + [[package]] name = "lazy_static" version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" dependencies = [ - "spin", + "spin 0.9.8", +] + +[[package]] +name = "lebe" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03087c2bad5e1034e8cace5926dec053fb3790248370865f5117a7d0213354c8" + +[[package]] +name = "lettre" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f204773bab09b150320ea1c83db41dc6ee606a4bc36dc1f43005fe7b58ce06" +dependencies = [ + "base64 0.22.1", + "chumsky", + "email-encoding", + "email_address", + "fastrand 2.1.1", + "futures-util", + "hostname", + "httpdate", + "idna 1.0.2", + "mime", + "native-tls", + "nom", + "percent-encoding", + "quoted_printable", + "socket2", + "tokio", + "url", ] [[package]] @@ -2660,6 +4278,16 @@ version = "0.2.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ec2a862134d2a7d32d7983ddcdd1c4923530833c9f2ea1a44fc5fa473989058" +[[package]] +name = "libnghttp2-sys" +version = "0.1.10+1.61.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "959c25552127d2e1fa72f0e52548ec04fc386e827ba71a7bd01db46a447dc135" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "libredox" version = "0.1.3" @@ -2682,12 +4310,63 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "libwebp-sys" +version = "0.9.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "54cd30df7c7165ce74a456e4ca9732c603e8dc5e60784558c1c6dc047f876733" +dependencies = [ + "cc", + "glob", +] + +[[package]] +name = "libz-sys" +version = "1.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2d16453e800a8cf6dd2fc3eb4bc99b786a9b90c663b8559a5b1a041bf89e472" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.4.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78b3ae25bc7c8c38cec158d1f2757ee79e9b3740fbc7ccf0e59e4b08d793fa89" +[[package]] +name = "litemap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "643cb0b8d4fcc284004d5fd0d67ccf61dfffadb7f75e1e71bc420f4688a3a704" + +[[package]] +name = "local-channel" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6cbc85e69b8df4b8bb8b89ec634e7189099cea8927a276b7384ce5488e53ec8" +dependencies = [ + "futures-core", + "futures-sink", + "local-waker", +] + +[[package]] +name = "local-waker" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d873d7c67ce09b42110d801813efbc9364414e356be9935700d368351657487" + [[package]] name = "lock_api" version = "0.4.12" @@ -2704,6 +4383,34 @@ version = "0.4.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7a70ba024b9dc04c27ea2f0c0548feb474ec5c54bba33a7f72f873a39d07b24" +[[package]] +name = "lru-cache" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31e24f1ad8321ca0e8a1e0ac13f23cb668e6f5466c2c57319f6a5cf1cc8e3b1c" +dependencies = [ + "linked-hash-map", +] + +[[package]] +name = "lz4" +version = "1.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d1febb2b4a79ddd1980eede06a8f7902197960aa0383ffcfdd62fe723036725" +dependencies = [ + "lz4-sys", +] + +[[package]] +name = "lz4-sys" +version = "1.11.1+lz4-1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" +dependencies = [ + "cc", + "libc", +] + [[package]] name = "mac" version = "0.1.1" @@ -2748,6 +4455,29 @@ version = "0.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" +[[package]] +name = "maxminddb" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6087e5d8ea14861bb7c7f573afbc7be3798d3ef0fae87ec4fd9a4de9a127c3c" +dependencies = [ + "ipnetwork", + "log", + "memchr", + "serde", +] + +[[package]] +name = "maybe-async" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cf92c10c7e361d6b99666ec1c6f9805b0bea2c3bd8c78dc6fe98ac5bd78db11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", +] + [[package]] name = "md-5" version = "0.10.6" @@ -2755,7 +4485,52 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf" dependencies = [ "cfg-if", - "digest", + "digest 0.10.7", +] + +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + +[[package]] +name = "meilisearch-index-setting-macro" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b1f2124b55b9cb28e6a08b28854f4e834a51333cbdc2f72935f401efa686c13c" +dependencies = [ + "convert_case 0.6.0", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "meilisearch-sdk" +version = "0.24.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2257ea8ed24b079c21570f473e58cccc3de23b46cee331fc513fccdc3f1ae5a1" +dependencies = [ + "async-trait", + "either", + "futures", + "futures-io", + "isahc", + "iso8601", + "js-sys", + "jsonwebtoken", + "log", + "meilisearch-index-setting-macro", + "serde", + "serde_json", + "thiserror", + "time", + "uuid 1.10.0", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "yaup", ] [[package]] @@ -2779,6 +4554,25 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minidom" +version = "0.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f45614075738ce1b77a1768912a60c0227525971b03e09122a05b8a34a2a6278" +dependencies = [ + "rxml", +] + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -2791,6 +4585,15 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a05b5d0594e0cb1ad8cee3373018d2b84e25905dc75b2468114cc9a8e86cfc20" +[[package]] +name = "miniz_oxide" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8a240ddb74feaf34a79a7add65a741f3167852fba007066dcac1ca548d89c08" +dependencies = [ + "adler", +] + [[package]] name = "miniz_oxide" version = "0.8.0" @@ -2821,6 +4624,7 @@ checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ "hermit-abi 0.3.9", "libc", + "log", "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.52.0", ] @@ -2845,6 +4649,12 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "murmur2" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb585ade2549a017db2e35978b77c319214fa4b37cede841e27954dd6e8f3ca8" + [[package]] name = "native-dialog" version = "0.7.0" @@ -2934,6 +4744,12 @@ dependencies = [ "memoffset", ] +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" + [[package]] name = "nodrop" version = "0.1.14" @@ -2950,6 +4766,12 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + [[package]] name = "normpath" version = "1.3.0" @@ -3007,6 +4829,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "num-bigint" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint-dig" version = "0.8.4" @@ -3060,6 +4892,16 @@ dependencies = [ "libm", ] +[[package]] +name = "num_cpus" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4161fcb6d602d4d2081af7c3a45852d875a03dd337a6bfdd6e06407b61342a43" +dependencies = [ + "hermit-abi 0.3.9", + "libc", +] + [[package]] name = "num_enum" version = "0.7.3" @@ -3243,6 +5085,18 @@ dependencies = [ "portable-atomic", ] +[[package]] +name = "oncemutex" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d11de466f4a3006fe8a5e7ec84e93b79c70cb992ae0aa0eb631ad2df8abfe2" + +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.3.0" @@ -3327,13 +5181,23 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "ordered-multimap" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccd746e37177e1711c20dd619a1620f34f5c8b569c53590a72dedd5344d8924a" +dependencies = [ + "dlv-list 0.3.0", + "hashbrown 0.12.3", +] + [[package]] name = "ordered-multimap" version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" dependencies = [ - "dlv-list", + "dlv-list 0.5.2", "hashbrown 0.14.5", ] @@ -3383,7 +5247,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.8", ] [[package]] @@ -3440,6 +5304,12 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "parse-size" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "487f2ccd1e17ce8c1bfab3a65c89525af41cfad4c8659021a1e9a2aacd73b89b" + [[package]] name = "password-hash" version = "0.4.2" @@ -3451,6 +5321,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "paste" version = "1.0.15" @@ -3469,10 +5350,10 @@ version = "0.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83a0692ec44e4cf1ef28ca317f14f8f07da2d95ec3fa01f86e4467b725e60917" dependencies = [ - "digest", - "hmac", - "password-hash", - "sha2", + "digest 0.10.7", + "hmac 0.12.1", + "password-hash 0.4.2", + "sha2 0.10.8", ] [[package]] @@ -3624,6 +5505,27 @@ dependencies = [ "siphasher", ] +[[package]] +name = "phonenumber" +version = "0.3.6+8.13.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11756237b57b8cc5e97dc8b1e70ea436324d30e7075de63b14fd15073a8f692a" +dependencies = [ + "bincode", + "either", + "fnv", + "itertools 0.12.1", + "lazy_static", + "nom", + "quick-xml 0.31.0", + "regex", + "regex-cache", + "serde", + "serde_derive", + "strum", + "thiserror", +] + [[package]] name = "pin-project" version = "1.1.5" @@ -3663,7 +5565,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" dependencies = [ "atomic-waker", - "fastrand", + "fastrand 2.1.1", "futures-io", ] @@ -3702,7 +5604,7 @@ checksum = "42cf17e9a1800f5f396bc67d193dc9411b59012a5876445ef450d449881e1016" dependencies = [ "base64 0.22.1", "indexmap 2.5.0", - "quick-xml", + "quick-xml 0.32.0", "serde", "time", ] @@ -3717,7 +5619,23 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.0", +] + +[[package]] +name = "polling" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b2d323e8ca7996b3e23126511a523f7e62924d93ecd5ae73b333815b0eb3dce" +dependencies = [ + "autocfg", + "bitflags 1.3.2", + "cfg-if", + "concurrent-queue", + "libc", + "log", + "pin-project-lite", + "windows-sys 0.48.0", ] [[package]] @@ -3762,6 +5680,20 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" +[[package]] +name = "prettytable-rs" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a" +dependencies = [ + "csv", + "encode_unicode 1.0.0", + "is-terminal", + "lazy_static", + "term", + "unicode-width", +] + [[package]] name = "primeorder" version = "0.13.6" @@ -3838,6 +5770,123 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "procfs" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "731e0d9356b0c25f16f33b5be79b1c57b562f141ebfcdb0ad8ac2c13a24293b4" +dependencies = [ + "bitflags 2.6.0", + "hex", + "lazy_static", + "procfs-core", + "rustix", +] + +[[package]] +name = "procfs-core" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d3554923a69f4ce04c4a754260c338f505ce22642d3830e049a399fc2059a29" +dependencies = [ + "bitflags 2.6.0", + "hex", +] + +[[package]] +name = "prometheus" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d33c28a30771f7f96db69893f78b857f7450d7e0237e9c8fc6427a81bae7ed1" +dependencies = [ + "cfg-if", + "fnv", + "lazy_static", + "libc", + "memchr", + "parking_lot", + "procfs", + "thiserror", +] + +[[package]] +name = "psm" +version = "0.1.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa37f80ca58604976033fae9515a8a2989fc13797d953f7c04fb8fa36a11f205" +dependencies = [ + "cc", +] + +[[package]] +name = "ptr_meta" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0738ccf7ea06b608c10564b31debd4f5bc5e197fc8bfe088f68ae5ce81e7a4f1" +dependencies = [ + "ptr_meta_derive", +] + +[[package]] +name = "ptr_meta_derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16b845dbfca988fa33db069c0e230574d15a3088f147a87b64c7589eb662c9ac" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "qoi" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f6d64c71eb498fe9eae14ce4ec935c555749aef511cca85b5568910d6e48001" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "quanta" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5167a477619228a0b284fac2674e3c388cba90631d7b7de620e6f1fcd08da5" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi 0.11.0+wasi-snapshot-preview1", + "web-sys", + "winapi", +] + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f50b1c63b38611e7d4d7f68b82d3ad0cc71a2ad2e7f61fc10f1328d917c93cd" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "quick-xml" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1004a344b30a54e2ee58d66a71b32d2db2feb0a31f9a2d302bf0536f15de2a33" +dependencies = [ + "memchr", +] + [[package]] name = "quick-xml" version = "0.32.0" @@ -3858,7 +5907,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls", + "rustls 0.23.13", "socket2", "thiserror", "tokio", @@ -3873,9 +5922,9 @@ checksum = "fadfaed2cd7f389d0161bb73eeb07b7b78f8691047a6f3e73caaeae55310a4a6" dependencies = [ "bytes", "rand 0.8.5", - "ring", + "ring 0.17.8", "rustc-hash", - "rustls", + "rustls 0.23.13", "slab", "thiserror", "tinyvec", @@ -3904,6 +5953,29 @@ dependencies = [ "proc-macro2", ] +[[package]] +name = "quoted_printable" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "640c9bd8497b02465aeef5375144c26062e0dcd5939dfcbb0f5db76cb8c17c73" + +[[package]] +name = "r2d2" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51de85fb3fb6524929c8a2eb85e6b6d363de4e8c48f9e2c2eac4944abc181c93" +dependencies = [ + "log", + "parking_lot", + "scheduled-thread-pool", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + [[package]] name = "rand" version = "0.7.3" @@ -3985,6 +6057,15 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "raw-cpuid" +version = "11.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ab240315c661615f2ee9f0f2cd32d5a7343a84d5ebcccb99d46e6637565e7b0" +dependencies = [ + "bitflags 2.6.0", +] + [[package]] name = "raw-window-handle" version = "0.5.2" @@ -4017,6 +6098,31 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "redis" +version = "0.27.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "81cccf17a692ce51b86564334614d72dcae1def0fd5ecebc9f02956da74352b5" +dependencies = [ + "ahash 0.8.11", + "arc-swap", + "async-trait", + "bytes", + "combine", + "futures-util", + "itoa 1.0.11", + "num-bigint", + "percent-encoding", + "pin-project-lite", + "r2d2", + "ryu", + "sha1_smol", + "socket2", + "tokio", + "tokio-util", + "url", +] + [[package]] name = "redox_syscall" version = "0.5.6" @@ -4069,6 +6175,24 @@ dependencies = [ "regex-syntax 0.8.5", ] +[[package]] +name = "regex-cache" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f7b62d69743b8b94f353b6b7c3deb4c5582828328bcb8d5fedf214373808793" +dependencies = [ + "lru-cache", + "oncemutex", + "regex", + "regex-syntax 0.6.29", +] + +[[package]] +name = "regex-lite" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a49587ad06b26609c52e423de037e7f57f20d53535d66e08c695f347df952a" + [[package]] name = "regex-syntax" version = "0.6.29" @@ -4081,6 +6205,58 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "rend" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "71fe3824f5629716b1589be05dacd749f6aa084c87e00e016714a8cdfccc997c" +dependencies = [ + "bytecheck", +] + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64 0.21.7", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2 0.3.26", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.31", + "hyper-tls 0.5.0", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile 1.0.4", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration 0.5.1", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "winreg 0.50.0", +] + [[package]] name = "reqwest" version = "0.12.7" @@ -4091,15 +6267,16 @@ dependencies = [ "base64 0.22.1", "bytes", "encoding_rs", + "futures-channel", "futures-core", "futures-util", - "h2", - "http", - "http-body", + "h2 0.4.6", + "http 1.1.0", + "http-body 1.0.1", "http-body-util", - "hyper", - "hyper-rustls", - "hyper-tls", + "hyper 1.4.1", + "hyper-rustls 0.27.3", + "hyper-tls 0.6.0", "hyper-util", "ipnet", "js-sys", @@ -4110,17 +6287,17 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls", - "rustls-pemfile", + "rustls 0.23.13", + "rustls-pemfile 2.1.3", "rustls-pki-types", "serde", "serde_json", "serde_urlencoded", - "sync_wrapper", - "system-configuration", + "sync_wrapper 1.0.1", + "system-configuration 0.6.1", "tokio", "tokio-native-tls", - "tokio-rustls", + "tokio-rustls 0.26.0", "tokio-util", "tower-service", "url", @@ -4144,7 +6321,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] @@ -4172,6 +6349,30 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "rgb" +version = "0.8.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a" +dependencies = [ + "bytemuck", +] + +[[package]] +name = "ring" +version = "0.16.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3053cf52e236a3ed746dfc745aa9cacf1b791d846bdaf412f60a8d7d6e17c8fc" +dependencies = [ + "cc", + "libc", + "once_cell", + "spin 0.5.2", + "untrusted 0.7.1", + "web-sys", + "winapi", +] + [[package]] name = "ring" version = "0.17.8" @@ -4182,11 +6383,40 @@ dependencies = [ "cfg-if", "getrandom 0.2.15", "libc", - "spin", - "untrusted", + "spin 0.9.8", + "untrusted 0.9.0", "windows-sys 0.52.0", ] +[[package]] +name = "rkyv" +version = "0.7.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9008cd6385b9e161d8229e1f6549dd23c3d022f132a2ea37ac3a10ac4935779b" +dependencies = [ + "bitvec", + "bytecheck", + "bytes", + "hashbrown 0.12.3", + "ptr_meta", + "rend", + "rkyv_derive", + "seahash", + "tinyvec", + "uuid 1.10.0", +] + +[[package]] +name = "rkyv_derive" +version = "0.7.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503d1d27590a2b0a3a4ca4c94755aa2875657196ecbf401a42eff41d7de532c0" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "rsa" version = "0.9.6" @@ -4194,7 +6424,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d0e5124fcb30e76a7e79bfee683a2746db83784b86289f6251b54b7950a0dfc" dependencies = [ "const-oid", - "digest", + "digest 0.10.7", "num-bigint-dig", "num-integer", "num-traits", @@ -4207,6 +6437,16 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rust-ini" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6d5f2436026b4f6e79dc829837d467cc7e9a55ee40e750d716713540715a2df" +dependencies = [ + "cfg-if", + "ordered-multimap 0.4.3", +] + [[package]] name = "rust-ini" version = "0.21.1" @@ -4214,10 +6454,81 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e310ef0e1b6eeb79169a1171daf9abcb87a2e17c03bee2c4bb100b55c75409f" dependencies = [ "cfg-if", - "ordered-multimap", + "ordered-multimap 0.7.3", "trim-in-place", ] +[[package]] +name = "rust-s3" +version = "0.33.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b2ac5ff6acfbe74226fa701b5ef793aaa054055c13ebb7060ad36942956e027" +dependencies = [ + "async-trait", + "aws-creds", + "aws-region", + "base64 0.13.1", + "bytes", + "cfg-if", + "futures", + "hex", + "hmac 0.12.1", + "http 0.2.12", + "log", + "maybe-async", + "md5", + "minidom", + "percent-encoding", + "quick-xml 0.26.0", + "reqwest 0.11.27", + "serde", + "serde_derive", + "sha2 0.10.8", + "thiserror", + "time", + "tokio", + "tokio-stream", + "url", +] + +[[package]] +name = "rust_decimal" +version = "1.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b082d80e3e3cc52b2ed634388d436fe1f4de6af5786cc2de9ba9737527bdf555" +dependencies = [ + "arrayvec", + "borsh", + "bytes", + "num-traits", + "rand 0.8.5", + "rkyv", + "serde", + "serde_json", +] + +[[package]] +name = "rust_decimal_macros" +version = "1.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da991f231869f34268415a49724c6578e740ad697ba0999199d6f22b3949332c" +dependencies = [ + "quote", + "rust_decimal", +] + +[[package]] +name = "rust_iso3166" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd3126eab517ef8ca4761a366cb0d55e1bf5ab9c7b7f18301d712a57de000a90" +dependencies = [ + "js-sys", + "phf 0.11.2", + "prettytable-rs", + "wasm-bindgen", +] + [[package]] name = "rustc-demangle" version = "0.1.24" @@ -4252,6 +6563,18 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +name = "rustls" +version = "0.21.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring 0.17.8", + "rustls-webpki 0.101.7", + "sct", +] + [[package]] name = "rustls" version = "0.23.13" @@ -4259,13 +6582,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2dabaac7466917e566adb06783a81ca48944c6898a1b08b9374106dd671f4c8" dependencies = [ "once_cell", - "ring", + "ring 0.17.8", "rustls-pki-types", - "rustls-webpki", + "rustls-webpki 0.102.8", "subtle", "zeroize", ] +[[package]] +name = "rustls-native-certs" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9aace74cb666635c918e9c12bc0d348266037aa8eb599b5cba565709a8dff00" +dependencies = [ + "openssl-probe", + "rustls-pemfile 1.0.4", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64 0.21.7", +] + [[package]] name = "rustls-pemfile" version = "2.1.3" @@ -4282,17 +6626,60 @@ version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0e696e35370c65c9c541198af4543ccd580cf17fc25d8e05c5a242b202488c55" +[[package]] +name = "rustls-webpki" +version = "0.101.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b6275d1ee7a1cd780b64aca7726599a1dbc893b1e64144529e55c3c2f745765" +dependencies = [ + "ring 0.17.8", + "untrusted 0.9.0", +] + [[package]] name = "rustls-webpki" version = "0.102.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "64ca1bc8749bd4cf37b5ce386cc146580777b4e8572c7b97baf22c83f444bee9" dependencies = [ - "ring", + "ring 0.17.8", "rustls-pki-types", - "untrusted", + "untrusted 0.9.0", ] +[[package]] +name = "rustversion" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e819f2bc632f285be6d7cd36e25940d45b2391dd6d9b939e79de557f7014248" + +[[package]] +name = "rusty-money" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b28f881005eac7ad8d46b6f075da5f322bd7f4f83a38720fc069694ddadd683" +dependencies = [ + "rust_decimal", + "rust_decimal_macros", +] + +[[package]] +name = "rxml" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a98f186c7a2f3abbffb802984b7f1dfd65dac8be1aafdaabbca4137f53f0dff7" +dependencies = [ + "bytes", + "rxml_validation", + "smartstring", +] + +[[package]] +name = "rxml_validation" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22a197350ece202f19a166d1ad6d9d6de145e1d2a8ef47db299abe164dbd7530" + [[package]] name = "ryu" version = "1.0.18" @@ -4317,6 +6704,15 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "scheduled-thread-pool" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3cbc66816425a074528352f5789333ecff06ca41b36b0b0efdfbb29edc391a19" +dependencies = [ + "parking_lot", +] + [[package]] name = "schemars" version = "0.8.21" @@ -4340,7 +6736,7 @@ checksum = "b1eee588578aff73f856ab961cd2f79e36bc45d7ded33a7562adba4667aecc0e" dependencies = [ "proc-macro2", "quote", - "serde_derive_internals", + "serde_derive_internals 0.29.1", "syn 2.0.79", ] @@ -4350,6 +6746,34 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "sct" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da046153aa2352493d6cb7da4b6e5c0c057d8a1d0a9aa8560baffdd945acd414" +dependencies = [ + "ring 0.17.8", + "untrusted 0.9.0", +] + +[[package]] +name = "seahash" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c107b6f4780854c8b126e228ea8869f4d7b71260f962fefb57b996b8959ba6b" + +[[package]] +name = "sealed" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b5e421024b5e5edfbaa8e60ecf90bda9dbffc602dbb230e6028763f85f0c68c" +dependencies = [ + "heck 0.3.3", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "sec1" version = "0.7.3" @@ -4370,50 +6794,169 @@ version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02" dependencies = [ - "bitflags 2.6.0", - "core-foundation 0.9.4", - "core-foundation-sys", - "libc", - "security-framework-sys", + "bitflags 2.6.0", + "core-foundation 0.9.4", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea4a292869320c0272d7bc55a5a6aafaff59b4f63404a003887b679a2e05b4b6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "selectors" +version = "0.22.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe" +dependencies = [ + "bitflags 1.3.2", + "cssparser", + "derive_more", + "fxhash", + "log", + "matches", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc", + "smallvec", + "thin-slice", +] + +[[package]] +name = "semver" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +dependencies = [ + "serde", +] + +[[package]] +name = "sentry" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00421ed8fa0c995f07cde48ba6c89e80f2b312f74ff637326f392fbfd23abe02" +dependencies = [ + "httpdate", + "native-tls", + "reqwest 0.12.7", + "sentry-backtrace", + "sentry-contexts", + "sentry-core", + "sentry-debug-images", + "sentry-panic", + "sentry-tracing", + "tokio", + "ureq", +] + +[[package]] +name = "sentry-actix" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1986312ea8425a28299262ead2483ca8f0e167994f9239848d5718041abcd49" +dependencies = [ + "actix-web", + "futures-util", + "sentry-core", +] + +[[package]] +name = "sentry-backtrace" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a79194074f34b0cbe5dd33896e5928bbc6ab63a889bd9df2264af5acb186921e" +dependencies = [ + "backtrace", + "once_cell", + "regex", + "sentry-core", +] + +[[package]] +name = "sentry-contexts" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eba8870c5dba2bfd9db25c75574a11429f6b95957b0a78ac02e2970dd7a5249a" +dependencies = [ + "hostname", + "libc", + "os_info", + "rustc_version", + "sentry-core", + "uname", +] + +[[package]] +name = "sentry-core" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a75011ea1c0d5c46e9e57df03ce81f5c7f0a9e199086334a1f9c0a541e0826" +dependencies = [ + "once_cell", + "rand 0.8.5", + "sentry-types", + "serde", + "serde_json", +] + +[[package]] +name = "sentry-debug-images" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ec2a486336559414ab66548da610da5e9626863c3c4ffca07d88f7dc71c8de8" +dependencies = [ + "findshlibs", + "once_cell", + "sentry-core", ] [[package]] -name = "security-framework-sys" -version = "2.12.0" +name = "sentry-panic" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea4a292869320c0272d7bc55a5a6aafaff59b4f63404a003887b679a2e05b4b6" +checksum = "2eaa3ecfa3c8750c78dcfd4637cfa2598b95b52897ed184b4dc77fcf7d95060d" dependencies = [ - "core-foundation-sys", - "libc", + "sentry-backtrace", + "sentry-core", ] [[package]] -name = "selectors" -version = "0.22.0" +name = "sentry-tracing" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df320f1889ac4ba6bc0cdc9c9af7af4bd64bb927bccdf32d81140dc1f9be12fe" +checksum = "f715932bf369a61b7256687c6f0554141b7ce097287e30e3f7ed6e9de82498fe" dependencies = [ - "bitflags 1.3.2", - "cssparser", - "derive_more", - "fxhash", - "log", - "matches", - "phf 0.8.0", - "phf_codegen 0.8.0", - "precomputed-hash", - "servo_arc", - "smallvec", - "thin-slice", + "sentry-backtrace", + "sentry-core", + "tracing-core", + "tracing-subscriber", ] [[package]] -name = "semver" -version = "1.0.23" +name = "sentry-types" +version = "0.32.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61697e0a1c7e512e84a621326239844a24d8207b4669b41bc18b32ea5cbf988b" +checksum = "4519c900ce734f7a0eb7aba0869dfb225a7af8820634a7dd51449e3b093cfb7c" dependencies = [ + "debugid", + "hex", + "rand 0.8.5", "serde", + "serde_json", + "thiserror", + "time", + "url", + "uuid 1.10.0", ] [[package]] @@ -4447,6 +6990,17 @@ dependencies = [ "syn 2.0.79", ] +[[package]] +name = "serde_derive_internals" +version = "0.26.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85bf8229e7920a9f636479437026331ce11aa132b4dde37d121944a44d6e5f3c" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "serde_derive_internals" version = "0.29.1" @@ -4481,6 +7035,47 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_path_to_error" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af99884400da37c88f5e9146b7f1fd0fbcae8f6eec4e9da38b67d05486f814a6" +dependencies = [ + "itoa 1.0.11", + "serde", +] + +[[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_qs" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7715380eec75f029a4ef7de39a9200e0a63823176b759d055b613f5a87df6a6" +dependencies = [ + "percent-encoding", + "serde", + "thiserror", +] + +[[package]] +name = "serde_qs" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8cac3f1e2ca2fe333923a1ae72caca910b98ed0630bb35ef6f8c8517d6e81afa" +dependencies = [ + "percent-encoding", + "serde", + "thiserror", +] + [[package]] name = "serde_repr" version = "0.1.19" @@ -4537,7 +7132,7 @@ version = "3.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8fee4991ef4f274617a51ad4af30519438dacb2f56ac773b08a1922ff743350" dependencies = [ - "darling", + "darling 0.20.10", "proc-macro2", "quote", "syn 2.0.79", @@ -4575,6 +7170,15 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1da05c97445caa12d05e848c4a4fcbbea29e748ac28f7e80e9b010392063770" +dependencies = [ + "sha1_smol", +] + [[package]] name = "sha1" version = "0.10.6" @@ -4583,7 +7187,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures", - "digest", + "digest 0.10.7", ] [[package]] @@ -4592,6 +7196,19 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bbfa15b3dddfee50a0fff136974b3e1bde555604ba463834a7eb7deb6417705d" +[[package]] +name = "sha2" +version = "0.9.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d58a1e1bf39749807d89cf2d98ac2dfa0ff1cb3faa38fbb64dd88ac8013d800" +dependencies = [ + "block-buffer 0.9.0", + "cfg-if", + "cpufeatures", + "digest 0.9.0", + "opaque-debug", +] + [[package]] name = "sha2" version = "0.10.8" @@ -4600,7 +7217,7 @@ checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", "cpufeatures", - "digest", + "digest 0.10.7", ] [[package]] @@ -4643,7 +7260,7 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de" dependencies = [ - "digest", + "digest 0.10.7", "rand_core 0.6.4", ] @@ -4653,6 +7270,12 @@ version = "0.3.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d66dc143e6b11c1eddc06d5c423cfc97062865baf299914ab64caa38182078fe" +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + [[package]] name = "siphasher" version = "0.3.11" @@ -4668,6 +7291,17 @@ dependencies = [ "autocfg", ] +[[package]] +name = "sluice" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d7400c0eff44aa2fcb5e31a5f24ba9716ed90138769e4977a2ba6014ae63eb5" +dependencies = [ + "async-channel 1.9.0", + "futures-core", + "futures-io", +] + [[package]] name = "smallvec" version = "1.13.2" @@ -4677,6 +7311,37 @@ dependencies = [ "serde", ] +[[package]] +name = "smart-default" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "133659a15339456eeeb07572eb02a91c91e9815e9cbc89566944d2c8d3efdbf6" +dependencies = [ + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "smartstring" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fb72c633efbaa2dd666986505016c32c3044395ceaf881518399d2f4127ee29" +dependencies = [ + "autocfg", + "static_assertions", + "version_check", +] + +[[package]] +name = "smol_str" +version = "0.1.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fad6c857cbab2627dcf01ec85a623ca4e7dcb5691cbaa3d7fb7653671f0d09c9" +dependencies = [ + "serde", +] + [[package]] name = "socket2" version = "0.5.7" @@ -4735,6 +7400,21 @@ dependencies = [ "system-deps", ] +[[package]] +name = "spdx" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47317bbaf63785b53861e1ae2d11b80d6b624211d42cb20efcd210ee6f8a14bc" +dependencies = [ + "smallvec", +] + +[[package]] +name = "spin" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e63cff320ae2c57904679ba7cb63280a3dc4613885beafb148ee7bf9aa9042d" + [[package]] name = "spin" version = "0.9.8" @@ -4744,6 +7424,15 @@ dependencies = [ "lock_api", ] +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + [[package]] name = "spki" version = "0.7.3" @@ -4786,10 +7475,11 @@ dependencies = [ "atoi", "byteorder", "bytes", + "chrono", "crc", "crossbeam-queue", "either", - "event-listener", + "event-listener 5.3.1", "futures-channel", "futures-core", "futures-intrusive", @@ -4804,9 +7494,12 @@ dependencies = [ "once_cell", "paste", "percent-encoding", + "rust_decimal", + "rustls 0.23.13", + "rustls-pemfile 2.1.3", "serde", "serde_json", - "sha2", + "sha2 0.10.8", "smallvec", "sqlformat", "thiserror", @@ -4814,6 +7507,7 @@ dependencies = [ "tokio-stream", "tracing", "url", + "webpki-roots", ] [[package]] @@ -4844,7 +7538,7 @@ dependencies = [ "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.8", "sqlx-core", "sqlx-mysql", "sqlx-postgres", @@ -4866,8 +7560,9 @@ dependencies = [ "bitflags 2.6.0", "byteorder", "bytes", + "chrono", "crc", - "digest", + "digest 0.10.7", "dotenvy", "either", "futures-channel", @@ -4877,7 +7572,7 @@ dependencies = [ "generic-array", "hex", "hkdf", - "hmac", + "hmac 0.12.1", "itoa 1.0.11", "log", "md-5", @@ -4886,9 +7581,10 @@ dependencies = [ "percent-encoding", "rand 0.8.5", "rsa", + "rust_decimal", "serde", - "sha1", - "sha2", + "sha1 0.10.6", + "sha2 0.10.8", "smallvec", "sqlx-core", "stringprep", @@ -4907,6 +7603,7 @@ dependencies = [ "base64 0.22.1", "bitflags 2.6.0", "byteorder", + "chrono", "crc", "dotenvy", "etcetera", @@ -4916,7 +7613,7 @@ dependencies = [ "futures-util", "hex", "hkdf", - "hmac", + "hmac 0.12.1", "home", "itoa 1.0.11", "log", @@ -4924,9 +7621,10 @@ dependencies = [ "memchr", "once_cell", "rand 0.8.5", + "rust_decimal", "serde", "serde_json", - "sha2", + "sha2 0.10.8", "smallvec", "sqlx-core", "stringprep", @@ -4942,6 +7640,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5b2cf34a45953bfd3daaf3db0f7a7878ab9b7a6b91b422d24a7a9e4c857b680" dependencies = [ "atoi", + "chrono", "flume", "futures-channel", "futures-core", @@ -4964,12 +7663,31 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a8f112729512f8e442d81f95a8a7ddf2b7c6b8a1a6f509a95864142b30cab2d3" +[[package]] +name = "stacker" +version = "0.1.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799c883d55abdb5e98af1a7b3f23b9b6de8ecada0ecac058672d7635eb48ca7b" +dependencies = [ + "cc", + "cfg-if", + "libc", + "psm", + "windows-sys 0.59.0", +] + [[package]] name = "static_assertions" version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" +[[package]] +name = "strfmt" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a8348af2d9fc3258c8733b8d9d8db2e56f54b2363a4b5b81585c7875ed65e65" + [[package]] name = "string_cache" version = "0.8.7" @@ -5007,12 +7725,40 @@ dependencies = [ "unicode-properties", ] +[[package]] +name = "strsim" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.79", +] + [[package]] name = "subtle" version = "2.6.1" @@ -5052,6 +7798,24 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn_derive" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1329189c02ff984e9736652b1631330da25eaa6bc639089ed4915d25446cbe7b" +dependencies = [ + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.79", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "sync_wrapper" version = "1.0.1" @@ -5061,6 +7825,17 @@ dependencies = [ "futures-core", ] +[[package]] +name = "synstructure" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8af7666ab7b6390ab78131fb5b0fce11d6b7a6951602017c35fa82800708971" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", +] + [[package]] name = "sys-info" version = "0.9.1" @@ -5095,6 +7870,17 @@ dependencies = [ "windows 0.52.0", ] +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys 0.5.0", +] + [[package]] name = "system-configuration" version = "0.6.1" @@ -5103,7 +7889,17 @@ checksum = "3c879d448e9d986b661742763247d3693ed13609438cf3d006f51f5368a5ba6b" dependencies = [ "bitflags 2.6.0", "core-foundation 0.9.4", - "system-configuration-sys", + "system-configuration-sys 0.6.0", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", ] [[package]] @@ -5179,6 +7975,12 @@ dependencies = [ "syn 2.0.79", ] +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + [[package]] name = "tar" version = "0.4.42" @@ -5204,7 +8006,7 @@ checksum = "246bd333561c5601241b7a09f19957d5f659667f3c1191c869a066fb309e1841" dependencies = [ "anyhow", "bytes", - "dirs", + "dirs 5.0.1", "dunce", "embed_plist", "futures-util", @@ -5212,7 +8014,7 @@ dependencies = [ "glob", "gtk", "heck 0.5.0", - "http", + "http 1.1.0", "http-range", "jni", "libc", @@ -5225,7 +8027,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle 0.6.2", - "reqwest", + "reqwest 0.12.7", "serde", "serde_json", "serde_repr", @@ -5255,7 +8057,7 @@ checksum = "e5bc30f14b3c1548d75dfdf3e40bffe20a53bc4e3381e9bacc21dc765d701d0a" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 5.0.1", "glob", "heck 0.5.0", "json-patch", @@ -5288,7 +8090,7 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", + "sha2 0.10.8", "syn 2.0.79", "tauri-utils", "thiserror", @@ -5337,7 +8139,7 @@ checksum = "4a2e49d1fb1aef2bd3a973aa7634474cfdac6bb894854f76a238e2fadf939d37" dependencies = [ "dunce", "log", - "rust-ini", + "rust-ini 0.21.1", "serde", "serde_json", "tauri", @@ -5449,13 +8251,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "391ebb8ae8cd6aec44b5d96d3005659d88cde69c57326f639bbc660116a30d63" dependencies = [ "base64 0.22.1", - "dirs", + "dirs 5.0.1", "flate2", "futures-util", - "http", - "infer", + "http 1.1.0", + "infer 0.16.0", "minisign-verify", - "reqwest", + "reqwest 0.12.7", "semver", "serde", "serde_json", @@ -5494,7 +8296,7 @@ checksum = "8d9465366fd7f9e9c77385fa8b7cb583b060544e8800bd0309deb100008c312d" dependencies = [ "dpi", "gtk", - "http", + "http 1.1.0", "jni", "raw-window-handle 0.6.2", "serde", @@ -5512,7 +8314,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a03a49d6bcc0e65d64ea4420e2097270a25a9e1ff0fb2ece75e54fbbd54e45f7" dependencies = [ "gtk", - "http", + "http 1.1.0", "jni", "log", "objc2", @@ -5543,7 +8345,7 @@ dependencies = [ "dunce", "glob", "html5ever", - "infer", + "infer 0.16.0", "json-patch", "kuchikiki", "log", @@ -5583,22 +8385,42 @@ version = "3.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" dependencies = [ - "cfg-if", - "fastrand", - "once_cell", - "rustix", - "windows-sys 0.59.0", + "cfg-if", + "fastrand 2.1.1", + "once_cell", + "rustix", + "windows-sys 0.59.0", +] + +[[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 = "term" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c59df8ac95d96ff9bede18eb7300b0fda5e5d8d90960e76f8e14ae765eedbf1f" +dependencies = [ + "dirs-next", + "rustversion", + "winapi", ] [[package]] -name = "tendril" -version = "0.4.3" +name = "termcolor" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" dependencies = [ - "futf", - "mac", - "utf-8", + "winapi-util", ] [[package]] @@ -5613,8 +8435,8 @@ dependencies = [ "bytes", "chrono", "daedalus", - "dashmap", - "dirs", + "dashmap 6.1.0", + "dirs 5.0.1", "discord-rich-presence", "dunce", "flate2", @@ -5627,12 +8449,12 @@ dependencies = [ "paste", "rand 0.8.5", "regex", - "reqwest", + "reqwest 0.12.7", "serde", "serde_ini", "serde_json", "sha1_smol", - "sha2", + "sha2 0.10.8", "sqlx", "sys-info", "sysinfo", @@ -5648,7 +8470,7 @@ dependencies = [ "urlencoding", "uuid 1.10.0", "whoami", - "winreg", + "winreg 0.52.0", "zip 0.6.6", ] @@ -5659,8 +8481,8 @@ dependencies = [ "chrono", "cocoa 0.25.0", "daedalus", - "dashmap", - "dirs", + "dashmap 6.1.0", + "dirs 5.0.1", "futures", "lazy_static", "native-dialog", @@ -5746,6 +8568,17 @@ dependencies = [ "once_cell", ] +[[package]] +name = "tiff" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba1310fcea54c6a9a4fd1aad794ecc02c31682f6bfbecdf460bf19533eed1e3e" +dependencies = [ + "flate2", + "jpeg-decoder", + "weezl", +] + [[package]] name = "time" version = "0.3.36" @@ -5786,6 +8619,16 @@ dependencies = [ "crunchy", ] +[[package]] +name = "tinystr" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9117f5d4db391c1cf6927e7bea3db74b9a1c1add8f7eda9ffd5364f40f57b82f" +dependencies = [ + "displaydoc", + "zerovec", +] + [[package]] name = "tinyvec" version = "1.8.0" @@ -5841,13 +8684,23 @@ dependencies = [ "tokio", ] +[[package]] +name = "tokio-rustls" +version = "0.24.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c28327cf380ac148141087fbfb9de9d7bd4e84ab5d2c28fbc911d753de8a7081" +dependencies = [ + "rustls 0.21.12", + "tokio", +] + [[package]] name = "tokio-rustls" version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c7bc40d0e5a97695bb96e27995cd3a08538541b0a846f65bba7a359f36700d4" dependencies = [ - "rustls", + "rustls 0.23.13", "rustls-pki-types", "tokio", ] @@ -5947,6 +8800,20 @@ dependencies = [ "winnow 0.6.20", ] +[[package]] +name = "totp-rs" +version = "5.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17b2f27dad992486c26b4e7455f38aa487e838d6d61b57e72906ee2b8c287a90" +dependencies = [ + "base32", + "constant_time_eq 0.2.6", + "hmac 0.12.1", + "rand 0.8.5", + "sha1 0.10.6", + "sha2 0.10.8", +] + [[package]] name = "tower-service" version = "0.3.3" @@ -5996,6 +8863,16 @@ dependencies = [ "tracing-subscriber", ] +[[package]] +name = "tracing-futures" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97d095ae15e245a057c8e8451bab9b3ee1e1f68e9ba2b4fbc18d0ac5237835f2" +dependencies = [ + "pin-project", + "tracing", +] + [[package]] name = "tracing-log" version = "0.2.0" @@ -6034,7 +8911,7 @@ checksum = "533fc2d4105e0e3d96ce1c71f2d308c9fbbe2ef9c587cab63dd627ab5bde218f" dependencies = [ "core-graphics 0.24.0", "crossbeam-channel", - "dirs", + "dirs 5.0.1", "libappindicator", "muda", "objc2", @@ -6068,13 +8945,13 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http", + "http 1.1.0", "httparse", "log", "rand 0.8.5", - "rustls", + "rustls 0.23.13", "rustls-pki-types", - "sha1", + "sha1 0.10.6", "thiserror", "utf-8", ] @@ -6102,6 +8979,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "uname" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72f89f0ca32e4db1c04e2a72f5345d59796d4866a1ee0609084569f73683dc8" +dependencies = [ + "libc", +] + [[package]] name = "unic-char-property" version = "0.9.0" @@ -6143,6 +9029,15 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7d2d4dafb69621809a81864c9c1b864479e1235c0dd4e199924b9742439ed89" +dependencies = [ + "version_check", +] + [[package]] name = "unicode-bidi" version = "0.3.15" @@ -6188,12 +9083,31 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39ec24b3121d976906ece63c9daad25b85969647682eee313cb5779fdd69e14e" +[[package]] +name = "untrusted" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a156c684c91ea7d62626509bce3cb4e1d9ed5c4d978f7b4352658f96a4c26b4a" + [[package]] name = "untrusted" version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" +[[package]] +name = "ureq" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b74fc6b57825be3373f7054754755f03ac3a8f5d70015ccad699ba2029956f4a" +dependencies = [ + "base64 0.22.1", + "log", + "native-tls", + "once_cell", + "url", +] + [[package]] name = "url" version = "2.5.2" @@ -6201,7 +9115,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" dependencies = [ "form_urlencoded", - "idna", + "idna 0.5.0", "percent-encoding", "serde", ] @@ -6230,6 +9144,18 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "utf16_iter" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8232dd3cdaed5356e0f716d285e4b40b932ac434100fe9b7e0e8e935b9e6246" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "uuid" version = "0.8.2" @@ -6246,7 +9172,57 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" dependencies = [ "getrandom 0.2.15", + "rand 0.8.5", + "serde", +] + +[[package]] +name = "v_htmlescape" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" + +[[package]] +name = "validator" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b92f40481c04ff1f4f61f304d61793c7b56ff76ac1469f1beb199b1445b253bd" +dependencies = [ + "idna 0.4.0", + "lazy_static", + "phonenumber", + "regex", "serde", + "serde_derive", + "serde_json", + "url", + "validator_derive", +] + +[[package]] +name = "validator_derive" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc44ca3088bb3ba384d9aecf40c6a23a676ce23e09bdaca2073d99c207f864af" +dependencies = [ + "if_chain", + "lazy_static", + "proc-macro-error", + "proc-macro2", + "quote", + "regex", + "syn 1.0.109", + "validator_types", +] + +[[package]] +name = "validator_types" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "111abfe30072511849c5910134e8baf8dc05de4c0e5903d681cbd5c9c4d611e3" +dependencies = [ + "proc-macro2", + "syn 1.0.109", ] [[package]] @@ -6279,7 +9255,7 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c73a36bc44e3039f51fbee93e39f41225f6b17b380eb70cc2aab942df06b34dd" dependencies = [ - "itertools", + "itertools 0.11.0", "nom", ] @@ -6309,6 +9285,12 @@ dependencies = [ "libc", ] +[[package]] +name = "waker-fn" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "317211a0dc0ceedd78fb2ca9a44aed3d7b9b26f81870d485c07122b4350673b7" + [[package]] name = "walkdir" version = "2.5.0" @@ -6497,6 +9479,16 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webp" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f53152f51fb5af0c08484c33d16cca96175881d1f3dec068c23b31a158c2d99" +dependencies = [ + "image 0.25.3", + "libwebp-sys", +] + [[package]] name = "webpki-roots" version = "0.26.6" @@ -6542,6 +9534,12 @@ dependencies = [ "windows-core 0.58.0", ] +[[package]] +name = "weezl" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53a85b86a771b1c87058196170769dd264f66c0782acf1ae6cc51bfd64b39082" + [[package]] name = "wfd" version = "0.1.7" @@ -6967,6 +9965,16 @@ dependencies = [ "memchr", ] +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + [[package]] name = "winreg" version = "0.52.0" @@ -6977,6 +9985,28 @@ dependencies = [ "windows-sys 0.48.0", ] +[[package]] +name = "woothee" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "896174c6a4779d4d7d4523dd27aef7d46609eda2497e370f6c998325c6bf6971" +dependencies = [ + "lazy_static", + "regex", +] + +[[package]] +name = "write16" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1890f4022759daae28ed4fe62859b1236caebfc61ede2f63ed4e695f3f6d936" + +[[package]] +name = "writeable" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e9df38ee2d2c3c5948ea468a8406ff0db0b29ae1ffde1bcf20ef305bcc95c51" + [[package]] name = "wry" version = "0.44.1" @@ -6992,7 +10022,7 @@ dependencies = [ "gdkx11", "gtk", "html5ever", - "http", + "http 1.1.0", "javascriptcore-rs", "jni", "kuchikiki", @@ -7003,7 +10033,7 @@ dependencies = [ "once_cell", "percent-encoding", "raw-window-handle 0.6.2", - "sha2", + "sha2 0.10.8", "soup3", "tao-macros", "thiserror", @@ -7016,6 +10046,15 @@ dependencies = [ "x11-dl", ] +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] + [[package]] name = "x11" version = "2.21.0" @@ -7058,6 +10097,70 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "xml-rs" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af4e2e2f7cba5a093896c1e150fbfe177d1883e7448200efb81d40b9d339ef26" + +[[package]] +name = "yaserde" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bf52af554a50b866aaad63d7eabd6fca298db3dfe49afd50b7ba5a33dfa0582" +dependencies = [ + "log", + "xml-rs", +] + +[[package]] +name = "yaserde_derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ab8bd5c76eebb8380b26833d30abddbdd885b00dd06178412e0d51d5bfc221f" +dependencies = [ + "heck 0.4.1", + "log", + "proc-macro2", + "quote", + "syn 1.0.109", + "xml-rs", +] + +[[package]] +name = "yaup" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a59e7d27bed43f7c37c25df5192ea9d435a8092a902e02203359ac9ce3e429d9" +dependencies = [ + "serde", + "url", +] + +[[package]] +name = "yoke" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c5b1314b079b0930c31e3af543d8ee1757b1951ae1e1565ec704403a7240ca5" +dependencies = [ + "serde", + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "28cc31741b18cb6f1d5ff12f5b7523e3d6eb0852bbbad19d73905511d9849b95" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", + "synstructure", +] + [[package]] name = "zbus" version = "4.4.0" @@ -7075,7 +10178,7 @@ dependencies = [ "async-trait", "blocking", "enumflags2", - "event-listener", + "event-listener 5.3.1", "futures-core", "futures-sink", "futures-util", @@ -7085,7 +10188,7 @@ dependencies = [ "rand 0.8.5", "serde", "serde_repr", - "sha1", + "sha1 0.10.6", "static_assertions", "tokio", "tracing", @@ -7142,12 +10245,55 @@ dependencies = [ "syn 2.0.79", ] +[[package]] +name = "zerofrom" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91ec111ce797d0e0784a1116d0ddcdbea84322cd79e5d5ad173daeba4f93ab55" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ea7b4a3637ea8669cedf0f1fd5c286a17f3de97b8dd5a70a6c167a1730e63a5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", + "synstructure", +] + [[package]] name = "zeroize" version = "1.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ced3678a2879b30306d323f4542626697a464a97c0a07c9aebf7ebca65cd4dde" +[[package]] +name = "zerovec" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa2b893d79df23bfb12d5461018d408ea19dfafe76c2c7ef6d4eba614f8ff079" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6eafa6dfb17584ea3e2bd6e76e0cc15ad7af12b09abdd1ca55961bed9b1063c6" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.79", +] + [[package]] name = "zip" version = "0.6.6" @@ -7157,13 +10303,13 @@ dependencies = [ "aes", "byteorder", "bzip2", - "constant_time_eq", + "constant_time_eq 0.1.5", "crc32fast", "crossbeam-utils", "flate2", - "hmac", + "hmac 0.12.1", "pbkdf2", - "sha1", + "sha1 0.10.6", "time", "zstd 0.11.2+zstd.1.5.2", ] @@ -7230,6 +10376,15 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "zune-inflate" +version = "0.2.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73ab332fe2f6680068f3582b16a24f90ad7096d5d39b974d1c0aff0125116f02" +dependencies = [ + "simd-adler32", +] + [[package]] name = "zvariant" version = "4.2.0" @@ -7267,3 +10422,19 @@ dependencies = [ "quote", "syn 2.0.79", ] + +[[package]] +name = "zxcvbn" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "103fa851fff70ea29af380e87c25c48ff7faac5c530c70bd0e65366d4e0c94e4" +dependencies = [ + "derive_builder", + "fancy-regex", + "itertools 0.10.5", + "js-sys", + "lazy_static", + "quick-error", + "regex", + "time", +] diff --git a/Cargo.toml b/Cargo.toml index ed2ddbc47..2edb492b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,8 @@ resolver = '2' members = [ './packages/app-lib', './apps/app-playground', - './apps/app' + './apps/app', + './apps/labrinth' ] # Optimize for speed and reduce size on release builds diff --git a/apps/app-playground/package.json b/apps/app-playground/package.json index 4e4825b84..0d76eaed8 100644 --- a/apps/app-playground/package.json +++ b/apps/app-playground/package.json @@ -2,7 +2,7 @@ "name": "@modrinth/app-playground", "scripts": { "build": "cargo build --release", - "lint": "cargo fmt --check && cargo clippy -- -D warnings", + "lint": "cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings", "fix": "cargo fmt && cargo clippy --fix", "dev": "cargo run", "test": "cargo test" diff --git a/apps/app/package.json b/apps/app/package.json index 30d9cfff5..9ed027b2a 100644 --- a/apps/app/package.json +++ b/apps/app/package.json @@ -5,7 +5,7 @@ "tauri": "tauri", "dev": "tauri dev", "test": "cargo test", - "lint": "cargo fmt --check && cargo clippy -- -D warnings", + "lint": "cargo fmt --check && cargo clippy --all-targets -- -D warnings", "fix": "cargo fmt && cargo clippy --fix" }, "devDependencies": { @@ -15,4 +15,4 @@ "@modrinth/app-frontend": "workspace:*", "@modrinth/app-lib": "workspace:*" } -} +} \ No newline at end of file diff --git a/apps/labrinth/.dockerignore b/apps/labrinth/.dockerignore new file mode 100644 index 000000000..633fa76f3 --- /dev/null +++ b/apps/labrinth/.dockerignore @@ -0,0 +1,3 @@ +target +.env +Dockerfile \ No newline at end of file diff --git a/apps/labrinth/.env b/apps/labrinth/.env new file mode 100644 index 000000000..07dc02eec --- /dev/null +++ b/apps/labrinth/.env @@ -0,0 +1,108 @@ +DEBUG=true +RUST_LOG=info,sqlx::query=warn +SENTRY_DSN=none + +SITE_URL=https://modrinth.com +CDN_URL=https://staging-cdn.modrinth.com +LABRINTH_ADMIN_KEY=feedbeef +RATE_LIMIT_IGNORE_KEY=feedbeef + +DATABASE_URL=postgresql://labrinth:labrinth@localhost/labrinth +DATABASE_MIN_CONNECTIONS=0 +DATABASE_MAX_CONNECTIONS=16 + +MEILISEARCH_ADDR=http://localhost:7700 +MEILISEARCH_KEY=modrinth + +REDIS_URL=redis://localhost +REDIS_MAX_CONNECTIONS=10000 + +BIND_ADDR=127.0.0.1:8000 +SELF_ADDR=http://127.0.0.1:8000 + +MODERATION_SLACK_WEBHOOK= +PUBLIC_DISCORD_WEBHOOK= +CLOUDFLARE_INTEGRATION=false + +STORAGE_BACKEND=local + +MOCK_FILE_PATH=/tmp/modrinth + +BACKBLAZE_KEY_ID=none +BACKBLAZE_KEY=none +BACKBLAZE_BUCKET_ID=none + +S3_ACCESS_TOKEN=none +S3_SECRET=none +S3_URL=none +S3_REGION=none +S3_BUCKET_NAME=none + +# 1 hour +LOCAL_INDEX_INTERVAL=3600 +# 30 minutes +VERSION_INDEX_INTERVAL=1800 + +RATE_LIMIT_IGNORE_IPS='["127.0.0.1"]' + +WHITELISTED_MODPACK_DOMAINS='["cdn.modrinth.com", "github.com", "raw.githubusercontent.com"]' + +ALLOWED_CALLBACK_URLS='["localhost", ".modrinth.com", "127.0.0.1"]' + +GITHUB_CLIENT_ID=none +GITHUB_CLIENT_SECRET=none + +GITLAB_CLIENT_ID=none +GITLAB_CLIENT_SECRET=none + +DISCORD_CLIENT_ID=none +DISCORD_CLIENT_SECRET=none + +MICROSOFT_CLIENT_ID=none +MICROSOFT_CLIENT_SECRET=none + +GOOGLE_CLIENT_ID=none +GOOGLE_CLIENT_SECRET=none + +PAYPAL_API_URL=https://api-m.sandbox.paypal.com/v1/ +PAYPAL_WEBHOOK_ID=none +PAYPAL_CLIENT_ID=none +PAYPAL_CLIENT_SECRET=none + +STEAM_API_KEY=none + +TREMENDOUS_API_URL=https://testflight.tremendous.com/api/v2/ +TREMENDOUS_API_KEY=none +TREMENDOUS_PRIVATE_KEY=none +TREMENDOUS_CAMPAIGN_ID=none + +TURNSTILE_SECRET=none + +SMTP_USERNAME=none +SMTP_PASSWORD=none +SMTP_HOST=none + +SITE_VERIFY_EMAIL_PATH=none +SITE_RESET_PASSWORD_PATH=none +SITE_BILLING_PATH=none + +BEEHIIV_PUBLICATION_ID=none +BEEHIIV_API_KEY=none + +ANALYTICS_ALLOWED_ORIGINS='["http://127.0.0.1:3000", "http://localhost:3000", "https://modrinth.com", "https://www.modrinth.com", "*"]' + +CLICKHOUSE_URL=http://localhost:8123 +CLICKHOUSE_USER=default +CLICKHOUSE_PASSWORD= +CLICKHOUSE_DATABASE=staging_ariadne + +MAXMIND_LICENSE_KEY=none + +FLAME_ANVIL_URL=none + +STRIPE_API_KEY=none +STRIPE_WEBHOOK_SECRET=none + +ADITUDE_API_KEY=none + +PYRO_API_KEY=none \ No newline at end of file diff --git a/apps/labrinth/.sqlx/query-00a733e8ea78f15743afe6a9d637fa4fb87a205854905fb16cf1b8e715f1e01d.json b/apps/labrinth/.sqlx/query-00a733e8ea78f15743afe6a9d637fa4fb87a205854905fb16cf1b8e715f1e01d.json new file mode 100644 index 000000000..3e046f329 --- /dev/null +++ b/apps/labrinth/.sqlx/query-00a733e8ea78f15743afe6a9d637fa4fb87a205854905fb16cf1b8e715f1e01d.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT version_id,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types,\n ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games,\n ARRAY_AGG(DISTINCT lfl.loader_field_id) filter (where lfl.loader_field_id is not null) loader_fields\n FROM versions v\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n INNER JOIN loaders_project_types_games lptg ON lptg.loader_id = l.id AND lptg.project_type_id = pt.id\n INNER JOIN games g ON lptg.game_id = g.id\n LEFT JOIN loader_fields_loaders lfl ON lfl.loader_id = l.id\n WHERE v.id = ANY($1)\n GROUP BY version_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "loaders", + "type_info": "VarcharArray" + }, + { + "ordinal": 2, + "name": "project_types", + "type_info": "VarcharArray" + }, + { + "ordinal": 3, + "name": "games", + "type_info": "VarcharArray" + }, + { + "ordinal": 4, + "name": "loader_fields", + "type_info": "Int4Array" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + null, + null, + null, + null + ] + }, + "hash": "00a733e8ea78f15743afe6a9d637fa4fb87a205854905fb16cf1b8e715f1e01d" +} diff --git a/apps/labrinth/.sqlx/query-010cafcafb6adc25b00e3c81d844736b0245e752a90334c58209d8a02536c800.json b/apps/labrinth/.sqlx/query-010cafcafb6adc25b00e3c81d844736b0245e752a90334c58209d8a02536c800.json new file mode 100644 index 000000000..1336c5438 --- /dev/null +++ b/apps/labrinth/.sqlx/query-010cafcafb6adc25b00e3c81d844736b0245e752a90334c58209d8a02536c800.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET moderation_message = NULL, moderation_message_body = NULL, queued = NOW()\n WHERE (id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "010cafcafb6adc25b00e3c81d844736b0245e752a90334c58209d8a02536c800" +} diff --git a/apps/labrinth/.sqlx/query-02843e787de72594e186a14734bd02099ca6d2f07dcc06da8d6d8a069638ca2a.json b/apps/labrinth/.sqlx/query-02843e787de72594e186a14734bd02099ca6d2f07dcc06da8d6d8a069638ca2a.json new file mode 100644 index 000000000..253030013 --- /dev/null +++ b/apps/labrinth/.sqlx/query-02843e787de72594e186a14734bd02099ca6d2f07dcc06da8d6d8a069638ca2a.json @@ -0,0 +1,30 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, mod_id FROM versions\n WHERE ((version_number = $1 OR id = $3) AND mod_id = $2)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "mod_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "02843e787de72594e186a14734bd02099ca6d2f07dcc06da8d6d8a069638ca2a" +} diff --git a/apps/labrinth/.sqlx/query-02a585c845168c1bd8a82c30af351db75597da5456e29efc033ebb098e81e905.json b/apps/labrinth/.sqlx/query-02a585c845168c1bd8a82c30af351db75597da5456e29efc033ebb098e81e905.json new file mode 100644 index 000000000..4777f327b --- /dev/null +++ b/apps/labrinth/.sqlx/query-02a585c845168c1bd8a82c30af351db75597da5456e29efc033ebb098e81e905.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO team_members (\n id, team_id, user_id, role, permissions, organization_permissions, is_owner, accepted, payouts_split\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Varchar", + "Int8", + "Int8", + "Bool", + "Bool", + "Numeric" + ] + }, + "nullable": [] + }, + "hash": "02a585c845168c1bd8a82c30af351db75597da5456e29efc033ebb098e81e905" +} diff --git a/apps/labrinth/.sqlx/query-0379424a41b12db94c7734086fca5b96c8cdfe0a9f9c00e5c67e6b95a33c8c6b.json b/apps/labrinth/.sqlx/query-0379424a41b12db94c7734086fca5b96c8cdfe0a9f9c00e5c67e6b95a33c8c6b.json new file mode 100644 index 000000000..d3188818e --- /dev/null +++ b/apps/labrinth/.sqlx/query-0379424a41b12db94c7734086fca5b96c8cdfe0a9f9c00e5c67e6b95a33c8c6b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT SUM(amount)\n FROM payouts_values\n WHERE user_id = $1 AND date_available > NOW()\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "sum", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "0379424a41b12db94c7734086fca5b96c8cdfe0a9f9c00e5c67e6b95a33c8c6b" +} diff --git a/apps/labrinth/.sqlx/query-0461463e3e14f6c8ede5571a2905b8171e8caf4ebbd3ec844ef2cebd83980247.json b/apps/labrinth/.sqlx/query-0461463e3e14f6c8ede5571a2905b8171e8caf4ebbd3ec844ef2cebd83980247.json new file mode 100644 index 000000000..e096043b6 --- /dev/null +++ b/apps/labrinth/.sqlx/query-0461463e3e14f6c8ede5571a2905b8171e8caf4ebbd3ec844ef2cebd83980247.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM reports\n WHERE user_id = $1 OR reporter = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "0461463e3e14f6c8ede5571a2905b8171e8caf4ebbd3ec844ef2cebd83980247" +} diff --git a/apps/labrinth/.sqlx/query-04c04958c71c4fab903c46c9185286e7460a6ff7b03cbc90939ac6c7cb526433.json b/apps/labrinth/.sqlx/query-04c04958c71c4fab903c46c9185286e7460a6ff7b03cbc90939ac6c7cb526433.json new file mode 100644 index 000000000..6c62c2b6b --- /dev/null +++ b/apps/labrinth/.sqlx/query-04c04958c71c4fab903c46c9185286e7460a6ff7b03cbc90939ac6c7cb526433.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, enum_id, value, ordering, metadata, created FROM loader_field_enum_values\n WHERE enum_id = ANY($1)\n ORDER BY enum_id, ordering, created DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "enum_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "value", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "ordering", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "metadata", + "type_info": "Jsonb" + }, + { + "ordinal": 5, + "name": "created", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int4Array" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + false + ] + }, + "hash": "04c04958c71c4fab903c46c9185286e7460a6ff7b03cbc90939ac6c7cb526433" +} diff --git a/apps/labrinth/.sqlx/query-061a3e43df9464263aaf1555a27c1f4b6a0f381282f4fa75cc13b1d354857578.json b/apps/labrinth/.sqlx/query-061a3e43df9464263aaf1555a27c1f4b6a0f381282f4fa75cc13b1d354857578.json new file mode 100644 index 000000000..5cc54d4de --- /dev/null +++ b/apps/labrinth/.sqlx/query-061a3e43df9464263aaf1555a27c1f4b6a0f381282f4fa75cc13b1d354857578.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT m.id AS pid, NULL AS oid\n FROM mods m\n WHERE m.team_id = $1\n \n UNION ALL\n \n SELECT NULL AS pid, o.id AS oid\n FROM organizations o\n WHERE o.team_id = $1 \n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "pid", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "oid", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "061a3e43df9464263aaf1555a27c1f4b6a0f381282f4fa75cc13b1d354857578" +} diff --git a/apps/labrinth/.sqlx/query-06a92b638c77276f36185788748191e7731a2cce874ecca4af913d0d0412d223.json b/apps/labrinth/.sqlx/query-06a92b638c77276f36185788748191e7731a2cce874ecca4af913d0d0412d223.json new file mode 100644 index 000000000..e7f9ee9ae --- /dev/null +++ b/apps/labrinth/.sqlx/query-06a92b638c77276f36185788748191e7731a2cce874ecca4af913d0d0412d223.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE versions\n SET downloads = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "06a92b638c77276f36185788748191e7731a2cce874ecca4af913d0d0412d223" +} diff --git a/apps/labrinth/.sqlx/query-06bf1b34b70f5e61bf619c4d7706d07d6db413751ecab86896a708c8539e38b6.json b/apps/labrinth/.sqlx/query-06bf1b34b70f5e61bf619c4d7706d07d6db413751ecab86896a708c8539e38b6.json new file mode 100644 index 000000000..245750239 --- /dev/null +++ b/apps/labrinth/.sqlx/query-06bf1b34b70f5e61bf619c4d7706d07d6db413751ecab86896a708c8539e38b6.json @@ -0,0 +1,83 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.is_owner, tm.permissions, tm.organization_permissions, tm.accepted, tm.payouts_split, tm.ordering, v.mod_id \n FROM versions v\n INNER JOIN mods m ON m.id = v.mod_id\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.user_id = $2 AND tm.accepted = TRUE\n WHERE v.id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "team_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "role", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "is_owner", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "permissions", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "organization_permissions", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "accepted", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "payouts_split", + "type_info": "Numeric" + }, + { + "ordinal": 9, + "name": "ordering", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "mod_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false, + false, + false, + false + ] + }, + "hash": "06bf1b34b70f5e61bf619c4d7706d07d6db413751ecab86896a708c8539e38b6" +} diff --git a/apps/labrinth/.sqlx/query-06bfc01bafa2db73eb049f268eb9f333aeeb23a048ee524b278fe184e2d3ae45.json b/apps/labrinth/.sqlx/query-06bfc01bafa2db73eb049f268eb9f333aeeb23a048ee524b278fe184e2d3ae45.json new file mode 100644 index 000000000..fac948763 --- /dev/null +++ b/apps/labrinth/.sqlx/query-06bfc01bafa2db73eb049f268eb9f333aeeb23a048ee524b278fe184e2d3ae45.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET paypal_country = NULL, paypal_email = NULL, paypal_id = NULL\n WHERE (id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "06bfc01bafa2db73eb049f268eb9f333aeeb23a048ee524b278fe184e2d3ae45" +} diff --git a/apps/labrinth/.sqlx/query-06f51ba9bfc8ddf76c3ac2ad0a93849a3ff19649835bcdb1d44697d9a229725a.json b/apps/labrinth/.sqlx/query-06f51ba9bfc8ddf76c3ac2ad0a93849a3ff19649835bcdb1d44697d9a229725a.json new file mode 100644 index 000000000..b57941a46 --- /dev/null +++ b/apps/labrinth/.sqlx/query-06f51ba9bfc8ddf76c3ac2ad0a93849a3ff19649835bcdb1d44697d9a229725a.json @@ -0,0 +1,65 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT o.id, o.slug, o.name, o.team_id, o.description, o.icon_url, o.raw_icon_url, o.color\n FROM organizations o\n WHERE o.id = ANY($1) OR LOWER(o.slug) = ANY($2)\n GROUP BY o.id;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "team_id", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "icon_url", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "raw_icon_url", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "color", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int8Array", + "TextArray" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true + ] + }, + "hash": "06f51ba9bfc8ddf76c3ac2ad0a93849a3ff19649835bcdb1d44697d9a229725a" +} diff --git a/apps/labrinth/.sqlx/query-08baa3d4e15821d791a1981a6abf653991dcc0901cea49156cd202d10ed2968c.json b/apps/labrinth/.sqlx/query-08baa3d4e15821d791a1981a6abf653991dcc0901cea49156cd202d10ed2968c.json new file mode 100644 index 000000000..e810b2a24 --- /dev/null +++ b/apps/labrinth/.sqlx/query-08baa3d4e15821d791a1981a6abf653991dcc0901cea49156cd202d10ed2968c.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM users WHERE github_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "08baa3d4e15821d791a1981a6abf653991dcc0901cea49156cd202d10ed2968c" +} diff --git a/apps/labrinth/.sqlx/query-08f6bc80d18c171e54dd1db90e15569a02b526d708a9c918c90d79c764cb02fa.json b/apps/labrinth/.sqlx/query-08f6bc80d18c171e54dd1db90e15569a02b526d708a9c918c90d79c764cb02fa.json new file mode 100644 index 000000000..9d822ef17 --- /dev/null +++ b/apps/labrinth/.sqlx/query-08f6bc80d18c171e54dd1db90e15569a02b526d708a9c918c90d79c764cb02fa.json @@ -0,0 +1,44 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT c.id id, c.category category, c.icon icon, c.header category_header, pt.name project_type\n FROM categories c\n INNER JOIN project_types pt ON c.project_type = pt.id\n ORDER BY c.ordering, c.category\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "category", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "icon", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "category_header", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "project_type", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "08f6bc80d18c171e54dd1db90e15569a02b526d708a9c918c90d79c764cb02fa" +} diff --git a/apps/labrinth/.sqlx/query-09e411b2d15dd49a62f7b09fd1cebb487bd5fffe0858cd9e0356cea10fea83a3.json b/apps/labrinth/.sqlx/query-09e411b2d15dd49a62f7b09fd1cebb487bd5fffe0858cd9e0356cea10fea83a3.json new file mode 100644 index 000000000..59424ca7a --- /dev/null +++ b/apps/labrinth/.sqlx/query-09e411b2d15dd49a62f7b09fd1cebb487bd5fffe0858cd9e0356cea10fea83a3.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT mod_id, image_url, featured, ordering\n FROM mods_gallery\n WHERE mod_id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "image_url", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "featured", + "type_info": "Bool" + }, + { + "ordinal": 3, + "name": "ordering", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + true, + false + ] + }, + "hash": "09e411b2d15dd49a62f7b09fd1cebb487bd5fffe0858cd9e0356cea10fea83a3" +} diff --git a/apps/labrinth/.sqlx/query-09f4fba5c0c26457a7415a2196d4f5a9b2c72662b92cae8c96dda9557a024df7.json b/apps/labrinth/.sqlx/query-09f4fba5c0c26457a7415a2196d4f5a9b2c72662b92cae8c96dda9557a024df7.json new file mode 100644 index 000000000..57bc596c8 --- /dev/null +++ b/apps/labrinth/.sqlx/query-09f4fba5c0c26457a7415a2196d4f5a9b2c72662b92cae8c96dda9557a024df7.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET email = $1, email_verified = FALSE\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "09f4fba5c0c26457a7415a2196d4f5a9b2c72662b92cae8c96dda9557a024df7" +} diff --git a/apps/labrinth/.sqlx/query-0a1a470c12b84c7e171f0f51e8e541e9abe8bbee17fc441a5054e1dfd5607c05.json b/apps/labrinth/.sqlx/query-0a1a470c12b84c7e171f0f51e8e541e9abe8bbee17fc441a5054e1dfd5607c05.json new file mode 100644 index 000000000..4bf411aae --- /dev/null +++ b/apps/labrinth/.sqlx/query-0a1a470c12b84c7e171f0f51e8e541e9abe8bbee17fc441a5054e1dfd5607c05.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE versions\n SET name = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "0a1a470c12b84c7e171f0f51e8e541e9abe8bbee17fc441a5054e1dfd5607c05" +} diff --git a/apps/labrinth/.sqlx/query-0a31f7b04f4b68c556bdbfe373ef7945741f915d4ae657363fe67db46e8bd4cf.json b/apps/labrinth/.sqlx/query-0a31f7b04f4b68c556bdbfe373ef7945741f915d4ae657363fe67db46e8bd4cf.json new file mode 100644 index 000000000..07ef85af5 --- /dev/null +++ b/apps/labrinth/.sqlx/query-0a31f7b04f4b68c556bdbfe373ef7945741f915d4ae657363fe67db46e8bd4cf.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT SUM(amount)\n FROM payouts_values\n WHERE user_id = $1 AND date_available <= NOW()\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "sum", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "0a31f7b04f4b68c556bdbfe373ef7945741f915d4ae657363fe67db46e8bd4cf" +} diff --git a/apps/labrinth/.sqlx/query-0bd68c1b7c90ddcdde8c8bbd8362c6d0c7fb15e375d734bf34c365e71d623780.json b/apps/labrinth/.sqlx/query-0bd68c1b7c90ddcdde8c8bbd8362c6d0c7fb15e375d734bf34c365e71d623780.json new file mode 100644 index 000000000..0c3a38d49 --- /dev/null +++ b/apps/labrinth/.sqlx/query-0bd68c1b7c90ddcdde8c8bbd8362c6d0c7fb15e375d734bf34c365e71d623780.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT SUM(amount) amount, SUM(fee) fee\n FROM payouts\n WHERE user_id = $1 AND (status = 'success' OR status = 'in-transit')\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "amount", + "type_info": "Numeric" + }, + { + "ordinal": 1, + "name": "fee", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "0bd68c1b7c90ddcdde8c8bbd8362c6d0c7fb15e375d734bf34c365e71d623780" +} diff --git a/apps/labrinth/.sqlx/query-0c2addb0d7a87fa558821ff8e943bbb751fb2bdc22d1a5368f61cc7827586840.json b/apps/labrinth/.sqlx/query-0c2addb0d7a87fa558821ff8e943bbb751fb2bdc22d1a5368f61cc7827586840.json new file mode 100644 index 000000000..667bdcbfa --- /dev/null +++ b/apps/labrinth/.sqlx/query-0c2addb0d7a87fa558821ff8e943bbb751fb2bdc22d1a5368f61cc7827586840.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO files (id, version_id, url, filename, is_primary, size, file_type)\n VALUES ($1, $2, $3, $4, $5, $6, $7)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Varchar", + "Varchar", + "Bool", + "Int4", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "0c2addb0d7a87fa558821ff8e943bbb751fb2bdc22d1a5368f61cc7827586840" +} diff --git a/apps/labrinth/.sqlx/query-0c6b7e51b0b9115d95b5dbb9bb88a3e266b78ae9375a90261503c2cccd5bdf1b.json b/apps/labrinth/.sqlx/query-0c6b7e51b0b9115d95b5dbb9bb88a3e266b78ae9375a90261503c2cccd5bdf1b.json new file mode 100644 index 000000000..a90476b1d --- /dev/null +++ b/apps/labrinth/.sqlx/query-0c6b7e51b0b9115d95b5dbb9bb88a3e266b78ae9375a90261503c2cccd5bdf1b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET organization_id = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "0c6b7e51b0b9115d95b5dbb9bb88a3e266b78ae9375a90261503c2cccd5bdf1b" +} diff --git a/apps/labrinth/.sqlx/query-0d0f736e563abba7561c9b5de108c772541ad0049f706602d01460238f88ffd8.json b/apps/labrinth/.sqlx/query-0d0f736e563abba7561c9b5de108c772541ad0049f706602d01460238f88ffd8.json new file mode 100644 index 000000000..33e8733d6 --- /dev/null +++ b/apps/labrinth/.sqlx/query-0d0f736e563abba7561c9b5de108c772541ad0049f706602d01460238f88ffd8.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT mod_id,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types,\n ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games,\n ARRAY_AGG(DISTINCT lfl.loader_field_id) filter (where lfl.loader_field_id is not null) loader_fields\n FROM versions v\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n INNER JOIN loaders_project_types_games lptg ON lptg.loader_id = l.id AND lptg.project_type_id = pt.id\n INNER JOIN games g ON lptg.game_id = g.id\n LEFT JOIN loader_fields_loaders lfl ON lfl.loader_id = l.id\n WHERE v.id = ANY($1)\n GROUP BY mod_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "loaders", + "type_info": "VarcharArray" + }, + { + "ordinal": 2, + "name": "project_types", + "type_info": "VarcharArray" + }, + { + "ordinal": 3, + "name": "games", + "type_info": "VarcharArray" + }, + { + "ordinal": 4, + "name": "loader_fields", + "type_info": "Int4Array" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + null, + null, + null, + null + ] + }, + "hash": "0d0f736e563abba7561c9b5de108c772541ad0049f706602d01460238f88ffd8" +} diff --git a/apps/labrinth/.sqlx/query-0d23c47e3f6803078016c4ae5d52c47b7a0d23ca747c753a5710b3eb3cf7c621.json b/apps/labrinth/.sqlx/query-0d23c47e3f6803078016c4ae5d52c47b7a0d23ca747c753a5710b3eb3cf7c621.json new file mode 100644 index 000000000..1b911274b --- /dev/null +++ b/apps/labrinth/.sqlx/query-0d23c47e3f6803078016c4ae5d52c47b7a0d23ca747c753a5710b3eb3cf7c621.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET paypal_country = $1, paypal_email = $2, paypal_id = $3\n WHERE (id = $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "0d23c47e3f6803078016c4ae5d52c47b7a0d23ca747c753a5710b3eb3cf7c621" +} diff --git a/apps/labrinth/.sqlx/query-0f29bb5ba767ebd0669c860994e48e3cb2674f0d53f6c4ab85c79d46b04cbb40.json b/apps/labrinth/.sqlx/query-0f29bb5ba767ebd0669c860994e48e3cb2674f0d53f6c4ab85c79d46b04cbb40.json new file mode 100644 index 000000000..c5059cac4 --- /dev/null +++ b/apps/labrinth/.sqlx/query-0f29bb5ba767ebd0669c860994e48e3cb2674f0d53f6c4ab85c79d46b04cbb40.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "0f29bb5ba767ebd0669c860994e48e3cb2674f0d53f6c4ab85c79d46b04cbb40" +} diff --git a/apps/labrinth/.sqlx/query-0fb1cca8a2a37107104244953371fe2f8a5e6edd57f4b325c5842c6571eb16b4.json b/apps/labrinth/.sqlx/query-0fb1cca8a2a37107104244953371fe2f8a5e6edd57f4b325c5842c6571eb16b4.json new file mode 100644 index 000000000..99c932987 --- /dev/null +++ b/apps/labrinth/.sqlx/query-0fb1cca8a2a37107104244953371fe2f8a5e6edd57f4b325c5842c6571eb16b4.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(SELECT 1 FROM mod_follows mf WHERE mf.follower_id = $1 AND mf.mod_id = $2)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "0fb1cca8a2a37107104244953371fe2f8a5e6edd57f4b325c5842c6571eb16b4" +} diff --git a/apps/labrinth/.sqlx/query-105c44658c58739b933ae3ef0504c66b7926390c9f30e09c635161544177762a.json b/apps/labrinth/.sqlx/query-105c44658c58739b933ae3ef0504c66b7926390c9f30e09c635161544177762a.json new file mode 100644 index 000000000..807d85451 --- /dev/null +++ b/apps/labrinth/.sqlx/query-105c44658c58739b933ae3ef0504c66b7926390c9f30e09c635161544177762a.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE payouts\n SET status = $1\n WHERE platform_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "105c44658c58739b933ae3ef0504c66b7926390c9f30e09c635161544177762a" +} diff --git a/apps/labrinth/.sqlx/query-10f81e605c9ef63153f6879d507dc1d1bb38846e16d9fa6cbd6cceea2efbfd51.json b/apps/labrinth/.sqlx/query-10f81e605c9ef63153f6879d507dc1d1bb38846e16d9fa6cbd6cceea2efbfd51.json new file mode 100644 index 000000000..4caa17396 --- /dev/null +++ b/apps/labrinth/.sqlx/query-10f81e605c9ef63153f6879d507dc1d1bb38846e16d9fa6cbd6cceea2efbfd51.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT version_id, field_id, int_value, enum_value, string_value\n FROM version_fields\n WHERE version_id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "field_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "int_value", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "enum_value", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "string_value", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + true, + true, + true + ] + }, + "hash": "10f81e605c9ef63153f6879d507dc1d1bb38846e16d9fa6cbd6cceea2efbfd51" +} diff --git a/apps/labrinth/.sqlx/query-1209ffc1ffbea89f7060573275dc7325ac4d7b4885b6c1d1ec92998e6012e455.json b/apps/labrinth/.sqlx/query-1209ffc1ffbea89f7060573275dc7325ac4d7b4885b6c1d1ec92998e6012e455.json new file mode 100644 index 000000000..9e5f6b5a6 --- /dev/null +++ b/apps/labrinth/.sqlx/query-1209ffc1ffbea89f7060573275dc7325ac4d7b4885b6c1d1ec92998e6012e455.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods_gallery\n SET description = $2\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "1209ffc1ffbea89f7060573275dc7325ac4d7b4885b6c1d1ec92998e6012e455" +} diff --git a/apps/labrinth/.sqlx/query-1220d15a56dbf823eaa452fbafa17442ab0568bc81a31fa38e16e3df3278e5f9.json b/apps/labrinth/.sqlx/query-1220d15a56dbf823eaa452fbafa17442ab0568bc81a31fa38e16e3df3278e5f9.json new file mode 100644 index 000000000..15e40b66e --- /dev/null +++ b/apps/labrinth/.sqlx/query-1220d15a56dbf823eaa452fbafa17442ab0568bc81a31fa38e16e3df3278e5f9.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "1220d15a56dbf823eaa452fbafa17442ab0568bc81a31fa38e16e3df3278e5f9" +} diff --git a/apps/labrinth/.sqlx/query-1243d13d622a9970240c8f26b5031b4c68d08607f7a0142b662b53eb05b4723a.json b/apps/labrinth/.sqlx/query-1243d13d622a9970240c8f26b5031b4c68d08607f7a0142b662b53eb05b4723a.json new file mode 100644 index 000000000..0d9a9e0e1 --- /dev/null +++ b/apps/labrinth/.sqlx/query-1243d13d622a9970240c8f26b5031b4c68d08607f7a0142b662b53eb05b4723a.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT lfe.id, lfe.enum_name, lfe.ordering, lfe.hidable \n FROM loader_field_enums lfe\n WHERE lfe.enum_name = $1\n ORDER BY lfe.ordering ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "enum_name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "ordering", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "hidable", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + true, + false + ] + }, + "hash": "1243d13d622a9970240c8f26b5031b4c68d08607f7a0142b662b53eb05b4723a" +} diff --git a/apps/labrinth/.sqlx/query-124fbf0544ea6989d6dc5e840405dbc76d7385276a38ad79d9093c53c73bbde2.json b/apps/labrinth/.sqlx/query-124fbf0544ea6989d6dc5e840405dbc76d7385276a38ad79d9093c53c73bbde2.json new file mode 100644 index 000000000..ecf7f3118 --- /dev/null +++ b/apps/labrinth/.sqlx/query-124fbf0544ea6989d6dc5e840405dbc76d7385276a38ad79d9093c53c73bbde2.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET webhook_sent = TRUE\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "124fbf0544ea6989d6dc5e840405dbc76d7385276a38ad79d9093c53c73bbde2" +} diff --git a/apps/labrinth/.sqlx/query-1280600bf1bf7b4f0d19d0de0ca5adc8115925320edc35d189bf177ad2b7317a.json b/apps/labrinth/.sqlx/query-1280600bf1bf7b4f0d19d0de0ca5adc8115925320edc35d189bf177ad2b7317a.json new file mode 100644 index 000000000..a4888342e --- /dev/null +++ b/apps/labrinth/.sqlx/query-1280600bf1bf7b4f0d19d0de0ca5adc8115925320edc35d189bf177ad2b7317a.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT SUM(amount) from payouts_values\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "sum", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + null + ] + }, + "hash": "1280600bf1bf7b4f0d19d0de0ca5adc8115925320edc35d189bf177ad2b7317a" +} diff --git a/apps/labrinth/.sqlx/query-155361716f9d697c0d961b7bbad30e70698a8e5c9ceaa03b2091e058b58fb938.json b/apps/labrinth/.sqlx/query-155361716f9d697c0d961b7bbad30e70698a8e5c9ceaa03b2091e058b58fb938.json new file mode 100644 index 000000000..469174715 --- /dev/null +++ b/apps/labrinth/.sqlx/query-155361716f9d697c0d961b7bbad30e70698a8e5c9ceaa03b2091e058b58fb938.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT v.id id, v.mod_id mod_id FROM files f\n INNER JOIN versions v ON v.id = f.version_id\n WHERE f.url = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "mod_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "155361716f9d697c0d961b7bbad30e70698a8e5c9ceaa03b2091e058b58fb938" +} diff --git a/apps/labrinth/.sqlx/query-16049957962ded08751d5a4ddce2ffac17ecd486f61210c51a952508425d83e6.json b/apps/labrinth/.sqlx/query-16049957962ded08751d5a4ddce2ffac17ecd486f61210c51a952508425d83e6.json new file mode 100644 index 000000000..b5b24cbde --- /dev/null +++ b/apps/labrinth/.sqlx/query-16049957962ded08751d5a4ddce2ffac17ecd486f61210c51a952508425d83e6.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE versions\n SET changelog = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "16049957962ded08751d5a4ddce2ffac17ecd486f61210c51a952508425d83e6" +} diff --git a/apps/labrinth/.sqlx/query-164e5168aabe47d64f99ea851392c9d8479022cff360a610f185c342a24e88d8.json b/apps/labrinth/.sqlx/query-164e5168aabe47d64f99ea851392c9d8479022cff360a610f185c342a24e88d8.json new file mode 100644 index 000000000..327489789 --- /dev/null +++ b/apps/labrinth/.sqlx/query-164e5168aabe47d64f99ea851392c9d8479022cff360a610f185c342a24e88d8.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT mod_id FROM versions WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "164e5168aabe47d64f99ea851392c9d8479022cff360a610f185c342a24e88d8" +} diff --git a/apps/labrinth/.sqlx/query-165a4e679a0063dbf20832f298b4af3bb350f2e7128b0a91d6c1b8a25e56b0f6.json b/apps/labrinth/.sqlx/query-165a4e679a0063dbf20832f298b4af3bb350f2e7128b0a91d6c1b8a25e56b0f6.json new file mode 100644 index 000000000..786b9624a --- /dev/null +++ b/apps/labrinth/.sqlx/query-165a4e679a0063dbf20832f298b4af3bb350f2e7128b0a91d6c1b8a25e56b0f6.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM reports WHERE id = $1 AND reporter = $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "165a4e679a0063dbf20832f298b4af3bb350f2e7128b0a91d6c1b8a25e56b0f6" +} diff --git a/apps/labrinth/.sqlx/query-166d93a7d4ac629444eadcd51d793490220bbf1e503bf85ec97b37500c8f74aa.json b/apps/labrinth/.sqlx/query-166d93a7d4ac629444eadcd51d793490220bbf1e503bf85ec97b37500c8f74aa.json new file mode 100644 index 000000000..625c8fbd0 --- /dev/null +++ b/apps/labrinth/.sqlx/query-166d93a7d4ac629444eadcd51d793490220bbf1e503bf85ec97b37500c8f74aa.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM sessions WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "166d93a7d4ac629444eadcd51d793490220bbf1e503bf85ec97b37500c8f74aa" +} diff --git a/apps/labrinth/.sqlx/query-186d0e933ece20163915926293a01754ff571de4f06e521bb4f7c0207268e03b.json b/apps/labrinth/.sqlx/query-186d0e933ece20163915926293a01754ff571de4f06e521bb4f7c0207268e03b.json new file mode 100644 index 000000000..e31392b44 --- /dev/null +++ b/apps/labrinth/.sqlx/query-186d0e933ece20163915926293a01754ff571de4f06e521bb4f7c0207268e03b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM mods_links\n WHERE joining_mod_id = $1 AND joining_platform_id IN (\n SELECT id FROM link_platforms WHERE name = ANY($2)\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "186d0e933ece20163915926293a01754ff571de4f06e521bb4f7c0207268e03b" +} diff --git a/apps/labrinth/.sqlx/query-1931ff3846345c0af4e15c3a84dcbfc7c9cbb92c98d2e73634f611a1e5358c7a.json b/apps/labrinth/.sqlx/query-1931ff3846345c0af4e15c3a84dcbfc7c9cbb92c98d2e73634f611a1e5358c7a.json new file mode 100644 index 000000000..8f4c07259 --- /dev/null +++ b/apps/labrinth/.sqlx/query-1931ff3846345c0af4e15c3a84dcbfc7c9cbb92c98d2e73634f611a1e5358c7a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM threads WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "1931ff3846345c0af4e15c3a84dcbfc7c9cbb92c98d2e73634f611a1e5358c7a" +} diff --git a/apps/labrinth/.sqlx/query-19c7498a01f51b8220245a53490916191a153fa1fe14404d39ab2980e3386058.json b/apps/labrinth/.sqlx/query-19c7498a01f51b8220245a53490916191a153fa1fe14404d39ab2980e3386058.json new file mode 100644 index 000000000..46355e304 --- /dev/null +++ b/apps/labrinth/.sqlx/query-19c7498a01f51b8220245a53490916191a153fa1fe14404d39ab2980e3386058.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET organization_id = NULL\n WHERE (id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "19c7498a01f51b8220245a53490916191a153fa1fe14404d39ab2980e3386058" +} diff --git a/apps/labrinth/.sqlx/query-19dc22c4d6d14222f8e8bace74c2961761c53b7375460ade15af921754d5d7da.json b/apps/labrinth/.sqlx/query-19dc22c4d6d14222f8e8bace74c2961761c53b7375460ade15af921754d5d7da.json new file mode 100644 index 000000000..9254267e3 --- /dev/null +++ b/apps/labrinth/.sqlx/query-19dc22c4d6d14222f8e8bace74c2961761c53b7375460ade15af921754d5d7da.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET license = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "19dc22c4d6d14222f8e8bace74c2961761c53b7375460ade15af921754d5d7da" +} diff --git a/apps/labrinth/.sqlx/query-1b66b5d566aa6a969bacbb7897af829a569e13a619db295d2e6abcdb89fcac17.json b/apps/labrinth/.sqlx/query-1b66b5d566aa6a969bacbb7897af829a569e13a619db295d2e6abcdb89fcac17.json new file mode 100644 index 000000000..4ae6b1789 --- /dev/null +++ b/apps/labrinth/.sqlx/query-1b66b5d566aa6a969bacbb7897af829a569e13a619db295d2e6abcdb89fcac17.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO collections_mods (collection_id, mod_id)\n SELECT * FROM UNNEST ($1::int8[], $2::int8[])\n ON CONFLICT DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "1b66b5d566aa6a969bacbb7897af829a569e13a619db295d2e6abcdb89fcac17" +} diff --git a/apps/labrinth/.sqlx/query-1cefe4924d3c1f491739858ce844a22903d2dbe26f255219299f1833a10ce3d7.json b/apps/labrinth/.sqlx/query-1cefe4924d3c1f491739858ce844a22903d2dbe26f255219299f1833a10ce3d7.json new file mode 100644 index 000000000..6e0fd619f --- /dev/null +++ b/apps/labrinth/.sqlx/query-1cefe4924d3c1f491739858ce844a22903d2dbe26f255219299f1833a10ce3d7.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id FROM mods TABLESAMPLE SYSTEM_ROWS($1) WHERE status = ANY($2)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "TextArray" + ] + }, + "nullable": [ + false + ] + }, + "hash": "1cefe4924d3c1f491739858ce844a22903d2dbe26f255219299f1833a10ce3d7" +} diff --git a/apps/labrinth/.sqlx/query-1d09169d25a30f4495778b0695ac00e48d682db15963a01195bbd5f981178ce9.json b/apps/labrinth/.sqlx/query-1d09169d25a30f4495778b0695ac00e48d682db15963a01195bbd5f981178ce9.json new file mode 100644 index 000000000..a0c0ea4fc --- /dev/null +++ b/apps/labrinth/.sqlx/query-1d09169d25a30f4495778b0695ac00e48d682db15963a01195bbd5f981178ce9.json @@ -0,0 +1,33 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO users (\n id, username, email,\n avatar_url, raw_avatar_url, bio, created,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7,\n $8, $9, $10, $11, $12, $13,\n $14, $15, $16, $17, $18, $19, $20\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Varchar", + "Varchar", + "Text", + "Varchar", + "Timestamptz", + "Int8", + "Int8", + "Int8", + "Varchar", + "Int8", + "Varchar", + "Bool", + "Text", + "Text", + "Text", + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "1d09169d25a30f4495778b0695ac00e48d682db15963a01195bbd5f981178ce9" +} diff --git a/apps/labrinth/.sqlx/query-1d28c47c125cb4c6cff8ff373a56484d43a485f607a66b0753de07aceb02d274.json b/apps/labrinth/.sqlx/query-1d28c47c125cb4c6cff8ff373a56484d43a485f607a66b0753de07aceb02d274.json new file mode 100644 index 000000000..580b7013e --- /dev/null +++ b/apps/labrinth/.sqlx/query-1d28c47c125cb4c6cff8ff373a56484d43a485f607a66b0753de07aceb02d274.json @@ -0,0 +1,82 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT c.id id, c.name name, c.description description,\n c.icon_url icon_url, c.raw_icon_url raw_icon_url, c.color color, c.created created, c.user_id user_id,\n c.updated updated, c.status status,\n ARRAY_AGG(DISTINCT cm.mod_id) filter (where cm.mod_id is not null) mods\n FROM collections c\n LEFT JOIN collections_mods cm ON cm.collection_id = c.id\n WHERE c.id = ANY($1)\n GROUP BY c.id;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "description", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "icon_url", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "raw_icon_url", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "color", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 8, + "name": "updated", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "mods", + "type_info": "Int8Array" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + true, + true, + true, + true, + false, + false, + false, + false, + null + ] + }, + "hash": "1d28c47c125cb4c6cff8ff373a56484d43a485f607a66b0753de07aceb02d274" +} diff --git a/apps/labrinth/.sqlx/query-1d356243ac743720af11e6a49d17148618caa3be7cf33bc0859e51b06eede6e9.json b/apps/labrinth/.sqlx/query-1d356243ac743720af11e6a49d17148618caa3be7cf33bc0859e51b06eede6e9.json new file mode 100644 index 000000000..23b9b12a8 --- /dev/null +++ b/apps/labrinth/.sqlx/query-1d356243ac743720af11e6a49d17148618caa3be7cf33bc0859e51b06eede6e9.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT o.id FROM organizations o\n INNER JOIN team_members tm ON tm.team_id = o.team_id AND tm.accepted = TRUE\n WHERE tm.user_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "1d356243ac743720af11e6a49d17148618caa3be7cf33bc0859e51b06eede6e9" +} diff --git a/apps/labrinth/.sqlx/query-1ffce9b2d5c9fa6c8b9abce4bad9f9419c44ad6367b7463b979c91b9b5b4fea1.json b/apps/labrinth/.sqlx/query-1ffce9b2d5c9fa6c8b9abce4bad9f9419c44ad6367b7463b979c91b9b5b4fea1.json new file mode 100644 index 000000000..b487bee88 --- /dev/null +++ b/apps/labrinth/.sqlx/query-1ffce9b2d5c9fa6c8b9abce4bad9f9419c44ad6367b7463b979c91b9b5b4fea1.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM versions WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "1ffce9b2d5c9fa6c8b9abce4bad9f9419c44ad6367b7463b979c91b9b5b4fea1" +} diff --git a/apps/labrinth/.sqlx/query-2007ac2b16a1d3d8fd053d962ba8548613535255fa197059e86959adf372948d.json b/apps/labrinth/.sqlx/query-2007ac2b16a1d3d8fd053d962ba8548613535255fa197059e86959adf372948d.json new file mode 100644 index 000000000..32d8fc8d8 --- /dev/null +++ b/apps/labrinth/.sqlx/query-2007ac2b16a1d3d8fd053d962ba8548613535255fa197059e86959adf372948d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE threads_messages\n SET body = $2\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "2007ac2b16a1d3d8fd053d962ba8548613535255fa197059e86959adf372948d" +} diff --git a/apps/labrinth/.sqlx/query-2040e7f0a9b66bc12dc89007b07bab9da5fdd1b7ee72d411a9989deb4ee506bb.json b/apps/labrinth/.sqlx/query-2040e7f0a9b66bc12dc89007b07bab9da5fdd1b7ee72d411a9989deb4ee506bb.json new file mode 100644 index 000000000..42c7c646a --- /dev/null +++ b/apps/labrinth/.sqlx/query-2040e7f0a9b66bc12dc89007b07bab9da5fdd1b7ee72d411a9989deb4ee506bb.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE pats\n SET last_used = $2\n WHERE id IN\n (SELECT * FROM UNNEST($1::bigint[]))\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "2040e7f0a9b66bc12dc89007b07bab9da5fdd1b7ee72d411a9989deb4ee506bb" +} diff --git a/apps/labrinth/.sqlx/query-21c44c435bf9a6c138d40cd40d70ccecfd09d877e84f3fbe5cd190dd69d3b7e1.json b/apps/labrinth/.sqlx/query-21c44c435bf9a6c138d40cd40d70ccecfd09d877e84f3fbe5cd190dd69d3b7e1.json new file mode 100644 index 000000000..a706ac679 --- /dev/null +++ b/apps/labrinth/.sqlx/query-21c44c435bf9a6c138d40cd40d70ccecfd09d877e84f3fbe5cd190dd69d3b7e1.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT t.id, t.thread_type, t.mod_id, t.report_id,\n ARRAY_AGG(DISTINCT tm.user_id) filter (where tm.user_id is not null) members,\n JSONB_AGG(DISTINCT jsonb_build_object('id', tmsg.id, 'author_id', tmsg.author_id, 'thread_id', tmsg.thread_id, 'body', tmsg.body, 'created', tmsg.created, 'hide_identity', tmsg.hide_identity)) filter (where tmsg.id is not null) messages\n FROM threads t\n LEFT OUTER JOIN threads_messages tmsg ON tmsg.thread_id = t.id\n LEFT OUTER JOIN threads_members tm ON tm.thread_id = t.id\n WHERE t.id = ANY($1)\n GROUP BY t.id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "thread_type", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "report_id", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "members", + "type_info": "Int8Array" + }, + { + "ordinal": 5, + "name": "messages", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + true, + true, + null, + null + ] + }, + "hash": "21c44c435bf9a6c138d40cd40d70ccecfd09d877e84f3fbe5cd190dd69d3b7e1" +} diff --git a/apps/labrinth/.sqlx/query-220e59ae72edef546e3c7682ae91336bfba3e4230add1543910d80e846e0ad95.json b/apps/labrinth/.sqlx/query-220e59ae72edef546e3c7682ae91336bfba3e4230add1543910d80e846e0ad95.json new file mode 100644 index 000000000..1c57e13ca --- /dev/null +++ b/apps/labrinth/.sqlx/query-220e59ae72edef546e3c7682ae91336bfba3e4230add1543910d80e846e0ad95.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT m.id FROM mods m\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.accepted = TRUE\n WHERE tm.user_id = $1\n ORDER BY m.downloads DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "220e59ae72edef546e3c7682ae91336bfba3e4230add1543910d80e846e0ad95" +} diff --git a/apps/labrinth/.sqlx/query-2265be690ec4c6b03fd142bb8b81a5ebec67d09a08c05e9dba122f5acf2fc98a.json b/apps/labrinth/.sqlx/query-2265be690ec4c6b03fd142bb8b81a5ebec67d09a08c05e9dba122f5acf2fc98a.json new file mode 100644 index 000000000..de8d7cce7 --- /dev/null +++ b/apps/labrinth/.sqlx/query-2265be690ec4c6b03fd142bb8b81a5ebec67d09a08c05e9dba122f5acf2fc98a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM payouts_values WHERE created = $1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Timestamptz" + ] + }, + "nullable": [ + null + ] + }, + "hash": "2265be690ec4c6b03fd142bb8b81a5ebec67d09a08c05e9dba122f5acf2fc98a" +} diff --git a/apps/labrinth/.sqlx/query-232d7d0319c20dd5fff29331b067d6c6373bcff761a77958a2bb5f59068a83a5.json b/apps/labrinth/.sqlx/query-232d7d0319c20dd5fff29331b067d6c6373bcff761a77958a2bb5f59068a83a5.json new file mode 100644 index 000000000..545c4f504 --- /dev/null +++ b/apps/labrinth/.sqlx/query-232d7d0319c20dd5fff29331b067d6c6373bcff761a77958a2bb5f59068a83a5.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE team_members\n SET permissions = $1\n WHERE (team_id = $2 AND user_id = $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "232d7d0319c20dd5fff29331b067d6c6373bcff761a77958a2bb5f59068a83a5" +} diff --git a/apps/labrinth/.sqlx/query-25131559cb73a088000ab6379a769233440ade6c7511542da410065190d203fc.json b/apps/labrinth/.sqlx/query-25131559cb73a088000ab6379a769233440ade6c7511542da410065190d203fc.json new file mode 100644 index 000000000..a17f3a7a1 --- /dev/null +++ b/apps/labrinth/.sqlx/query-25131559cb73a088000ab6379a769233440ade6c7511542da410065190d203fc.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "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 + ] + }, + "hash": "25131559cb73a088000ab6379a769233440ade6c7511542da410065190d203fc" +} diff --git a/apps/labrinth/.sqlx/query-26210e28d63aa61e6bea453b720bc18674c8f19334bdbeb48244a941f10a5e17.json b/apps/labrinth/.sqlx/query-26210e28d63aa61e6bea453b720bc18674c8f19334bdbeb48244a941f10a5e17.json new file mode 100644 index 000000000..8d135fcbf --- /dev/null +++ b/apps/labrinth/.sqlx/query-26210e28d63aa61e6bea453b720bc18674c8f19334bdbeb48244a941f10a5e17.json @@ -0,0 +1,31 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT v.id version_id, v.mod_id mod_id\n FROM mods m\n INNER JOIN versions v ON m.id = v.mod_id AND (cardinality($4::varchar[]) = 0 OR v.version_type = ANY($4))\n INNER JOIN version_fields vf ON vf.field_id = 3 AND v.id = vf.version_id\n INNER JOIN loader_field_enum_values lfev ON vf.enum_value = lfev.id AND (cardinality($2::varchar[]) = 0 OR lfev.value = ANY($2::varchar[]))\n INNER JOIN loaders_versions lv ON lv.version_id = v.id\n INNER JOIN loaders l on lv.loader_id = l.id AND (cardinality($3::varchar[]) = 0 OR l.loader = ANY($3::varchar[]))\n WHERE m.id = ANY($1)\n ORDER BY v.date_published ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "mod_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array", + "VarcharArray", + "VarcharArray", + "VarcharArray" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "26210e28d63aa61e6bea453b720bc18674c8f19334bdbeb48244a941f10a5e17" +} diff --git a/apps/labrinth/.sqlx/query-2647c3691a9809ebe28d1780d3efe922d2308b35838a4c0a7947cb1a0b32f3e1.json b/apps/labrinth/.sqlx/query-2647c3691a9809ebe28d1780d3efe922d2308b35838a4c0a7947cb1a0b32f3e1.json new file mode 100644 index 000000000..4ac520830 --- /dev/null +++ b/apps/labrinth/.sqlx/query-2647c3691a9809ebe28d1780d3efe922d2308b35838a4c0a7947cb1a0b32f3e1.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET icon_url = $1, raw_icon_url = $2, color = $3\n WHERE (id = $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Int4", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "2647c3691a9809ebe28d1780d3efe922d2308b35838a4c0a7947cb1a0b32f3e1" +} diff --git a/apps/labrinth/.sqlx/query-268af672e8e475885c18da9edd81bac19f3a78a8a462bf9bb2dbe0a72c2f1ff7.json b/apps/labrinth/.sqlx/query-268af672e8e475885c18da9edd81bac19f3a78a8a462bf9bb2dbe0a72c2f1ff7.json new file mode 100644 index 000000000..aa5b4f467 --- /dev/null +++ b/apps/labrinth/.sqlx/query-268af672e8e475885c18da9edd81bac19f3a78a8a462bf9bb2dbe0a72c2f1ff7.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO mods_links (\n joining_mod_id, joining_platform_id, url\n )\n SELECT * FROM UNNEST($1::bigint[], $2::int[], $3::varchar[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "Int4Array", + "VarcharArray" + ] + }, + "nullable": [] + }, + "hash": "268af672e8e475885c18da9edd81bac19f3a78a8a462bf9bb2dbe0a72c2f1ff7" +} diff --git a/apps/labrinth/.sqlx/query-26c8f1dbb233bfcdc555344e9d41525ed4f616d17bb3aa76430e95492caa5c74.json b/apps/labrinth/.sqlx/query-26c8f1dbb233bfcdc555344e9d41525ed4f616d17bb3aa76430e95492caa5c74.json new file mode 100644 index 000000000..69fc76be5 --- /dev/null +++ b/apps/labrinth/.sqlx/query-26c8f1dbb233bfcdc555344e9d41525ed4f616d17bb3aa76430e95492caa5c74.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT m.team_id FROM organizations o\n INNER JOIN mods m ON m.organization_id = o.id\n WHERE o.id = $1 AND $1 IS NOT NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "team_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "26c8f1dbb233bfcdc555344e9d41525ed4f616d17bb3aa76430e95492caa5c74" +} diff --git a/apps/labrinth/.sqlx/query-285c089b43bf0225ba03e279f7a227c3483bae818d077efdc54e588b858c8760.json b/apps/labrinth/.sqlx/query-285c089b43bf0225ba03e279f7a227c3483bae818d077efdc54e588b858c8760.json new file mode 100644 index 000000000..0b54267b1 --- /dev/null +++ b/apps/labrinth/.sqlx/query-285c089b43bf0225ba03e279f7a227c3483bae818d077efdc54e588b858c8760.json @@ -0,0 +1,21 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO payouts (\n id, amount, fee, user_id, status, method, method_address, platform_id\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Numeric", + "Numeric", + "Int8", + "Varchar", + "Text", + "Text", + "Text" + ] + }, + "nullable": [] + }, + "hash": "285c089b43bf0225ba03e279f7a227c3483bae818d077efdc54e588b858c8760" +} diff --git a/apps/labrinth/.sqlx/query-285cdd452fff85480dde02119d224a6e422e4041deb6f640ab5159d55ba2789c.json b/apps/labrinth/.sqlx/query-285cdd452fff85480dde02119d224a6e422e4041deb6f640ab5159d55ba2789c.json new file mode 100644 index 000000000..72f8988c7 --- /dev/null +++ b/apps/labrinth/.sqlx/query-285cdd452fff85480dde02119d224a6e422e4041deb6f640ab5159d55ba2789c.json @@ -0,0 +1,82 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, user_id, price_id, amount, currency_code, status, due, last_attempt, charge_type, subscription_id, subscription_interval\n FROM charges\n WHERE (status = 'open' AND due < $1) OR (status = 'failed' AND last_attempt < $1 - INTERVAL '2 days')", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "price_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "amount", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "currency_code", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "due", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_attempt", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "charge_type", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "subscription_id", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "subscription_interval", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + true + ] + }, + "hash": "285cdd452fff85480dde02119d224a6e422e4041deb6f640ab5159d55ba2789c" +} diff --git a/apps/labrinth/.sqlx/query-294f264382ad55475b51776cd5d306c4867e8e6966ab79921bba69dc023f8337.json b/apps/labrinth/.sqlx/query-294f264382ad55475b51776cd5d306c4867e8e6966ab79921bba69dc023f8337.json new file mode 100644 index 000000000..0c1b70c0c --- /dev/null +++ b/apps/labrinth/.sqlx/query-294f264382ad55475b51776cd5d306c4867e8e6966ab79921bba69dc023f8337.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM threads_members\n WHERE thread_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "294f264382ad55475b51776cd5d306c4867e8e6966ab79921bba69dc023f8337" +} diff --git a/apps/labrinth/.sqlx/query-29e171bd746ac5dc1fabae4c9f81c3d1df4e69c860b7d0f6a907377664199217.json b/apps/labrinth/.sqlx/query-29e171bd746ac5dc1fabae4c9f81c3d1df4e69c860b7d0f6a907377664199217.json new file mode 100644 index 000000000..75f3f2d9f --- /dev/null +++ b/apps/labrinth/.sqlx/query-29e171bd746ac5dc1fabae4c9f81c3d1df4e69c860b7d0f6a907377664199217.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id FROM reports\n WHERE closed = FALSE\n ORDER BY created ASC\n LIMIT $1;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "29e171bd746ac5dc1fabae4c9f81c3d1df4e69c860b7d0f6a907377664199217" +} diff --git a/apps/labrinth/.sqlx/query-29fcff0f1d36bd1a9e0c8c4005209308f0c5f383e4e52ed8c6b989994ead32df.json b/apps/labrinth/.sqlx/query-29fcff0f1d36bd1a9e0c8c4005209308f0c5f383e4e52ed8c6b989994ead32df.json new file mode 100644 index 000000000..99f1949c5 --- /dev/null +++ b/apps/labrinth/.sqlx/query-29fcff0f1d36bd1a9e0c8c4005209308f0c5f383e4e52ed8c6b989994ead32df.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE team_members\n SET ordering = $1\n WHERE (team_id = $2 AND user_id = $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "29fcff0f1d36bd1a9e0c8c4005209308f0c5f383e4e52ed8c6b989994ead32df" +} diff --git a/apps/labrinth/.sqlx/query-2a043ce990f4a31c1a3e5c836af515027eaf1ff1bbf08310fd215d0e96c2cdb3.json b/apps/labrinth/.sqlx/query-2a043ce990f4a31c1a3e5c836af515027eaf1ff1bbf08310fd215d0e96c2cdb3.json new file mode 100644 index 000000000..b1f45c5c4 --- /dev/null +++ b/apps/labrinth/.sqlx/query-2a043ce990f4a31c1a3e5c836af515027eaf1ff1bbf08310fd215d0e96c2cdb3.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM uploaded_images\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "2a043ce990f4a31c1a3e5c836af515027eaf1ff1bbf08310fd215d0e96c2cdb3" +} diff --git a/apps/labrinth/.sqlx/query-2ae397b672260d1be8b54962e59e84976023c0cefa777b4ad86636bf43aa6920.json b/apps/labrinth/.sqlx/query-2ae397b672260d1be8b54962e59e84976023c0cefa777b4ad86636bf43aa6920.json new file mode 100644 index 000000000..cb05425af --- /dev/null +++ b/apps/labrinth/.sqlx/query-2ae397b672260d1be8b54962e59e84976023c0cefa777b4ad86636bf43aa6920.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM team_members\n WHERE team_id = $1 AND (is_owner = TRUE OR user_id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "2ae397b672260d1be8b54962e59e84976023c0cefa777b4ad86636bf43aa6920" +} diff --git a/apps/labrinth/.sqlx/query-2b097a9a1b24b9648d3558e348c7d8cd467e589504c6e754f1f6836203946590.json b/apps/labrinth/.sqlx/query-2b097a9a1b24b9648d3558e348c7d8cd467e589504c6e754f1f6836203946590.json new file mode 100644 index 000000000..1ccb38543 --- /dev/null +++ b/apps/labrinth/.sqlx/query-2b097a9a1b24b9648d3558e348c7d8cd467e589504c6e754f1f6836203946590.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT u.id \n FROM team_members\n INNER JOIN users u ON u.id = team_members.user_id\n WHERE team_id = $1 AND is_owner = TRUE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "2b097a9a1b24b9648d3558e348c7d8cd467e589504c6e754f1f6836203946590" +} diff --git a/apps/labrinth/.sqlx/query-2bfde0471537cbdadd768006ff616e7513703971f9d60211106933d3eb759ad2.json b/apps/labrinth/.sqlx/query-2bfde0471537cbdadd768006ff616e7513703971f9d60211106933d3eb759ad2.json new file mode 100644 index 000000000..92b22eeba --- /dev/null +++ b/apps/labrinth/.sqlx/query-2bfde0471537cbdadd768006ff616e7513703971f9d60211106933d3eb759ad2.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM team_members\n WHERE user_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "2bfde0471537cbdadd768006ff616e7513703971f9d60211106933d3eb759ad2" +} diff --git a/apps/labrinth/.sqlx/query-2d460f25461e95c744c835af5d67f8a7dd2438a46e3033611dfc0edd74fb9180.json b/apps/labrinth/.sqlx/query-2d460f25461e95c744c835af5d67f8a7dd2438a46e3033611dfc0edd74fb9180.json new file mode 100644 index 000000000..982fd3729 --- /dev/null +++ b/apps/labrinth/.sqlx/query-2d460f25461e95c744c835af5d67f8a7dd2438a46e3033611dfc0edd74fb9180.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT COUNT(v.id)\n FROM versions v\n INNER JOIN mods m on v.mod_id = m.id AND m.status = ANY($1)\n WHERE v.status = ANY($2)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "TextArray", + "TextArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "2d460f25461e95c744c835af5d67f8a7dd2438a46e3033611dfc0edd74fb9180" +} diff --git a/apps/labrinth/.sqlx/query-2d68489b978c7a19bbea6a9736d23ca253f4038c0e3e060720d669825073b242.json b/apps/labrinth/.sqlx/query-2d68489b978c7a19bbea6a9736d23ca253f4038c0e3e060720d669825073b242.json new file mode 100644 index 000000000..d4fe6a446 --- /dev/null +++ b/apps/labrinth/.sqlx/query-2d68489b978c7a19bbea6a9736d23ca253f4038c0e3e060720d669825073b242.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT code FROM user_backup_codes\n WHERE user_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "code", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "2d68489b978c7a19bbea6a9736d23ca253f4038c0e3e060720d669825073b242" +} diff --git a/apps/labrinth/.sqlx/query-2df7a4dd792736be89c9da00c039ad7e271f79f4c756daac79ce5622ccb50db2.json b/apps/labrinth/.sqlx/query-2df7a4dd792736be89c9da00c039ad7e271f79f4c756daac79ce5622ccb50db2.json new file mode 100644 index 000000000..7262d8cce --- /dev/null +++ b/apps/labrinth/.sqlx/query-2df7a4dd792736be89c9da00c039ad7e271f79f4c756daac79ce5622ccb50db2.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET google_id = $2\n WHERE (id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "2df7a4dd792736be89c9da00c039ad7e271f79f4c756daac79ce5622ccb50db2" +} diff --git a/apps/labrinth/.sqlx/query-2e14706127d9822d5a0d7ada02425d224805637d03eda1343e12111f7deba443.json b/apps/labrinth/.sqlx/query-2e14706127d9822d5a0d7ada02425d224805637d03eda1343e12111f7deba443.json new file mode 100644 index 000000000..033937233 --- /dev/null +++ b/apps/labrinth/.sqlx/query-2e14706127d9822d5a0d7ada02425d224805637d03eda1343e12111f7deba443.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM mods_categories\n WHERE joining_mod_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "2e14706127d9822d5a0d7ada02425d224805637d03eda1343e12111f7deba443" +} diff --git a/apps/labrinth/.sqlx/query-304aaf99f8909f8315b57fb42b4320de66e7abb2fe1e7bdd19d8c4fd7d5b06be.json b/apps/labrinth/.sqlx/query-304aaf99f8909f8315b57fb42b4320de66e7abb2fe1e7bdd19d8c4fd7d5b06be.json new file mode 100644 index 000000000..d8d1e94a9 --- /dev/null +++ b/apps/labrinth/.sqlx/query-304aaf99f8909f8315b57fb42b4320de66e7abb2fe1e7bdd19d8c4fd7d5b06be.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id FROM users\n WHERE email = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "304aaf99f8909f8315b57fb42b4320de66e7abb2fe1e7bdd19d8c4fd7d5b06be" +} diff --git a/apps/labrinth/.sqlx/query-3151420021b0c5a85f7c338e67be971915ff89073815e27fa6af5254db22dce8.json b/apps/labrinth/.sqlx/query-3151420021b0c5a85f7c338e67be971915ff89073815e27fa6af5254db22dce8.json new file mode 100644 index 000000000..9e422eb63 --- /dev/null +++ b/apps/labrinth/.sqlx/query-3151420021b0c5a85f7c338e67be971915ff89073815e27fa6af5254db22dce8.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO loaders_versions (loader_id, version_id)\n SELECT * FROM UNNEST($1::integer[], $2::bigint[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4Array", + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "3151420021b0c5a85f7c338e67be971915ff89073815e27fa6af5254db22dce8" +} diff --git a/apps/labrinth/.sqlx/query-3151ef71738a1f0d097aa14967d7b9eb1f24d4de1f81b80c4bd186427edc1399.json b/apps/labrinth/.sqlx/query-3151ef71738a1f0d097aa14967d7b9eb1f24d4de1f81b80c4bd186427edc1399.json new file mode 100644 index 000000000..057d8602d --- /dev/null +++ b/apps/labrinth/.sqlx/query-3151ef71738a1f0d097aa14967d7b9eb1f24d4de1f81b80c4bd186427edc1399.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT mel.id, mel.flame_project_id, mel.status status\n FROM moderation_external_licenses mel\n WHERE mel.flame_project_id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "flame_project_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "status", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int4Array" + ] + }, + "nullable": [ + false, + true, + false + ] + }, + "hash": "3151ef71738a1f0d097aa14967d7b9eb1f24d4de1f81b80c4bd186427edc1399" +} diff --git a/apps/labrinth/.sqlx/query-31de3bbe7e8768bf32ae60d1cd32a26032e5cdb0e7f64eb4ac791855d7256cc8.json b/apps/labrinth/.sqlx/query-31de3bbe7e8768bf32ae60d1cd32a26032e5cdb0e7f64eb4ac791855d7256cc8.json new file mode 100644 index 000000000..7da3de886 --- /dev/null +++ b/apps/labrinth/.sqlx/query-31de3bbe7e8768bf32ae60d1cd32a26032e5cdb0e7f64eb4ac791855d7256cc8.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT o.id, o.slug, o.name, o.team_id, o.description, o.icon_url, o.raw_icon_url, o.color\n FROM organizations o\n LEFT JOIN mods m ON m.organization_id = o.id\n WHERE m.id = $1\n GROUP BY o.id;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "team_id", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "description", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "icon_url", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "raw_icon_url", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "color", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true + ] + }, + "hash": "31de3bbe7e8768bf32ae60d1cd32a26032e5cdb0e7f64eb4ac791855d7256cc8" +} diff --git a/apps/labrinth/.sqlx/query-32f4aa1ab67fbdcd7187fbae475876bf3d3225ca7b4994440a67cbd6a7b610f6.json b/apps/labrinth/.sqlx/query-32f4aa1ab67fbdcd7187fbae475876bf3d3225ca7b4994440a67cbd6a7b610f6.json new file mode 100644 index 000000000..5fc3bd90c --- /dev/null +++ b/apps/labrinth/.sqlx/query-32f4aa1ab67fbdcd7187fbae475876bf3d3225ca7b4994440a67cbd6a7b610f6.json @@ -0,0 +1,94 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number,\n v.changelog changelog, v.date_published date_published, v.downloads downloads,\n v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering\n FROM versions v\n WHERE v.id = ANY($1);\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "author_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "version_name", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "version_number", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "changelog", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "date_published", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "downloads", + "type_info": "Int4" + }, + { + "ordinal": 8, + "name": "version_type", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "featured", + "type_info": "Bool" + }, + { + "ordinal": 10, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "requested_status", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "ordering", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true + ] + }, + "hash": "32f4aa1ab67fbdcd7187fbae475876bf3d3225ca7b4994440a67cbd6a7b610f6" +} diff --git a/apps/labrinth/.sqlx/query-332f1d23442b4a637d4bccf29363a7aa4da974a1b6c5752eb1b611da75030741.json b/apps/labrinth/.sqlx/query-332f1d23442b4a637d4bccf29363a7aa4da974a1b6c5752eb1b611da75030741.json new file mode 100644 index 000000000..38745859c --- /dev/null +++ b/apps/labrinth/.sqlx/query-332f1d23442b4a637d4bccf29363a7aa4da974a1b6c5752eb1b611da75030741.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM pats\n WHERE user_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "332f1d23442b4a637d4bccf29363a7aa4da974a1b6c5752eb1b611da75030741" +} diff --git a/apps/labrinth/.sqlx/query-33a965c7dc615d3b701c05299889357db8dd36d378850625d2602ba471af4885.json b/apps/labrinth/.sqlx/query-33a965c7dc615d3b701c05299889357db8dd36d378850625d2602ba471af4885.json new file mode 100644 index 000000000..07c48a4c6 --- /dev/null +++ b/apps/labrinth/.sqlx/query-33a965c7dc615d3b701c05299889357db8dd36d378850625d2602ba471af4885.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET downloads = downloads + $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "33a965c7dc615d3b701c05299889357db8dd36d378850625d2602ba471af4885" +} diff --git a/apps/labrinth/.sqlx/query-33b9f52f7c67bf6272d0ba90a25185238d12494c9526ab112a854799627a69d7.json b/apps/labrinth/.sqlx/query-33b9f52f7c67bf6272d0ba90a25185238d12494c9526ab112a854799627a69d7.json new file mode 100644 index 000000000..834c72239 --- /dev/null +++ b/apps/labrinth/.sqlx/query-33b9f52f7c67bf6272d0ba90a25185238d12494c9526ab112a854799627a69d7.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET email_verified = TRUE\n WHERE (id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "33b9f52f7c67bf6272d0ba90a25185238d12494c9526ab112a854799627a69d7" +} diff --git a/apps/labrinth/.sqlx/query-33fc96ac71cfa382991cfb153e89da1e9f43ebf5367c28b30c336b758222307b.json b/apps/labrinth/.sqlx/query-33fc96ac71cfa382991cfb153e89da1e9f43ebf5367c28b30c336b758222307b.json new file mode 100644 index 000000000..3815368b0 --- /dev/null +++ b/apps/labrinth/.sqlx/query-33fc96ac71cfa382991cfb153e89da1e9f43ebf5367c28b30c336b758222307b.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM loaders_versions\n WHERE loaders_versions.version_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "33fc96ac71cfa382991cfb153e89da1e9f43ebf5367c28b30c336b758222307b" +} diff --git a/apps/labrinth/.sqlx/query-34354792d062d1d4e4d80d28c1bbc3c9b0abe0c6fb03e0387f102903d2b397b5.json b/apps/labrinth/.sqlx/query-34354792d062d1d4e4d80d28c1bbc3c9b0abe0c6fb03e0387f102903d2b397b5.json new file mode 100644 index 000000000..34de2de38 --- /dev/null +++ b/apps/labrinth/.sqlx/query-34354792d062d1d4e4d80d28c1bbc3c9b0abe0c6fb03e0387f102903d2b397b5.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM users WHERE google_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "34354792d062d1d4e4d80d28c1bbc3c9b0abe0c6fb03e0387f102903d2b397b5" +} diff --git a/apps/labrinth/.sqlx/query-34fcb1b5ff6d29fbf4e617cdde9a296e9312aec9ff074dd39a83ee1ccb7678ff.json b/apps/labrinth/.sqlx/query-34fcb1b5ff6d29fbf4e617cdde9a296e9312aec9ff074dd39a83ee1ccb7678ff.json new file mode 100644 index 000000000..c1c37d27e --- /dev/null +++ b/apps/labrinth/.sqlx/query-34fcb1b5ff6d29fbf4e617cdde9a296e9312aec9ff074dd39a83ee1ccb7678ff.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT file_id, algorithm, encode(hash, 'escape') hash\n FROM hashes\n WHERE file_id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "file_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "algorithm", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "hash", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + null + ] + }, + "hash": "34fcb1b5ff6d29fbf4e617cdde9a296e9312aec9ff074dd39a83ee1ccb7678ff" +} diff --git a/apps/labrinth/.sqlx/query-352185977065c9903c2504081ef7c400075807785d4b62fdb48d0a45ca560f51.json b/apps/labrinth/.sqlx/query-352185977065c9903c2504081ef7c400075807785d4b62fdb48d0a45ca560f51.json new file mode 100644 index 000000000..db73020c3 --- /dev/null +++ b/apps/labrinth/.sqlx/query-352185977065c9903c2504081ef7c400075807785d4b62fdb48d0a45ca560f51.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM versions WHERE id = $1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "352185977065c9903c2504081ef7c400075807785d4b62fdb48d0a45ca560f51" +} diff --git a/apps/labrinth/.sqlx/query-3533fb2c185019bd2f4e5a89499ac19fec99452146cc80405b32d961ec50e456.json b/apps/labrinth/.sqlx/query-3533fb2c185019bd2f4e5a89499ac19fec99452146cc80405b32d961ec50e456.json new file mode 100644 index 000000000..164065304 --- /dev/null +++ b/apps/labrinth/.sqlx/query-3533fb2c185019bd2f4e5a89499ac19fec99452146cc80405b32d961ec50e456.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE team_members\n SET organization_permissions = $1\n WHERE (team_id = $2 AND user_id = $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "3533fb2c185019bd2f4e5a89499ac19fec99452146cc80405b32d961ec50e456" +} diff --git a/apps/labrinth/.sqlx/query-3689ca9f16fb80c55a0d2fd3c08ae4d0b70b92c8ab9a75afb96297748ec36bd4.json b/apps/labrinth/.sqlx/query-3689ca9f16fb80c55a0d2fd3c08ae4d0b70b92c8ab9a75afb96297748ec36bd4.json new file mode 100644 index 000000000..b1f9dab61 --- /dev/null +++ b/apps/labrinth/.sqlx/query-3689ca9f16fb80c55a0d2fd3c08ae4d0b70b92c8ab9a75afb96297748ec36bd4.json @@ -0,0 +1,71 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT f.id, f.version_id, v.mod_id, f.url, f.filename, f.is_primary, f.size, f.file_type,\n JSONB_AGG(DISTINCT jsonb_build_object('algorithm', h.algorithm, 'hash', encode(h.hash, 'escape'))) filter (where h.hash is not null) hashes\n FROM files f\n INNER JOIN versions v on v.id = f.version_id\n INNER JOIN hashes h on h.file_id = f.id\n WHERE h.algorithm = $1 AND h.hash = ANY($2)\n GROUP BY f.id, v.mod_id, v.date_published\n ORDER BY v.date_published\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "version_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "url", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "filename", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "is_primary", + "type_info": "Bool" + }, + { + "ordinal": 6, + "name": "size", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "file_type", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "hashes", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Text", + "ByteaArray" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + null + ] + }, + "hash": "3689ca9f16fb80c55a0d2fd3c08ae4d0b70b92c8ab9a75afb96297748ec36bd4" +} diff --git a/apps/labrinth/.sqlx/query-371048e45dd74c855b84cdb8a6a565ccbef5ad166ec9511ab20621c336446da6.json b/apps/labrinth/.sqlx/query-371048e45dd74c855b84cdb8a6a565ccbef5ad166ec9511ab20621c336446da6.json new file mode 100644 index 000000000..e67642945 --- /dev/null +++ b/apps/labrinth/.sqlx/query-371048e45dd74c855b84cdb8a6a565ccbef5ad166ec9511ab20621c336446da6.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET follows = follows - 1\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "371048e45dd74c855b84cdb8a6a565ccbef5ad166ec9511ab20621c336446da6" +} diff --git a/apps/labrinth/.sqlx/query-37da053e79c32173d7420edbe9d2f668c8bf7e00f3ca3ae4abd60a7aa36c943b.json b/apps/labrinth/.sqlx/query-37da053e79c32173d7420edbe9d2f668c8bf7e00f3ca3ae4abd60a7aa36c943b.json new file mode 100644 index 000000000..248d9ceaf --- /dev/null +++ b/apps/labrinth/.sqlx/query-37da053e79c32173d7420edbe9d2f668c8bf7e00f3ca3ae4abd60a7aa36c943b.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, metadata, unitary\n FROM products\n WHERE id = ANY($1::bigint[])", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "metadata", + "type_info": "Jsonb" + }, + { + "ordinal": 2, + "name": "unitary", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "37da053e79c32173d7420edbe9d2f668c8bf7e00f3ca3ae4abd60a7aa36c943b" +} diff --git a/apps/labrinth/.sqlx/query-38429340be03cc5f539d9d14c156e6b6710051d2826b53a5ccfdbd231af964ca.json b/apps/labrinth/.sqlx/query-38429340be03cc5f539d9d14c156e6b6710051d2826b53a5ccfdbd231af964ca.json new file mode 100644 index 000000000..f963b76eb --- /dev/null +++ b/apps/labrinth/.sqlx/query-38429340be03cc5f539d9d14c156e6b6710051d2826b53a5ccfdbd231af964ca.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM collections WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "38429340be03cc5f539d9d14c156e6b6710051d2826b53a5ccfdbd231af964ca" +} diff --git a/apps/labrinth/.sqlx/query-389a48e2e5dec4370013f3cada400d83da45214984f8a2ec48f3f1343a28240e.json b/apps/labrinth/.sqlx/query-389a48e2e5dec4370013f3cada400d83da45214984f8a2ec48f3f1343a28240e.json new file mode 100644 index 000000000..31403a772 --- /dev/null +++ b/apps/labrinth/.sqlx/query-389a48e2e5dec4370013f3cada400d83da45214984f8a2ec48f3f1343a28240e.json @@ -0,0 +1,78 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.is_owner, tm.permissions, tm.organization_permissions, tm.accepted, tm.payouts_split, tm.ordering\n FROM organizations o\n INNER JOIN team_members tm ON tm.team_id = o.team_id AND user_id = $2 AND accepted = ANY($3)\n WHERE o.id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "team_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "role", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "is_owner", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "permissions", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "organization_permissions", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "accepted", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "payouts_split", + "type_info": "Numeric" + }, + { + "ordinal": 9, + "name": "ordering", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "BoolArray" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false, + false, + false + ] + }, + "hash": "389a48e2e5dec4370013f3cada400d83da45214984f8a2ec48f3f1343a28240e" +} diff --git a/apps/labrinth/.sqlx/query-38f651362c0778254c28ccd4745af611f4deb6e72f52b8cf65d0515f0fe14779.json b/apps/labrinth/.sqlx/query-38f651362c0778254c28ccd4745af611f4deb6e72f52b8cf65d0515f0fe14779.json new file mode 100644 index 000000000..0170be2e8 --- /dev/null +++ b/apps/labrinth/.sqlx/query-38f651362c0778254c28ccd4745af611f4deb6e72f52b8cf65d0515f0fe14779.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(SELECT 1 FROM organizations WHERE LOWER(slug) = LOWER($1))\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "38f651362c0778254c28ccd4745af611f4deb6e72f52b8cf65d0515f0fe14779" +} diff --git a/apps/labrinth/.sqlx/query-3af747b5543a5a9b10dcce0a1eb9c2a1926dd5a507fe0d8b7f52d8ccc7fcd0af.json b/apps/labrinth/.sqlx/query-3af747b5543a5a9b10dcce0a1eb9c2a1926dd5a507fe0d8b7f52d8ccc7fcd0af.json new file mode 100644 index 000000000..c46f206ad --- /dev/null +++ b/apps/labrinth/.sqlx/query-3af747b5543a5a9b10dcce0a1eb9c2a1926dd5a507fe0d8b7f52d8ccc7fcd0af.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods_gallery\n SET featured = $2\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "3af747b5543a5a9b10dcce0a1eb9c2a1926dd5a507fe0d8b7f52d8ccc7fcd0af" +} diff --git a/apps/labrinth/.sqlx/query-3baabc9f08401801fa290866888c540746fc50c1d79911f08f3322b605ce5c30.json b/apps/labrinth/.sqlx/query-3baabc9f08401801fa290866888c540746fc50c1d79911f08f3322b605ce5c30.json new file mode 100644 index 000000000..5aef00800 --- /dev/null +++ b/apps/labrinth/.sqlx/query-3baabc9f08401801fa290866888c540746fc50c1d79911f08f3322b605ce5c30.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id FROM mods\n WHERE status = $1\n ORDER BY queued ASC\n LIMIT $2;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "3baabc9f08401801fa290866888c540746fc50c1d79911f08f3322b605ce5c30" +} diff --git a/apps/labrinth/.sqlx/query-3bdcbfa5abe43cc9b4f996f147277a7f6921cca00f82cad0ef5d85032c761a36.json b/apps/labrinth/.sqlx/query-3bdcbfa5abe43cc9b4f996f147277a7f6921cca00f82cad0ef5d85032c761a36.json new file mode 100644 index 000000000..241b2bad0 --- /dev/null +++ b/apps/labrinth/.sqlx/query-3bdcbfa5abe43cc9b4f996f147277a7f6921cca00f82cad0ef5d85032c761a36.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM mod_follows\n WHERE follower_id = $1 AND mod_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "3bdcbfa5abe43cc9b4f996f147277a7f6921cca00f82cad0ef5d85032c761a36" +} diff --git a/apps/labrinth/.sqlx/query-3c50c07cddcc936a60ff1583b36fe0682da965b4aaf4579d08e2fe5468e71a3d.json b/apps/labrinth/.sqlx/query-3c50c07cddcc936a60ff1583b36fe0682da965b4aaf4579d08e2fe5468e71a3d.json new file mode 100644 index 000000000..28967a2a7 --- /dev/null +++ b/apps/labrinth/.sqlx/query-3c50c07cddcc936a60ff1583b36fe0682da965b4aaf4579d08e2fe5468e71a3d.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM collections_mods\n WHERE mod_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "3c50c07cddcc936a60ff1583b36fe0682da965b4aaf4579d08e2fe5468e71a3d" +} diff --git a/apps/labrinth/.sqlx/query-3c875a8a1c03432f258040c436e19dbab6e78bd1789dc70f445578c779c7b995.json b/apps/labrinth/.sqlx/query-3c875a8a1c03432f258040c436e19dbab6e78bd1789dc70f445578c779c7b995.json new file mode 100644 index 000000000..5d9191035 --- /dev/null +++ b/apps/labrinth/.sqlx/query-3c875a8a1c03432f258040c436e19dbab6e78bd1789dc70f445578c779c7b995.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT mel.id, mel.flame_project_id, mel.status status\n FROM moderation_external_licenses mel\n WHERE mel.flame_project_id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "flame_project_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "status", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int4Array" + ] + }, + "nullable": [ + false, + true, + false + ] + }, + "hash": "3c875a8a1c03432f258040c436e19dbab6e78bd1789dc70f445578c779c7b995" +} diff --git a/apps/labrinth/.sqlx/query-3f8bd0280a59ad4561ca652cebc7734a9af0e944f1671df71f9f4e25d835ffd9.json b/apps/labrinth/.sqlx/query-3f8bd0280a59ad4561ca652cebc7734a9af0e944f1671df71f9f4e25d835ffd9.json new file mode 100644 index 000000000..bd8065151 --- /dev/null +++ b/apps/labrinth/.sqlx/query-3f8bd0280a59ad4561ca652cebc7734a9af0e944f1671df71f9f4e25d835ffd9.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM oauth_client_authorizations WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "3f8bd0280a59ad4561ca652cebc7734a9af0e944f1671df71f9f4e25d835ffd9" +} diff --git a/apps/labrinth/.sqlx/query-4016797b6c41821d98dd024859088459c9b7157697b2b2fa745bdd21916a4ffc.json b/apps/labrinth/.sqlx/query-4016797b6c41821d98dd024859088459c9b7157697b2b2fa745bdd21916a4ffc.json new file mode 100644 index 000000000..811341804 --- /dev/null +++ b/apps/labrinth/.sqlx/query-4016797b6c41821d98dd024859088459c9b7157697b2b2fa745bdd21916a4ffc.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT joining_mod_id as mod_id, joining_platform_id as platform_id, lp.name as platform_name, url, lp.donation as donation\n FROM mods_links ml\n INNER JOIN mods m ON ml.joining_mod_id = m.id\n INNER JOIN link_platforms lp ON ml.joining_platform_id = lp.id\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "platform_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "platform_name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "url", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "donation", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8Array", + "TextArray" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "4016797b6c41821d98dd024859088459c9b7157697b2b2fa745bdd21916a4ffc" +} diff --git a/apps/labrinth/.sqlx/query-4065dd9c79f220db9daa3e162d791eeeddd9b913fb848602dca5e35570a56b27.json b/apps/labrinth/.sqlx/query-4065dd9c79f220db9daa3e162d791eeeddd9b913fb848602dca5e35570a56b27.json new file mode 100644 index 000000000..de3d7a517 --- /dev/null +++ b/apps/labrinth/.sqlx/query-4065dd9c79f220db9daa3e162d791eeeddd9b913fb848602dca5e35570a56b27.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM products WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "4065dd9c79f220db9daa3e162d791eeeddd9b913fb848602dca5e35570a56b27" +} diff --git a/apps/labrinth/.sqlx/query-40f7c5bec98fe3503d6bd6db2eae5a4edb8d5d6efda9b9dc124f344ae5c60e08.json b/apps/labrinth/.sqlx/query-40f7c5bec98fe3503d6bd6db2eae5a4edb8d5d6efda9b9dc124f344ae5c60e08.json new file mode 100644 index 000000000..4190c1eb4 --- /dev/null +++ b/apps/labrinth/.sqlx/query-40f7c5bec98fe3503d6bd6db2eae5a4edb8d5d6efda9b9dc124f344ae5c60e08.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM mods_categories\n WHERE joining_mod_id = $1 AND is_additional = TRUE\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "40f7c5bec98fe3503d6bd6db2eae5a4edb8d5d6efda9b9dc124f344ae5c60e08" +} diff --git a/apps/labrinth/.sqlx/query-4198ea701f956dd65cab1a8e60b5b67df45f8c07bb70e3c4f090d943feafdaf3.json b/apps/labrinth/.sqlx/query-4198ea701f956dd65cab1a8e60b5b67df45f8c07bb70e3c4f090d943feafdaf3.json new file mode 100644 index 000000000..be792ea9a --- /dev/null +++ b/apps/labrinth/.sqlx/query-4198ea701f956dd65cab1a8e60b5b67df45f8c07bb70e3c4f090d943feafdaf3.json @@ -0,0 +1,37 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT mod_id, SUM(amount) amount_sum, DATE_BIN($4::interval, created, TIMESTAMP '2001-01-01') AS interval_start\n FROM payouts_values\n WHERE mod_id = ANY($1) AND created BETWEEN $2 AND $3\n GROUP by mod_id, interval_start ORDER BY interval_start\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "amount_sum", + "type_info": "Numeric" + }, + { + "ordinal": 2, + "name": "interval_start", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8Array", + "Timestamptz", + "Timestamptz", + "Interval" + ] + }, + "nullable": [ + true, + null, + null + ] + }, + "hash": "4198ea701f956dd65cab1a8e60b5b67df45f8c07bb70e3c4f090d943feafdaf3" +} diff --git a/apps/labrinth/.sqlx/query-4242d5d0a6d1d4f22172cdfb06ef47189b69b52e01d00ec2effe580b42eda717.json b/apps/labrinth/.sqlx/query-4242d5d0a6d1d4f22172cdfb06ef47189b69b52e01d00ec2effe580b42eda717.json new file mode 100644 index 000000000..579c5ca30 --- /dev/null +++ b/apps/labrinth/.sqlx/query-4242d5d0a6d1d4f22172cdfb06ef47189b69b52e01d00ec2effe580b42eda717.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET password = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "4242d5d0a6d1d4f22172cdfb06ef47189b69b52e01d00ec2effe580b42eda717" +} diff --git a/apps/labrinth/.sqlx/query-43d4eafdbcb449a56551d3d6edeba0d6e196fa6539e3f9df107c23a74ba962af.json b/apps/labrinth/.sqlx/query-43d4eafdbcb449a56551d3d6edeba0d6e196fa6539e3f9df107c23a74ba962af.json new file mode 100644 index 000000000..c66e86018 --- /dev/null +++ b/apps/labrinth/.sqlx/query-43d4eafdbcb449a56551d3d6edeba0d6e196fa6539e3f9df107c23a74ba962af.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT id, enum_id, value, ordering, created, metadata\n FROM loader_field_enum_values lfev\n WHERE id = ANY($1)\n ORDER BY enum_id, ordering, created DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "enum_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "value", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "ordering", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "metadata", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int4Array" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + true + ] + }, + "hash": "43d4eafdbcb449a56551d3d6edeba0d6e196fa6539e3f9df107c23a74ba962af" +} diff --git a/apps/labrinth/.sqlx/query-457493bd11254ba1c9f81c47f15e8154053ae4e90e319d34a940fb73e33a69d4.json b/apps/labrinth/.sqlx/query-457493bd11254ba1c9f81c47f15e8154053ae4e90e319d34a940fb73e33a69d4.json new file mode 100644 index 000000000..33d196a94 --- /dev/null +++ b/apps/labrinth/.sqlx/query-457493bd11254ba1c9f81c47f15e8154053ae4e90e319d34a940fb73e33a69d4.json @@ -0,0 +1,82 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, user_id, price_id, amount, currency_code, status, due, last_attempt, charge_type, subscription_id, subscription_interval\n FROM charges\n WHERE user_id = $1 ORDER BY due DESC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "price_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "amount", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "currency_code", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "due", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_attempt", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "charge_type", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "subscription_id", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "subscription_interval", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + true + ] + }, + "hash": "457493bd11254ba1c9f81c47f15e8154053ae4e90e319d34a940fb73e33a69d4" +} diff --git a/apps/labrinth/.sqlx/query-45e3f7d3ae0396c0b0196ed959f9b60c57b7c57390758ddcc58fb2e0f276a426.json b/apps/labrinth/.sqlx/query-45e3f7d3ae0396c0b0196ed959f9b60c57b7c57390758ddcc58fb2e0f276a426.json new file mode 100644 index 000000000..5c3f9294f --- /dev/null +++ b/apps/labrinth/.sqlx/query-45e3f7d3ae0396c0b0196ed959f9b60c57b7c57390758ddcc58fb2e0f276a426.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE uploaded_images\n SET thread_message_id = $1\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "45e3f7d3ae0396c0b0196ed959f9b60c57b7c57390758ddcc58fb2e0f276a426" +} diff --git a/apps/labrinth/.sqlx/query-4838777a8ef4371f4f5bb4f4f038bb6d041455f0849a3972a5418d75165ae9c7.json b/apps/labrinth/.sqlx/query-4838777a8ef4371f4f5bb4f4f038bb6d041455f0849a3972a5418d75165ae9c7.json new file mode 100644 index 000000000..52ede1dd5 --- /dev/null +++ b/apps/labrinth/.sqlx/query-4838777a8ef4371f4f5bb4f4f038bb6d041455f0849a3972a5418d75165ae9c7.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT d.dependency_id, COALESCE(vd.mod_id, 0) mod_id, d.mod_dependency_id\n FROM versions v\n INNER JOIN dependencies d ON d.dependent_id = v.id\n LEFT JOIN versions vd ON d.dependency_id = vd.id\n WHERE v.mod_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "dependency_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "mod_dependency_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + true, + null, + true + ] + }, + "hash": "4838777a8ef4371f4f5bb4f4f038bb6d041455f0849a3972a5418d75165ae9c7" +} diff --git a/apps/labrinth/.sqlx/query-494610831c7303d9cb3c033ff94af80fcc428014795c719fcafe1272db2c0177.json b/apps/labrinth/.sqlx/query-494610831c7303d9cb3c033ff94af80fcc428014795c719fcafe1272db2c0177.json new file mode 100644 index 000000000..9f0aa93c3 --- /dev/null +++ b/apps/labrinth/.sqlx/query-494610831c7303d9cb3c033ff94af80fcc428014795c719fcafe1272db2c0177.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET stripe_customer_id = $1\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "494610831c7303d9cb3c033ff94af80fcc428014795c719fcafe1272db2c0177" +} diff --git a/apps/labrinth/.sqlx/query-49d5360751072cc2cb5954cdecb31044f41d210dd64bbbb5e7c2347acc2304e9.json b/apps/labrinth/.sqlx/query-49d5360751072cc2cb5954cdecb31044f41d210dd64bbbb5e7c2347acc2304e9.json new file mode 100644 index 000000000..7348f7595 --- /dev/null +++ b/apps/labrinth/.sqlx/query-49d5360751072cc2cb5954cdecb31044f41d210dd64bbbb5e7c2347acc2304e9.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM oauth_client_redirect_uris WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "49d5360751072cc2cb5954cdecb31044f41d210dd64bbbb5e7c2347acc2304e9" +} diff --git a/apps/labrinth/.sqlx/query-4b089a5d9408febe64af1cf5f78cc11c33f6702637c03c1ed9d24df8a847f91a.json b/apps/labrinth/.sqlx/query-4b089a5d9408febe64af1cf5f78cc11c33f6702637c03c1ed9d24df8a847f91a.json new file mode 100644 index 000000000..0d70b3f71 --- /dev/null +++ b/apps/labrinth/.sqlx/query-4b089a5d9408febe64af1cf5f78cc11c33f6702637c03c1ed9d24df8a847f91a.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE collections\n SET name = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "4b089a5d9408febe64af1cf5f78cc11c33f6702637c03c1ed9d24df8a847f91a" +} diff --git a/apps/labrinth/.sqlx/query-4c20de487460718c8c523fce28716900f5195d12397eba09a3c437d194ff2b2e.json b/apps/labrinth/.sqlx/query-4c20de487460718c8c523fce28716900f5195d12397eba09a3c437d194ff2b2e.json new file mode 100644 index 000000000..a3616d8d8 --- /dev/null +++ b/apps/labrinth/.sqlx/query-4c20de487460718c8c523fce28716900f5195d12397eba09a3c437d194ff2b2e.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT mod_id FROM versions WHERE id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "4c20de487460718c8c523fce28716900f5195d12397eba09a3c437d194ff2b2e" +} diff --git a/apps/labrinth/.sqlx/query-4c9e2190e2a68ffc093a69aaa1fc9384957138f57ac9cd85cbc6179613c13a08.json b/apps/labrinth/.sqlx/query-4c9e2190e2a68ffc093a69aaa1fc9384957138f57ac9cd85cbc6179613c13a08.json new file mode 100644 index 000000000..d60dc92d9 --- /dev/null +++ b/apps/labrinth/.sqlx/query-4c9e2190e2a68ffc093a69aaa1fc9384957138f57ac9cd85cbc6179613c13a08.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM mods WHERE id = $1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "4c9e2190e2a68ffc093a69aaa1fc9384957138f57ac9cd85cbc6179613c13a08" +} diff --git a/apps/labrinth/.sqlx/query-4cb9fe3dbb2cbfe30a49487f896fb7890f726af2ff11da53f450a88c3dc5fc64.json b/apps/labrinth/.sqlx/query-4cb9fe3dbb2cbfe30a49487f896fb7890f726af2ff11da53f450a88c3dc5fc64.json new file mode 100644 index 000000000..0397073b8 --- /dev/null +++ b/apps/labrinth/.sqlx/query-4cb9fe3dbb2cbfe30a49487f896fb7890f726af2ff11da53f450a88c3dc5fc64.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT encode(mef.sha1, 'escape') sha1, mel.status status\n FROM moderation_external_files mef\n INNER JOIN moderation_external_licenses mel ON mef.external_license_id = mel.id\n WHERE mef.sha1 = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "sha1", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "status", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "ByteaArray" + ] + }, + "nullable": [ + null, + false + ] + }, + "hash": "4cb9fe3dbb2cbfe30a49487f896fb7890f726af2ff11da53f450a88c3dc5fc64" +} diff --git a/apps/labrinth/.sqlx/query-4d752ee3f43a1bf34d71c4391c9232537e0941294951f383ea8fa61e9d83fc96.json b/apps/labrinth/.sqlx/query-4d752ee3f43a1bf34d71c4391c9232537e0941294951f383ea8fa61e9d83fc96.json new file mode 100644 index 000000000..002ba1727 --- /dev/null +++ b/apps/labrinth/.sqlx/query-4d752ee3f43a1bf34d71c4391c9232537e0941294951f383ea8fa61e9d83fc96.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM mods_gallery\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [] + }, + "hash": "4d752ee3f43a1bf34d71c4391c9232537e0941294951f383ea8fa61e9d83fc96" +} diff --git a/apps/labrinth/.sqlx/query-4de9fdac1831320d744cb6edd0cfe6e1fd670348f0ef881f67d7aec1acd6571f.json b/apps/labrinth/.sqlx/query-4de9fdac1831320d744cb6edd0cfe6e1fd670348f0ef881f67d7aec1acd6571f.json new file mode 100644 index 000000000..6baa687a8 --- /dev/null +++ b/apps/labrinth/.sqlx/query-4de9fdac1831320d744cb6edd0cfe6e1fd670348f0ef881f67d7aec1acd6571f.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM payouts\n WHERE user_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "4de9fdac1831320d744cb6edd0cfe6e1fd670348f0ef881f67d7aec1acd6571f" +} diff --git a/apps/labrinth/.sqlx/query-4e9f9eafbfd705dfc94571018cb747245a98ea61bad3fae4b3ce284229d99955.json b/apps/labrinth/.sqlx/query-4e9f9eafbfd705dfc94571018cb747245a98ea61bad3fae4b3ce284229d99955.json new file mode 100644 index 000000000..be21454a5 --- /dev/null +++ b/apps/labrinth/.sqlx/query-4e9f9eafbfd705dfc94571018cb747245a98ea61bad3fae4b3ce284229d99955.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET description = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "4e9f9eafbfd705dfc94571018cb747245a98ea61bad3fae4b3ce284229d99955" +} diff --git a/apps/labrinth/.sqlx/query-4fb5bd341369b4beb6b4a88de296b608ea5441a96db9f7360fbdccceb4628202.json b/apps/labrinth/.sqlx/query-4fb5bd341369b4beb6b4a88de296b608ea5441a96db9f7360fbdccceb4628202.json new file mode 100644 index 000000000..279dbcf75 --- /dev/null +++ b/apps/labrinth/.sqlx/query-4fb5bd341369b4beb6b4a88de296b608ea5441a96db9f7360fbdccceb4628202.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET slug = LOWER($1)\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "4fb5bd341369b4beb6b4a88de296b608ea5441a96db9f7360fbdccceb4628202" +} diff --git a/apps/labrinth/.sqlx/query-505543e3e6aa69a9b9d4ee50938a305e86949fefc8ba11f4b10992fa507d136c.json b/apps/labrinth/.sqlx/query-505543e3e6aa69a9b9d4ee50938a305e86949fefc8ba11f4b10992fa507d136c.json new file mode 100644 index 000000000..a1f5ae56a --- /dev/null +++ b/apps/labrinth/.sqlx/query-505543e3e6aa69a9b9d4ee50938a305e86949fefc8ba11f4b10992fa507d136c.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT r.id FROM reports r\n WHERE r.user_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "505543e3e6aa69a9b9d4ee50938a305e86949fefc8ba11f4b10992fa507d136c" +} diff --git a/apps/labrinth/.sqlx/query-50e65ff5df36ec59c5cf4470db908d7b04cf1ffb1640398ac518510178fd9a34.json b/apps/labrinth/.sqlx/query-50e65ff5df36ec59c5cf4470db908d7b04cf1ffb1640398ac518510178fd9a34.json new file mode 100644 index 000000000..8011e9563 --- /dev/null +++ b/apps/labrinth/.sqlx/query-50e65ff5df36ec59c5cf4470db908d7b04cf1ffb1640398ac518510178fd9a34.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO threads (\n id, thread_type, mod_id, report_id\n )\n VALUES (\n $1, $2, $3, $4\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "50e65ff5df36ec59c5cf4470db908d7b04cf1ffb1640398ac518510178fd9a34" +} diff --git a/apps/labrinth/.sqlx/query-51e53fa0cc848654300067d4f598da49a16f5ce3aa046d1b08628566b80ce88f.json b/apps/labrinth/.sqlx/query-51e53fa0cc848654300067d4f598da49a16f5ce3aa046d1b08628566b80ce88f.json new file mode 100644 index 000000000..4e8fd72a7 --- /dev/null +++ b/apps/labrinth/.sqlx/query-51e53fa0cc848654300067d4f598da49a16f5ce3aa046d1b08628566b80ce88f.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM user_backup_codes\n WHERE user_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "51e53fa0cc848654300067d4f598da49a16f5ce3aa046d1b08628566b80ce88f" +} diff --git a/apps/labrinth/.sqlx/query-520b6b75e79245e9ec19dbe5c30f041d8081eb317a21b122c0d61d7b13f58072.json b/apps/labrinth/.sqlx/query-520b6b75e79245e9ec19dbe5c30f041d8081eb317a21b122c0d61d7b13f58072.json new file mode 100644 index 000000000..893e3ac9c --- /dev/null +++ b/apps/labrinth/.sqlx/query-520b6b75e79245e9ec19dbe5c30f041d8081eb317a21b122c0d61d7b13f58072.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM notifications WHERE id = ANY($1))", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + null + ] + }, + "hash": "520b6b75e79245e9ec19dbe5c30f041d8081eb317a21b122c0d61d7b13f58072" +} diff --git a/apps/labrinth/.sqlx/query-52d947ff389e17378ff6d978916a85c2d6e7ef3cd4f09f4d5f070a6c33619cd9.json b/apps/labrinth/.sqlx/query-52d947ff389e17378ff6d978916a85c2d6e7ef3cd4f09f4d5f070a6c33619cd9.json new file mode 100644 index 000000000..841d785c5 --- /dev/null +++ b/apps/labrinth/.sqlx/query-52d947ff389e17378ff6d978916a85c2d6e7ef3cd4f09f4d5f070a6c33619cd9.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM user_backup_codes\n WHERE user_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "52d947ff389e17378ff6d978916a85c2d6e7ef3cd4f09f4d5f070a6c33619cd9" +} diff --git a/apps/labrinth/.sqlx/query-531e556fa37da6b74aab2e539bcc13b66ced32452152f20e8fa5df4b3d14f292.json b/apps/labrinth/.sqlx/query-531e556fa37da6b74aab2e539bcc13b66ced32452152f20e8fa5df4b3d14f292.json new file mode 100644 index 000000000..d05f826d1 --- /dev/null +++ b/apps/labrinth/.sqlx/query-531e556fa37da6b74aab2e539bcc13b66ced32452152f20e8fa5df4b3d14f292.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT t.id\n FROM threads t\n INNER JOIN reports r ON t.report_id = r.id AND (r.user_id = $1 OR r.reporter = $1)\n WHERE report_id IS NOT NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "531e556fa37da6b74aab2e539bcc13b66ced32452152f20e8fa5df4b3d14f292" +} diff --git a/apps/labrinth/.sqlx/query-53845c65f224a5ab0526d2d02806bd82ee2c40bb32bbb1a72c3a625853caeed8.json b/apps/labrinth/.sqlx/query-53845c65f224a5ab0526d2d02806bd82ee2c40bb32bbb1a72c3a625853caeed8.json new file mode 100644 index 000000000..52ca102b1 --- /dev/null +++ b/apps/labrinth/.sqlx/query-53845c65f224a5ab0526d2d02806bd82ee2c40bb32bbb1a72c3a625853caeed8.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, product_id, prices, currency_code\n FROM products_prices\n WHERE product_id = ANY($1::bigint[])", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "product_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "prices", + "type_info": "Jsonb" + }, + { + "ordinal": 3, + "name": "currency_code", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "53845c65f224a5ab0526d2d02806bd82ee2c40bb32bbb1a72c3a625853caeed8" +} diff --git a/apps/labrinth/.sqlx/query-53a8966ac345cc334ad65ea907be81af74e90b1217696c7eedcf8a8e3fca736e.json b/apps/labrinth/.sqlx/query-53a8966ac345cc334ad65ea907be81af74e90b1217696c7eedcf8a8e3fca736e.json new file mode 100644 index 000000000..8d539e73b --- /dev/null +++ b/apps/labrinth/.sqlx/query-53a8966ac345cc334ad65ea907be81af74e90b1217696c7eedcf8a8e3fca736e.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE versions\n SET version_number = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "53a8966ac345cc334ad65ea907be81af74e90b1217696c7eedcf8a8e3fca736e" +} diff --git a/apps/labrinth/.sqlx/query-53c50911a9e98ac6d0c83fec4117d340e5970b27edc76f21b903f362329a6542.json b/apps/labrinth/.sqlx/query-53c50911a9e98ac6d0c83fec4117d340e5970b27edc76f21b903f362329a6542.json new file mode 100644 index 000000000..f932bd386 --- /dev/null +++ b/apps/labrinth/.sqlx/query-53c50911a9e98ac6d0c83fec4117d340e5970b27edc76f21b903f362329a6542.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT version_id, field_id, int_value, enum_value, string_value\n FROM version_fields\n WHERE version_id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "field_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "int_value", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "enum_value", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "string_value", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + true, + true, + true + ] + }, + "hash": "53c50911a9e98ac6d0c83fec4117d340e5970b27edc76f21b903f362329a6542" +} diff --git a/apps/labrinth/.sqlx/query-54c6b31858b7bf383f9b7118583592d694ab2d80ac0f132c5b9bc42603f336c6.json b/apps/labrinth/.sqlx/query-54c6b31858b7bf383f9b7118583592d694ab2d80ac0f132c5b9bc42603f336c6.json new file mode 100644 index 000000000..d29360d16 --- /dev/null +++ b/apps/labrinth/.sqlx/query-54c6b31858b7bf383f9b7118583592d694ab2d80ac0f132c5b9bc42603f336c6.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE versions\n SET ordering = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "54c6b31858b7bf383f9b7118583592d694ab2d80ac0f132c5b9bc42603f336c6" +} diff --git a/apps/labrinth/.sqlx/query-54f62537bf546f8ad8185357a8294b6dd666f2e27e192937b82f25895e9bc975.json b/apps/labrinth/.sqlx/query-54f62537bf546f8ad8185357a8294b6dd666f2e27e192937b82f25895e9bc975.json new file mode 100644 index 000000000..dd409b268 --- /dev/null +++ b/apps/labrinth/.sqlx/query-54f62537bf546f8ad8185357a8294b6dd666f2e27e192937b82f25895e9bc975.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id\n FROM collections\n WHERE user_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "54f62537bf546f8ad8185357a8294b6dd666f2e27e192937b82f25895e9bc975" +} diff --git a/apps/labrinth/.sqlx/query-568ca221aaacb7222bf5099f59ae6bc3d96fbffaf91394115c29029ae9ea4108.json b/apps/labrinth/.sqlx/query-568ca221aaacb7222bf5099f59ae6bc3d96fbffaf91394115c29029ae9ea4108.json new file mode 100644 index 000000000..3db0dd3fb --- /dev/null +++ b/apps/labrinth/.sqlx/query-568ca221aaacb7222bf5099f59ae6bc3d96fbffaf91394115c29029ae9ea4108.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods_gallery\n SET name = $2\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "568ca221aaacb7222bf5099f59ae6bc3d96fbffaf91394115c29029ae9ea4108" +} diff --git a/apps/labrinth/.sqlx/query-56d7b62fc05c77f228e46dbfe4eaca81b445a7f5a44e52a0526a1b57bd7a8c9d.json b/apps/labrinth/.sqlx/query-56d7b62fc05c77f228e46dbfe4eaca81b445a7f5a44e52a0526a1b57bd7a8c9d.json new file mode 100644 index 000000000..53bd4798f --- /dev/null +++ b/apps/labrinth/.sqlx/query-56d7b62fc05c77f228e46dbfe4eaca81b445a7f5a44e52a0526a1b57bd7a8c9d.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)\n ON CONFLICT (id)\n DO UPDATE\n SET status = EXCLUDED.status,\n last_attempt = EXCLUDED.last_attempt,\n due = EXCLUDED.due,\n subscription_id = EXCLUDED.subscription_id,\n subscription_interval = EXCLUDED.subscription_interval\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Int8", + "Text", + "Text", + "Varchar", + "Timestamptz", + "Timestamptz", + "Int8", + "Text" + ] + }, + "nullable": [] + }, + "hash": "56d7b62fc05c77f228e46dbfe4eaca81b445a7f5a44e52a0526a1b57bd7a8c9d" +} diff --git a/apps/labrinth/.sqlx/query-5722c001ba705d5d6237037512081b0a006aca2fc6f1db71e5b47ec9d68a82b9.json b/apps/labrinth/.sqlx/query-5722c001ba705d5d6237037512081b0a006aca2fc6f1db71e5b47ec9d68a82b9.json new file mode 100644 index 000000000..685fa94fc --- /dev/null +++ b/apps/labrinth/.sqlx/query-5722c001ba705d5d6237037512081b0a006aca2fc6f1db71e5b47ec9d68a82b9.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET paypal_id = $2\n WHERE (id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + }, + "nullable": [] + }, + "hash": "5722c001ba705d5d6237037512081b0a006aca2fc6f1db71e5b47ec9d68a82b9" +} diff --git a/apps/labrinth/.sqlx/query-5942afe6eef37e3833a9a25f943a864d9eff046fcb74780fb49ffda96eabc2a9.json b/apps/labrinth/.sqlx/query-5942afe6eef37e3833a9a25f943a864d9eff046fcb74780fb49ffda96eabc2a9.json new file mode 100644 index 000000000..a52939d1e --- /dev/null +++ b/apps/labrinth/.sqlx/query-5942afe6eef37e3833a9a25f943a864d9eff046fcb74780fb49ffda96eabc2a9.json @@ -0,0 +1,30 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT m.id id, m.team_id team_id FROM team_members tm\n INNER JOIN mods m ON m.team_id = tm.team_id\n LEFT JOIN organizations o ON o.team_id = tm.team_id\n WHERE tm.team_id = ANY($1) AND tm.user_id = $3\n UNION\n SELECT m.id id, m.team_id team_id FROM team_members tm\n INNER JOIN organizations o ON o.team_id = tm.team_id\n INNER JOIN mods m ON m.organization_id = o.id\n WHERE o.id = ANY($2) AND tm.user_id = $3\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "team_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array", + "Int8Array", + "Int8" + ] + }, + "nullable": [ + null, + null + ] + }, + "hash": "5942afe6eef37e3833a9a25f943a864d9eff046fcb74780fb49ffda96eabc2a9" +} diff --git a/apps/labrinth/.sqlx/query-5944eb30a2bc0381c4d15eb1cf6ccf6e146a54381f2da8ab224960430e951976.json b/apps/labrinth/.sqlx/query-5944eb30a2bc0381c4d15eb1cf6ccf6e146a54381f2da8ab224960430e951976.json new file mode 100644 index 000000000..53b1146be --- /dev/null +++ b/apps/labrinth/.sqlx/query-5944eb30a2bc0381c4d15eb1cf6ccf6e146a54381f2da8ab224960430e951976.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id FROM threads\n WHERE report_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "5944eb30a2bc0381c4d15eb1cf6ccf6e146a54381f2da8ab224960430e951976" +} diff --git a/apps/labrinth/.sqlx/query-5b6249b416a36ffc357aaf5060ebe960d5f54fe42f29f132de0e2925b5182f7f.json b/apps/labrinth/.sqlx/query-5b6249b416a36ffc357aaf5060ebe960d5f54fe42f29f132de0e2925b5182f7f.json new file mode 100644 index 000000000..de84730e9 --- /dev/null +++ b/apps/labrinth/.sqlx/query-5b6249b416a36ffc357aaf5060ebe960d5f54fe42f29f132de0e2925b5182f7f.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE oauth_clients\n SET name = $1, icon_url = $2, raw_icon_url = $3, max_scopes = $4, url = $5, description = $6\n WHERE (id = $7)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Text", + "Text", + "Int8", + "Text", + "Text", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "5b6249b416a36ffc357aaf5060ebe960d5f54fe42f29f132de0e2925b5182f7f" +} diff --git a/apps/labrinth/.sqlx/query-5c3b340d278c356b6bc2cd7110e5093a7d1ad982ae0f468f8fff7c54e4e6603a.json b/apps/labrinth/.sqlx/query-5c3b340d278c356b6bc2cd7110e5093a7d1ad982ae0f468f8fff7c54e4e6603a.json new file mode 100644 index 000000000..454ffaa46 --- /dev/null +++ b/apps/labrinth/.sqlx/query-5c3b340d278c356b6bc2cd7110e5093a7d1ad982ae0f468f8fff7c54e4e6603a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id FROM project_types\n WHERE name = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "5c3b340d278c356b6bc2cd7110e5093a7d1ad982ae0f468f8fff7c54e4e6603a" +} diff --git a/apps/labrinth/.sqlx/query-5c5cac91f61b0cd98d2d986e2d22e5a6b220bdd39f98520385f4ea84b3ffeeed.json b/apps/labrinth/.sqlx/query-5c5cac91f61b0cd98d2d986e2d22e5a6b220bdd39f98520385f4ea84b3ffeeed.json new file mode 100644 index 000000000..587aae1fe --- /dev/null +++ b/apps/labrinth/.sqlx/query-5c5cac91f61b0cd98d2d986e2d22e5a6b220bdd39f98520385f4ea84b3ffeeed.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE versions\n SET status = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "5c5cac91f61b0cd98d2d986e2d22e5a6b220bdd39f98520385f4ea84b3ffeeed" +} diff --git a/apps/labrinth/.sqlx/query-5ca43f2fddda27ad857f230a3427087f1e58150949adc6273156718730c10f69.json b/apps/labrinth/.sqlx/query-5ca43f2fddda27ad857f230a3427087f1e58150949adc6273156718730c10f69.json new file mode 100644 index 000000000..1f43efed1 --- /dev/null +++ b/apps/labrinth/.sqlx/query-5ca43f2fddda27ad857f230a3427087f1e58150949adc6273156718730c10f69.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET role = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "5ca43f2fddda27ad857f230a3427087f1e58150949adc6273156718730c10f69" +} diff --git a/apps/labrinth/.sqlx/query-5cb1aea414894c4720c7297a2bd8b411871a26b0163c4e87fba3b8988a0becff.json b/apps/labrinth/.sqlx/query-5cb1aea414894c4720c7297a2bd8b411871a26b0163c4e87fba3b8988a0becff.json new file mode 100644 index 000000000..4b7060f6f --- /dev/null +++ b/apps/labrinth/.sqlx/query-5cb1aea414894c4720c7297a2bd8b411871a26b0163c4e87fba3b8988a0becff.json @@ -0,0 +1,88 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n clients.id as \"id!\",\n clients.name as \"name!\",\n clients.icon_url as \"icon_url?\",\n clients.raw_icon_url as \"raw_icon_url?\",\n clients.max_scopes as \"max_scopes!\",\n clients.secret_hash as \"secret_hash!\",\n clients.created as \"created!\",\n clients.created_by as \"created_by!\",\n clients.url as \"url?\",\n clients.description as \"description?\",\n uris.uri_ids as \"uri_ids?\",\n uris.uri_vals as \"uri_vals?\"\n FROM oauth_clients clients\n LEFT JOIN (\n SELECT client_id, array_agg(id) as uri_ids, array_agg(uri) as uri_vals\n FROM oauth_client_redirect_uris\n GROUP BY client_id\n ) uris ON clients.id = uris.client_id\n WHERE clients.id = ANY($1::bigint[])", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "icon_url?", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "raw_icon_url?", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "max_scopes!", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "secret_hash!", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "created!", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "created_by!", + "type_info": "Int8" + }, + { + "ordinal": 8, + "name": "url?", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "description?", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "uri_ids?", + "type_info": "Int8Array" + }, + { + "ordinal": 11, + "name": "uri_vals?", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + true, + true, + true, + true, + true, + true, + true, + true, + true, + true, + null, + null + ] + }, + "hash": "5cb1aea414894c4720c7297a2bd8b411871a26b0163c4e87fba3b8988a0becff" +} diff --git a/apps/labrinth/.sqlx/query-5cce25ecda748f570de563bd3b312075dd09094b44d2aea2910011eb56778ee0.json b/apps/labrinth/.sqlx/query-5cce25ecda748f570de563bd3b312075dd09094b44d2aea2910011eb56778ee0.json new file mode 100644 index 000000000..b97a63587 --- /dev/null +++ b/apps/labrinth/.sqlx/query-5cce25ecda748f570de563bd3b312075dd09094b44d2aea2910011eb56778ee0.json @@ -0,0 +1,155 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, email,\n avatar_url, raw_avatar_url, username, bio,\n created, role, badges,\n github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id,\n email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email,\n venmo_handle, stripe_customer_id\n FROM users\n WHERE id = ANY($1) OR LOWER(username) = ANY($2)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "email", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "avatar_url", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "raw_avatar_url", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "username", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "bio", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "role", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "badges", + "type_info": "Int8" + }, + { + "ordinal": 9, + "name": "github_id", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "discord_id", + "type_info": "Int8" + }, + { + "ordinal": 11, + "name": "gitlab_id", + "type_info": "Int8" + }, + { + "ordinal": 12, + "name": "google_id", + "type_info": "Varchar" + }, + { + "ordinal": 13, + "name": "steam_id", + "type_info": "Int8" + }, + { + "ordinal": 14, + "name": "microsoft_id", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "email_verified", + "type_info": "Bool" + }, + { + "ordinal": 16, + "name": "password", + "type_info": "Text" + }, + { + "ordinal": 17, + "name": "totp_secret", + "type_info": "Varchar" + }, + { + "ordinal": 18, + "name": "paypal_id", + "type_info": "Text" + }, + { + "ordinal": 19, + "name": "paypal_country", + "type_info": "Text" + }, + { + "ordinal": 20, + "name": "paypal_email", + "type_info": "Text" + }, + { + "ordinal": 21, + "name": "venmo_handle", + "type_info": "Text" + }, + { + "ordinal": 22, + "name": "stripe_customer_id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8Array", + "TextArray" + ] + }, + "nullable": [ + false, + true, + true, + true, + false, + true, + false, + false, + false, + true, + true, + true, + true, + true, + true, + false, + true, + true, + true, + true, + true, + true, + true + ] + }, + "hash": "5cce25ecda748f570de563bd3b312075dd09094b44d2aea2910011eb56778ee0" +} diff --git a/apps/labrinth/.sqlx/query-5d0b9862547d0920a5fd5ccc3460c6bf28bc7c0b1b832274ada6ce5d48b705a9.json b/apps/labrinth/.sqlx/query-5d0b9862547d0920a5fd5ccc3460c6bf28bc7c0b1b832274ada6ce5d48b705a9.json new file mode 100644 index 000000000..a7319f8f4 --- /dev/null +++ b/apps/labrinth/.sqlx/query-5d0b9862547d0920a5fd5ccc3460c6bf28bc7c0b1b832274ada6ce5d48b705a9.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM users WHERE gitlab_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "5d0b9862547d0920a5fd5ccc3460c6bf28bc7c0b1b832274ada6ce5d48b705a9" +} diff --git a/apps/labrinth/.sqlx/query-5d7425cfa91e332bf7cc14aa5c300b997e941c49757606f6b906cb5e060d3179.json b/apps/labrinth/.sqlx/query-5d7425cfa91e332bf7cc14aa5c300b997e941c49757606f6b906cb5e060d3179.json new file mode 100644 index 000000000..07f7d8e60 --- /dev/null +++ b/apps/labrinth/.sqlx/query-5d7425cfa91e332bf7cc14aa5c300b997e941c49757606f6b906cb5e060d3179.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET updated = NOW()\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "5d7425cfa91e332bf7cc14aa5c300b997e941c49757606f6b906cb5e060d3179" +} diff --git a/apps/labrinth/.sqlx/query-5db4b2678406a9977c1e37409920804e0225181befe8b784dea48e37e30fedcc.json b/apps/labrinth/.sqlx/query-5db4b2678406a9977c1e37409920804e0225181befe8b784dea48e37e30fedcc.json new file mode 100644 index 000000000..0dcd2037d --- /dev/null +++ b/apps/labrinth/.sqlx/query-5db4b2678406a9977c1e37409920804e0225181befe8b784dea48e37e30fedcc.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO mods_links (joining_mod_id, joining_platform_id, url)\n VALUES ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int4", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "5db4b2678406a9977c1e37409920804e0225181befe8b784dea48e37e30fedcc" +} diff --git a/apps/labrinth/.sqlx/query-5dd9503c98266d44dfef73dda81f0051789280b78d1b0fb4de509ac6ccfcb86a.json b/apps/labrinth/.sqlx/query-5dd9503c98266d44dfef73dda81f0051789280b78d1b0fb4de509ac6ccfcb86a.json new file mode 100644 index 000000000..eb13ec34d --- /dev/null +++ b/apps/labrinth/.sqlx/query-5dd9503c98266d44dfef73dda81f0051789280b78d1b0fb4de509ac6ccfcb86a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM users WHERE steam_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "5dd9503c98266d44dfef73dda81f0051789280b78d1b0fb4de509ac6ccfcb86a" +} diff --git a/apps/labrinth/.sqlx/query-5e6c981d0f6b42ee926f59dbe3e42fa9a2351eab68ddacbd91aa3cdc9c5cff7a.json b/apps/labrinth/.sqlx/query-5e6c981d0f6b42ee926f59dbe3e42fa9a2351eab68ddacbd91aa3cdc9c5cff7a.json new file mode 100644 index 000000000..d3d2f9b69 --- /dev/null +++ b/apps/labrinth/.sqlx/query-5e6c981d0f6b42ee926f59dbe3e42fa9a2351eab68ddacbd91aa3cdc9c5cff7a.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT version_id,\n ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types\n FROM versions v\n INNER JOIN loaders_versions lv ON v.id = lv.version_id\n INNER JOIN loaders l ON lv.loader_id = l.id\n INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id\n INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id\n WHERE v.id = ANY($1)\n GROUP BY version_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "loaders", + "type_info": "VarcharArray" + }, + { + "ordinal": 2, + "name": "project_types", + "type_info": "VarcharArray" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + null, + null + ] + }, + "hash": "5e6c981d0f6b42ee926f59dbe3e42fa9a2351eab68ddacbd91aa3cdc9c5cff7a" +} diff --git a/apps/labrinth/.sqlx/query-5ee2dc5cda9bfc0395da5a4ebf234093e9b8135db5e4a0258b00fa16fb825faa.json b/apps/labrinth/.sqlx/query-5ee2dc5cda9bfc0395da5a4ebf234093e9b8135db5e4a0258b00fa16fb825faa.json new file mode 100644 index 000000000..e5dff8444 --- /dev/null +++ b/apps/labrinth/.sqlx/query-5ee2dc5cda9bfc0395da5a4ebf234093e9b8135db5e4a0258b00fa16fb825faa.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT name FROM project_types\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "5ee2dc5cda9bfc0395da5a4ebf234093e9b8135db5e4a0258b00fa16fb825faa" +} diff --git a/apps/labrinth/.sqlx/query-5f2d1161981df3d0fd1588580015525db13b06266314448b7fa400d298920c86.json b/apps/labrinth/.sqlx/query-5f2d1161981df3d0fd1588580015525db13b06266314448b7fa400d298920c86.json new file mode 100644 index 000000000..471e2ef73 --- /dev/null +++ b/apps/labrinth/.sqlx/query-5f2d1161981df3d0fd1588580015525db13b06266314448b7fa400d298920c86.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods_gallery\n SET ordering = $2\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "5f2d1161981df3d0fd1588580015525db13b06266314448b7fa400d298920c86" +} diff --git a/apps/labrinth/.sqlx/query-611107b00394bb57122d6a39b8d559abd50399d46f174a99ccfd9f76c3430892.json b/apps/labrinth/.sqlx/query-611107b00394bb57122d6a39b8d559abd50399d46f174a99ccfd9f76c3430892.json new file mode 100644 index 000000000..c1fd659c2 --- /dev/null +++ b/apps/labrinth/.sqlx/query-611107b00394bb57122d6a39b8d559abd50399d46f174a99ccfd9f76c3430892.json @@ -0,0 +1,21 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO organizations (id, slug, name, team_id, description, icon_url, raw_icon_url, color)\n VALUES ($1, $2, $3, $4, $5, $6, $7, $8)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Text", + "Int8", + "Text", + "Varchar", + "Text", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "611107b00394bb57122d6a39b8d559abd50399d46f174a99ccfd9f76c3430892" +} diff --git a/apps/labrinth/.sqlx/query-61a7f29e024bf2f1368370e3f6e8ef70317c7e8545b5b6d4235f21164948ba27.json b/apps/labrinth/.sqlx/query-61a7f29e024bf2f1368370e3f6e8ef70317c7e8545b5b6d4235f21164948ba27.json new file mode 100644 index 000000000..1dc4c8ece --- /dev/null +++ b/apps/labrinth/.sqlx/query-61a7f29e024bf2f1368370e3f6e8ef70317c7e8545b5b6d4235f21164948ba27.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods_gallery\n SET featured = $2\n WHERE mod_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "61a7f29e024bf2f1368370e3f6e8ef70317c7e8545b5b6d4235f21164948ba27" +} diff --git a/apps/labrinth/.sqlx/query-62378074f2f12d010b4b2139ac8c879b6cb54517aaf36c55b6f99f1604015bb7.json b/apps/labrinth/.sqlx/query-62378074f2f12d010b4b2139ac8c879b6cb54517aaf36c55b6f99f1604015bb7.json new file mode 100644 index 000000000..63d3d400b --- /dev/null +++ b/apps/labrinth/.sqlx/query-62378074f2f12d010b4b2139ac8c879b6cb54517aaf36c55b6f99f1604015bb7.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE collections\n SET updated = NOW()\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "62378074f2f12d010b4b2139ac8c879b6cb54517aaf36c55b6f99f1604015bb7" +} diff --git a/apps/labrinth/.sqlx/query-623881c24c12e77f6fc57669929be55a34800cd2269da29d555959164919c9a3.json b/apps/labrinth/.sqlx/query-623881c24c12e77f6fc57669929be55a34800cd2269da29d555959164919c9a3.json new file mode 100644 index 000000000..6ad1c4b9b --- /dev/null +++ b/apps/labrinth/.sqlx/query-623881c24c12e77f6fc57669929be55a34800cd2269da29d555959164919c9a3.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT dependent_id as version_id, d.mod_dependency_id as dependency_project_id, d.dependency_id as dependency_version_id, d.dependency_file_name as file_name, d.dependency_type as dependency_type\n FROM dependencies d\n WHERE dependent_id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "dependency_project_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "dependency_version_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "file_name", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "dependency_type", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + true, + true, + true, + false + ] + }, + "hash": "623881c24c12e77f6fc57669929be55a34800cd2269da29d555959164919c9a3" +} diff --git a/apps/labrinth/.sqlx/query-6366891bb34a14278f1ae857b8d6f68dff44badae9ae5c5aceba3c32e8d00356.json b/apps/labrinth/.sqlx/query-6366891bb34a14278f1ae857b8d6f68dff44badae9ae5c5aceba3c32e8d00356.json new file mode 100644 index 000000000..b262237e9 --- /dev/null +++ b/apps/labrinth/.sqlx/query-6366891bb34a14278f1ae857b8d6f68dff44badae9ae5c5aceba3c32e8d00356.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO mods_links (joining_mod_id, joining_platform_id, url)\n VALUES ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int4", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "6366891bb34a14278f1ae857b8d6f68dff44badae9ae5c5aceba3c32e8d00356" +} diff --git a/apps/labrinth/.sqlx/query-64d5e7cfb8472fbedcd06143db0db2f4c9677c42f73c540e85ccb5aee1a7b6f9.json b/apps/labrinth/.sqlx/query-64d5e7cfb8472fbedcd06143db0db2f4c9677c42f73c540e85ccb5aee1a7b6f9.json new file mode 100644 index 000000000..30b8ab65f --- /dev/null +++ b/apps/labrinth/.sqlx/query-64d5e7cfb8472fbedcd06143db0db2f4c9677c42f73c540e85ccb5aee1a7b6f9.json @@ -0,0 +1,21 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE sessions\n SET last_login = $2, city = $3, country = $4, ip = $5, os = $6, platform = $7, user_agent = $8\n WHERE (id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Timestamptz", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "64d5e7cfb8472fbedcd06143db0db2f4c9677c42f73c540e85ccb5aee1a7b6f9" +} diff --git a/apps/labrinth/.sqlx/query-65c9f9cd010c14100839cd0b044103cac7e4b850d446b29d2efd9757b642fc1c.json b/apps/labrinth/.sqlx/query-65c9f9cd010c14100839cd0b044103cac7e4b850d446b29d2efd9757b642fc1c.json new file mode 100644 index 000000000..f94f7a466 --- /dev/null +++ b/apps/labrinth/.sqlx/query-65c9f9cd010c14100839cd0b044103cac7e4b850d446b29d2efd9757b642fc1c.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE oauth_access_tokens\n SET last_used = $2\n WHERE id IN\n (SELECT * FROM UNNEST($1::bigint[]))\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "65c9f9cd010c14100839cd0b044103cac7e4b850d446b29d2efd9757b642fc1c" +} diff --git a/apps/labrinth/.sqlx/query-65ddadc9d103ccb9d81e1f52565cff1889e5490f0d0d62170ed2b9515ffc5104.json b/apps/labrinth/.sqlx/query-65ddadc9d103ccb9d81e1f52565cff1889e5490f0d0d62170ed2b9515ffc5104.json new file mode 100644 index 000000000..de61d14e0 --- /dev/null +++ b/apps/labrinth/.sqlx/query-65ddadc9d103ccb9d81e1f52565cff1889e5490f0d0d62170ed2b9515ffc5104.json @@ -0,0 +1,47 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, client_id, user_id, scopes, created\n FROM oauth_client_authorizations\n WHERE client_id=$1 AND user_id=$2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "client_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "scopes", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "65ddadc9d103ccb9d81e1f52565cff1889e5490f0d0d62170ed2b9515ffc5104" +} diff --git a/apps/labrinth/.sqlx/query-665e294e9737fd0299fc4639127d56811485dc8a5a4e08a4e7292044d8a2fb7a.json b/apps/labrinth/.sqlx/query-665e294e9737fd0299fc4639127d56811485dc8a5a4e08a4e7292044d8a2fb7a.json new file mode 100644 index 000000000..188ec513b --- /dev/null +++ b/apps/labrinth/.sqlx/query-665e294e9737fd0299fc4639127d56811485dc8a5a4e08a4e7292044d8a2fb7a.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE reports\n SET body = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "665e294e9737fd0299fc4639127d56811485dc8a5a4e08a4e7292044d8a2fb7a" +} diff --git a/apps/labrinth/.sqlx/query-66b06ddcd0a4cf01e716331befa393a12631fe6752a7d078bda06b24d50daae2.json b/apps/labrinth/.sqlx/query-66b06ddcd0a4cf01e716331befa393a12631fe6752a7d078bda06b24d50daae2.json new file mode 100644 index 000000000..f2e83cf92 --- /dev/null +++ b/apps/labrinth/.sqlx/query-66b06ddcd0a4cf01e716331befa393a12631fe6752a7d078bda06b24d50daae2.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET requested_status = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "66b06ddcd0a4cf01e716331befa393a12631fe6752a7d078bda06b24d50daae2" +} diff --git a/apps/labrinth/.sqlx/query-66d61a9077fd4fdf3c56e9cd6599095409ff3b46aad164210a1359a3154dbdb8.json b/apps/labrinth/.sqlx/query-66d61a9077fd4fdf3c56e9cd6599095409ff3b46aad164210a1359a3154dbdb8.json new file mode 100644 index 000000000..4d9f2eb62 --- /dev/null +++ b/apps/labrinth/.sqlx/query-66d61a9077fd4fdf3c56e9cd6599095409ff3b46aad164210a1359a3154dbdb8.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM sessions WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "66d61a9077fd4fdf3c56e9cd6599095409ff3b46aad164210a1359a3154dbdb8" +} diff --git a/apps/labrinth/.sqlx/query-67d021f0776276081d3c50ca97afa6b78b98860bf929009e845e9c00a192e3b5.json b/apps/labrinth/.sqlx/query-67d021f0776276081d3c50ca97afa6b78b98860bf929009e845e9c00a192e3b5.json new file mode 100644 index 000000000..adf9b327f --- /dev/null +++ b/apps/labrinth/.sqlx/query-67d021f0776276081d3c50ca97afa6b78b98860bf929009e845e9c00a192e3b5.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id FROM report_types\n WHERE name = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "67d021f0776276081d3c50ca97afa6b78b98860bf929009e845e9c00a192e3b5" +} diff --git a/apps/labrinth/.sqlx/query-680067ff64918882a3bff1438a6a70ca51a5dc52e48e47bbeb6e32d6739422d2.json b/apps/labrinth/.sqlx/query-680067ff64918882a3bff1438a6a70ca51a5dc52e48e47bbeb6e32d6739422d2.json new file mode 100644 index 000000000..e28880d58 --- /dev/null +++ b/apps/labrinth/.sqlx/query-680067ff64918882a3bff1438a6a70ca51a5dc52e48e47bbeb6e32d6739422d2.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM notifications\n WHERE user_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "680067ff64918882a3bff1438a6a70ca51a5dc52e48e47bbeb6e32d6739422d2" +} diff --git a/apps/labrinth/.sqlx/query-683e08f3b71aca0d004ebf83a9e6b7b0b30291d595e5ae9f7e0fd38d347c3f74.json b/apps/labrinth/.sqlx/query-683e08f3b71aca0d004ebf83a9e6b7b0b30291d595e5ae9f7e0fd38d347c3f74.json new file mode 100644 index 000000000..9c06e50b9 --- /dev/null +++ b/apps/labrinth/.sqlx/query-683e08f3b71aca0d004ebf83a9e6b7b0b30291d595e5ae9f7e0fd38d347c3f74.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM uploaded_images WHERE owner_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "683e08f3b71aca0d004ebf83a9e6b7b0b30291d595e5ae9f7e0fd38d347c3f74" +} diff --git a/apps/labrinth/.sqlx/query-683e186dc086ef21d2f82c0d427fcee16c613fb93ea74d6eb0da684363ca7b13.json b/apps/labrinth/.sqlx/query-683e186dc086ef21d2f82c0d427fcee16c613fb93ea74d6eb0da684363ca7b13.json new file mode 100644 index 000000000..b079a30fc --- /dev/null +++ b/apps/labrinth/.sqlx/query-683e186dc086ef21d2f82c0d427fcee16c613fb93ea74d6eb0da684363ca7b13.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, project_type FROM categories\n WHERE category = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "project_type", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "683e186dc086ef21d2f82c0d427fcee16c613fb93ea74d6eb0da684363ca7b13" +} diff --git a/apps/labrinth/.sqlx/query-68ef15f50a067503dce124b50fb3c2efd07808c4a859ab1b1e9e65e16439a8f3.json b/apps/labrinth/.sqlx/query-68ef15f50a067503dce124b50fb3c2efd07808c4a859ab1b1e9e65e16439a8f3.json new file mode 100644 index 000000000..bbf10def8 --- /dev/null +++ b/apps/labrinth/.sqlx/query-68ef15f50a067503dce124b50fb3c2efd07808c4a859ab1b1e9e65e16439a8f3.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO oauth_client_authorizations (\n id, client_id, user_id, scopes\n )\n VALUES (\n $1, $2, $3, $4\n )\n ON CONFLICT (id)\n DO UPDATE SET scopes = EXCLUDED.scopes\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "68ef15f50a067503dce124b50fb3c2efd07808c4a859ab1b1e9e65e16439a8f3" +} diff --git a/apps/labrinth/.sqlx/query-69b093cad9109ccf4779bfd969897f6b9ebc9d0d4230c958de4fa07435776349.json b/apps/labrinth/.sqlx/query-69b093cad9109ccf4779bfd969897f6b9ebc9d0d4230c958de4fa07435776349.json new file mode 100644 index 000000000..1fe161862 --- /dev/null +++ b/apps/labrinth/.sqlx/query-69b093cad9109ccf4779bfd969897f6b9ebc9d0d4230c958de4fa07435776349.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM sessions\n WHERE user_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "69b093cad9109ccf4779bfd969897f6b9ebc9d0d4230c958de4fa07435776349" +} diff --git a/apps/labrinth/.sqlx/query-6ae142b226a035ebdb35fe53930e298e24ab4d07e10881f24cdaa7f373b41797.json b/apps/labrinth/.sqlx/query-6ae142b226a035ebdb35fe53930e298e24ab4d07e10881f24cdaa7f373b41797.json new file mode 100644 index 000000000..959c0cc65 --- /dev/null +++ b/apps/labrinth/.sqlx/query-6ae142b226a035ebdb35fe53930e298e24ab4d07e10881f24cdaa7f373b41797.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE organizations\n SET icon_url = NULL, raw_icon_url = NULL, color = NULL\n WHERE (id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "6ae142b226a035ebdb35fe53930e298e24ab4d07e10881f24cdaa7f373b41797" +} diff --git a/apps/labrinth/.sqlx/query-6b7958eac5f273af8f37c0c888594e106fe323cbb3b0c32868b02f869d30f33f.json b/apps/labrinth/.sqlx/query-6b7958eac5f273af8f37c0c888594e106fe323cbb3b0c32868b02f869d30f33f.json new file mode 100644 index 000000000..30c43125d --- /dev/null +++ b/apps/labrinth/.sqlx/query-6b7958eac5f273af8f37c0c888594e106fe323cbb3b0c32868b02f869d30f33f.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT r.id, rt.name, r.mod_id, r.version_id, r.user_id, r.body, r.reporter, r.created, t.id thread_id, r.closed\n FROM reports r\n INNER JOIN report_types rt ON rt.id = r.report_type_id\n INNER JOIN threads t ON t.report_id = r.id\n WHERE r.id = ANY($1)\n ORDER BY r.created DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "version_id", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "body", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "reporter", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "thread_id", + "type_info": "Int8" + }, + { + "ordinal": 9, + "name": "closed", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + true, + true, + true, + false, + false, + false, + false, + false + ] + }, + "hash": "6b7958eac5f273af8f37c0c888594e106fe323cbb3b0c32868b02f869d30f33f" +} diff --git a/apps/labrinth/.sqlx/query-6b881555e610ddc6796cdcbfd2de26e68b10522d0f1df3f006d58f6b72be9911.json b/apps/labrinth/.sqlx/query-6b881555e610ddc6796cdcbfd2de26e68b10522d0f1df3f006d58f6b72be9911.json new file mode 100644 index 000000000..797214d3b --- /dev/null +++ b/apps/labrinth/.sqlx/query-6b881555e610ddc6796cdcbfd2de26e68b10522d0f1df3f006d58f6b72be9911.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO oauth_access_tokens (\n id, authorization_id, token_hash, scopes, last_used\n )\n VALUES (\n $1, $2, $3, $4, $5\n )\n RETURNING created, expires\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 1, + "name": "expires", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Text", + "Int8", + "Timestamptz" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "6b881555e610ddc6796cdcbfd2de26e68b10522d0f1df3f006d58f6b72be9911" +} diff --git a/apps/labrinth/.sqlx/query-6c8b8a2f11c0b4e7a5973547fe1611a0fa4ef366d5c8a91d9fb9a1360ea04d46.json b/apps/labrinth/.sqlx/query-6c8b8a2f11c0b4e7a5973547fe1611a0fa4ef366d5c8a91d9fb9a1360ea04d46.json new file mode 100644 index 000000000..7833fda93 --- /dev/null +++ b/apps/labrinth/.sqlx/query-6c8b8a2f11c0b4e7a5973547fe1611a0fa4ef366d5c8a91d9fb9a1360ea04d46.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(SELECT 1 FROM hashes h\n INNER JOIN files f ON f.id = h.file_id\n INNER JOIN versions v ON v.id = f.version_id\n WHERE h.algorithm = $2 AND h.hash = $1 AND v.mod_id != $3)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Bytea", + "Text", + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "6c8b8a2f11c0b4e7a5973547fe1611a0fa4ef366d5c8a91d9fb9a1360ea04d46" +} diff --git a/apps/labrinth/.sqlx/query-6cc4e708db6ba1fa1fffdc5e0e40a11b5c512c25b113df984ab2a9557c5f5232.json b/apps/labrinth/.sqlx/query-6cc4e708db6ba1fa1fffdc5e0e40a11b5c512c25b113df984ab2a9557c5f5232.json new file mode 100644 index 000000000..96f99a96c --- /dev/null +++ b/apps/labrinth/.sqlx/query-6cc4e708db6ba1fa1fffdc5e0e40a11b5c512c25b113df984ab2a9557c5f5232.json @@ -0,0 +1,82 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, url, raw_url, size, created, owner_id, context, mod_id, version_id, thread_message_id, report_id\n FROM uploaded_images\n WHERE id = ANY($1)\n GROUP BY id;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "url", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "raw_url", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "size", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "owner_id", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "context", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 8, + "name": "version_id", + "type_info": "Int8" + }, + { + "ordinal": 9, + "name": "thread_message_id", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "report_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "6cc4e708db6ba1fa1fffdc5e0e40a11b5c512c25b113df984ab2a9557c5f5232" +} diff --git a/apps/labrinth/.sqlx/query-6db607d629be3047d53ff92bb82c07700595e8f4fcb7b602918540af4ae50d8b.json b/apps/labrinth/.sqlx/query-6db607d629be3047d53ff92bb82c07700595e8f4fcb7b602918540af4ae50d8b.json new file mode 100644 index 000000000..6fc8fcb1b --- /dev/null +++ b/apps/labrinth/.sqlx/query-6db607d629be3047d53ff92bb82c07700595e8f4fcb7b602918540af4ae50d8b.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM users\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "6db607d629be3047d53ff92bb82c07700595e8f4fcb7b602918540af4ae50d8b" +} diff --git a/apps/labrinth/.sqlx/query-6e07cc68675d0f583182eaa9f50853fa5996b9f83543fe8b6c2a073cf6a9cb5d.json b/apps/labrinth/.sqlx/query-6e07cc68675d0f583182eaa9f50853fa5996b9f83543fe8b6c2a073cf6a9cb5d.json new file mode 100644 index 000000000..130308bc0 --- /dev/null +++ b/apps/labrinth/.sqlx/query-6e07cc68675d0f583182eaa9f50853fa5996b9f83543fe8b6c2a073cf6a9cb5d.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT COUNT(id)\n FROM mods\n WHERE status = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "TextArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "6e07cc68675d0f583182eaa9f50853fa5996b9f83543fe8b6c2a073cf6a9cb5d" +} diff --git a/apps/labrinth/.sqlx/query-6e4ff5010b19890e26867611a243a308fb32f7439a18c83d1e16d3e537a43e7d.json b/apps/labrinth/.sqlx/query-6e4ff5010b19890e26867611a243a308fb32f7439a18c83d1e16d3e537a43e7d.json new file mode 100644 index 000000000..feafe67da --- /dev/null +++ b/apps/labrinth/.sqlx/query-6e4ff5010b19890e26867611a243a308fb32f7439a18c83d1e16d3e537a43e7d.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT encode(mef.sha1, 'escape') sha1, mel.status status\n FROM moderation_external_files mef\n INNER JOIN moderation_external_licenses mel ON mef.external_license_id = mel.id\n WHERE mef.sha1 = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "sha1", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "status", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "ByteaArray" + ] + }, + "nullable": [ + null, + false + ] + }, + "hash": "6e4ff5010b19890e26867611a243a308fb32f7439a18c83d1e16d3e537a43e7d" +} diff --git a/apps/labrinth/.sqlx/query-6f594641f9633fbab31a57ebdbd33dd74f89e45252dfc2ae1cdbda549291b21b.json b/apps/labrinth/.sqlx/query-6f594641f9633fbab31a57ebdbd33dd74f89e45252dfc2ae1cdbda549291b21b.json new file mode 100644 index 000000000..fc634f761 --- /dev/null +++ b/apps/labrinth/.sqlx/query-6f594641f9633fbab31a57ebdbd33dd74f89e45252dfc2ae1cdbda549291b21b.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM mod_follows\n WHERE follower_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "6f594641f9633fbab31a57ebdbd33dd74f89e45252dfc2ae1cdbda549291b21b" +} diff --git a/apps/labrinth/.sqlx/query-6fac7682527a4a9dc34e121e8b7c356cb8fe1d0ff1f9a19d29937721acaa8842.json b/apps/labrinth/.sqlx/query-6fac7682527a4a9dc34e121e8b7c356cb8fe1d0ff1f9a19d29937721acaa8842.json new file mode 100644 index 000000000..c7ccefa7e --- /dev/null +++ b/apps/labrinth/.sqlx/query-6fac7682527a4a9dc34e121e8b7c356cb8fe1d0ff1f9a19d29937721acaa8842.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id\n FROM pats\n WHERE user_id = $1\n ORDER BY created DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "6fac7682527a4a9dc34e121e8b7c356cb8fe1d0ff1f9a19d29937721acaa8842" +} diff --git a/apps/labrinth/.sqlx/query-6fbff950c4c996976a29898b120b9b8b562f25729166c21d6f5ed45c240c71be.json b/apps/labrinth/.sqlx/query-6fbff950c4c996976a29898b120b9b8b562f25729166c21d6f5ed45c240c71be.json new file mode 100644 index 000000000..d4c1b69fc --- /dev/null +++ b/apps/labrinth/.sqlx/query-6fbff950c4c996976a29898b120b9b8b562f25729166c21d6f5ed45c240c71be.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM uploaded_images WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "6fbff950c4c996976a29898b120b9b8b562f25729166c21d6f5ed45c240c71be" +} diff --git a/apps/labrinth/.sqlx/query-70c812c6a0d29465569169afde42c74a353a534aeedd5cdd81bceb2a7de6bc78.json b/apps/labrinth/.sqlx/query-70c812c6a0d29465569169afde42c74a353a534aeedd5cdd81bceb2a7de6bc78.json new file mode 100644 index 000000000..7036085d7 --- /dev/null +++ b/apps/labrinth/.sqlx/query-70c812c6a0d29465569169afde42c74a353a534aeedd5cdd81bceb2a7de6bc78.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM mods_categories\n WHERE joining_mod_id = $1 AND is_additional = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "70c812c6a0d29465569169afde42c74a353a534aeedd5cdd81bceb2a7de6bc78" +} diff --git a/apps/labrinth/.sqlx/query-7174cd941ff95260ad9c564daf92876c5ae253df538f4cd4c3701e63137fb01b.json b/apps/labrinth/.sqlx/query-7174cd941ff95260ad9c564daf92876c5ae253df538f4cd4c3701e63137fb01b.json new file mode 100644 index 000000000..454d523ce --- /dev/null +++ b/apps/labrinth/.sqlx/query-7174cd941ff95260ad9c564daf92876c5ae253df538f4cd4c3701e63137fb01b.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n tokens.id,\n tokens.authorization_id,\n tokens.token_hash,\n tokens.scopes,\n tokens.created,\n tokens.expires,\n tokens.last_used,\n auths.client_id,\n auths.user_id\n FROM oauth_access_tokens tokens\n JOIN oauth_client_authorizations auths\n ON tokens.authorization_id = auths.id\n WHERE tokens.token_hash = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "authorization_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "token_hash", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "scopes", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "expires", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "last_used", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "client_id", + "type_info": "Int8" + }, + { + "ordinal": 8, + "name": "user_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false, + false + ] + }, + "hash": "7174cd941ff95260ad9c564daf92876c5ae253df538f4cd4c3701e63137fb01b" +} diff --git a/apps/labrinth/.sqlx/query-71abd207410d123f9a50345ddcddee335fea0d0cc6f28762713ee01a36aee8a0.json b/apps/labrinth/.sqlx/query-71abd207410d123f9a50345ddcddee335fea0d0cc6f28762713ee01a36aee8a0.json new file mode 100644 index 000000000..5b2e73049 --- /dev/null +++ b/apps/labrinth/.sqlx/query-71abd207410d123f9a50345ddcddee335fea0d0cc6f28762713ee01a36aee8a0.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT m.id FROM mods m\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2\n WHERE m.id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array", + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "71abd207410d123f9a50345ddcddee335fea0d0cc6f28762713ee01a36aee8a0" +} diff --git a/apps/labrinth/.sqlx/query-73bdd6c9e7cd8c1ed582261aebdee0f8fd2734e712ef288a2608564c918009cb.json b/apps/labrinth/.sqlx/query-73bdd6c9e7cd8c1ed582261aebdee0f8fd2734e712ef288a2608564c918009cb.json new file mode 100644 index 000000000..f85fc1161 --- /dev/null +++ b/apps/labrinth/.sqlx/query-73bdd6c9e7cd8c1ed582261aebdee0f8fd2734e712ef288a2608564c918009cb.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM versions WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "73bdd6c9e7cd8c1ed582261aebdee0f8fd2734e712ef288a2608564c918009cb" +} diff --git a/apps/labrinth/.sqlx/query-742f20f422361971c21b72c629c57a6c3870d8d6c41577496907290db5994f12.json b/apps/labrinth/.sqlx/query-742f20f422361971c21b72c629c57a6c3870d8d6c41577496907290db5994f12.json new file mode 100644 index 000000000..1c271cbd6 --- /dev/null +++ b/apps/labrinth/.sqlx/query-742f20f422361971c21b72c629c57a6c3870d8d6c41577496907290db5994f12.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET badges = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "742f20f422361971c21b72c629c57a6c3870d8d6c41577496907290db5994f12" +} diff --git a/apps/labrinth/.sqlx/query-74854bb35744be413458d0609d6511aa4c9802b5fc4ac73abb520cf2577e1d84.json b/apps/labrinth/.sqlx/query-74854bb35744be413458d0609d6511aa4c9802b5fc4ac73abb520cf2577e1d84.json new file mode 100644 index 000000000..5c8681558 --- /dev/null +++ b/apps/labrinth/.sqlx/query-74854bb35744be413458d0609d6511aa4c9802b5fc4ac73abb520cf2577e1d84.json @@ -0,0 +1,95 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, user_id, session, created, last_login, expires, refresh_expires, os, platform,\n city, country, ip, user_agent\n FROM sessions\n WHERE id = ANY($1) OR session = ANY($2)\n ORDER BY created DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "session", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "last_login", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "expires", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "refresh_expires", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "os", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "platform", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "city", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "country", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "ip", + "type_info": "Varchar" + }, + { + "ordinal": 12, + "name": "user_agent", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8Array", + "TextArray" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true, + false, + false + ] + }, + "hash": "74854bb35744be413458d0609d6511aa4c9802b5fc4ac73abb520cf2577e1d84" +} diff --git a/apps/labrinth/.sqlx/query-74b354b3b79eba18040f8dcf401dd872a08f497d6628147a81b3e015e3a35ad8.json b/apps/labrinth/.sqlx/query-74b354b3b79eba18040f8dcf401dd872a08f497d6628147a81b3e015e3a35ad8.json new file mode 100644 index 000000000..2d576e437 --- /dev/null +++ b/apps/labrinth/.sqlx/query-74b354b3b79eba18040f8dcf401dd872a08f497d6628147a81b3e015e3a35ad8.json @@ -0,0 +1,86 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, url, raw_url, size, created, owner_id, context, mod_id, version_id, thread_message_id, report_id\n FROM uploaded_images\n WHERE context = $1\n AND (mod_id = $2 OR ($2 IS NULL AND mod_id IS NULL))\n AND (version_id = $3 OR ($3 IS NULL AND version_id IS NULL))\n AND (thread_message_id = $4 OR ($4 IS NULL AND thread_message_id IS NULL))\n AND (report_id = $5 OR ($5 IS NULL AND report_id IS NULL))\n GROUP BY id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "url", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "raw_url", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "size", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "owner_id", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "context", + "type_info": "Varchar" + }, + { + "ordinal": 7, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 8, + "name": "version_id", + "type_info": "Int8" + }, + { + "ordinal": 9, + "name": "thread_message_id", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "report_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8", + "Int8", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "74b354b3b79eba18040f8dcf401dd872a08f497d6628147a81b3e015e3a35ad8" +} diff --git a/apps/labrinth/.sqlx/query-75a860ca8087536a9fcf932846341c8bd322d314231bb8acac124d1cea93270b.json b/apps/labrinth/.sqlx/query-75a860ca8087536a9fcf932846341c8bd322d314231bb8acac124d1cea93270b.json new file mode 100644 index 000000000..96e778b9c --- /dev/null +++ b/apps/labrinth/.sqlx/query-75a860ca8087536a9fcf932846341c8bd322d314231bb8acac124d1cea93270b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT mf.mod_id FROM mod_follows mf\n WHERE mf.follower_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "75a860ca8087536a9fcf932846341c8bd322d314231bb8acac124d1cea93270b" +} diff --git a/apps/labrinth/.sqlx/query-75dc7f592781a1414e5f489543b14cb94c5265ddb3abfb3dda965c8cf154b753.json b/apps/labrinth/.sqlx/query-75dc7f592781a1414e5f489543b14cb94c5265ddb3abfb3dda965c8cf154b753.json new file mode 100644 index 000000000..cdf1b8bc1 --- /dev/null +++ b/apps/labrinth/.sqlx/query-75dc7f592781a1414e5f489543b14cb94c5265ddb3abfb3dda965c8cf154b753.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE files\n SET file_type = $2\n WHERE (id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "75dc7f592781a1414e5f489543b14cb94c5265ddb3abfb3dda965c8cf154b753" +} diff --git a/apps/labrinth/.sqlx/query-7628dd456f01d307cc8647b36734b189a5f08dbaa9db78fe28f1de3d8f4757b7.json b/apps/labrinth/.sqlx/query-7628dd456f01d307cc8647b36734b189a5f08dbaa9db78fe28f1de3d8f4757b7.json new file mode 100644 index 000000000..acb60fcf6 --- /dev/null +++ b/apps/labrinth/.sqlx/query-7628dd456f01d307cc8647b36734b189a5f08dbaa9db78fe28f1de3d8f4757b7.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE uploaded_images\n SET report_id = $1\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "7628dd456f01d307cc8647b36734b189a5f08dbaa9db78fe28f1de3d8f4757b7" +} diff --git a/apps/labrinth/.sqlx/query-7711b7c651015510a101cc409fa6f5229ac93d7209df8bc158f4dd4442f611f2.json b/apps/labrinth/.sqlx/query-7711b7c651015510a101cc409fa6f5229ac93d7209df8bc158f4dd4442f611f2.json new file mode 100644 index 000000000..7db6d3dbf --- /dev/null +++ b/apps/labrinth/.sqlx/query-7711b7c651015510a101cc409fa6f5229ac93d7209df8bc158f4dd4442f611f2.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM user_backup_codes\n WHERE user_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "7711b7c651015510a101cc409fa6f5229ac93d7209df8bc158f4dd4442f611f2" +} diff --git a/apps/labrinth/.sqlx/query-78699c6d2ca0f13f4609310df479903e8d5e0d2d4c2603df0333be7dc040a4ee.json b/apps/labrinth/.sqlx/query-78699c6d2ca0f13f4609310df479903e8d5e0d2d4c2603df0333be7dc040a4ee.json new file mode 100644 index 000000000..c5ff73ccf --- /dev/null +++ b/apps/labrinth/.sqlx/query-78699c6d2ca0f13f4609310df479903e8d5e0d2d4c2603df0333be7dc040a4ee.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM dependencies WHERE mod_dependency_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "78699c6d2ca0f13f4609310df479903e8d5e0d2d4c2603df0333be7dc040a4ee" +} diff --git a/apps/labrinth/.sqlx/query-7916fe4f04067324ae05598ec9dc6f97f18baf9eda30c64f32677158ada87478.json b/apps/labrinth/.sqlx/query-7916fe4f04067324ae05598ec9dc6f97f18baf9eda30c64f32677158ada87478.json new file mode 100644 index 000000000..6996aa840 --- /dev/null +++ b/apps/labrinth/.sqlx/query-7916fe4f04067324ae05598ec9dc6f97f18baf9eda30c64f32677158ada87478.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET monetization_status = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "7916fe4f04067324ae05598ec9dc6f97f18baf9eda30c64f32677158ada87478" +} diff --git a/apps/labrinth/.sqlx/query-797cddf8f779025726a4a42c42985b8bc4c14094b76d9cd66dca20a7da3dec2a.json b/apps/labrinth/.sqlx/query-797cddf8f779025726a4a42c42985b8bc4c14094b76d9cd66dca20a7da3dec2a.json new file mode 100644 index 000000000..295be9b4a --- /dev/null +++ b/apps/labrinth/.sqlx/query-797cddf8f779025726a4a42c42985b8bc4c14094b76d9cd66dca20a7da3dec2a.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM mods m INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.user_id = $2 WHERE m.id = $1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "797cddf8f779025726a4a42c42985b8bc4c14094b76d9cd66dca20a7da3dec2a" +} diff --git a/apps/labrinth/.sqlx/query-79b896b1a8ddab285294638302976b75d0d915f36036383cc21bd2fc48d4502c.json b/apps/labrinth/.sqlx/query-79b896b1a8ddab285294638302976b75d0d915f36036383cc21bd2fc48d4502c.json new file mode 100644 index 000000000..0596c7817 --- /dev/null +++ b/apps/labrinth/.sqlx/query-79b896b1a8ddab285294638302976b75d0d915f36036383cc21bd2fc48d4502c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM loaders_versions WHERE version_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "79b896b1a8ddab285294638302976b75d0d915f36036383cc21bd2fc48d4502c" +} diff --git a/apps/labrinth/.sqlx/query-79c73369365ed7a09f4f48a87605d22db4a49ab5fd9943b54865448d0e9a8d67.json b/apps/labrinth/.sqlx/query-79c73369365ed7a09f4f48a87605d22db4a49ab5fd9943b54865448d0e9a8d67.json new file mode 100644 index 000000000..dd98633e5 --- /dev/null +++ b/apps/labrinth/.sqlx/query-79c73369365ed7a09f4f48a87605d22db4a49ab5fd9943b54865448d0e9a8d67.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO loader_field_enum_values (enum_id, value, created, metadata)\n VALUES ($1, $2, COALESCE($3, timezone('utc', now())), $4)\n ON CONFLICT (enum_id, value) DO UPDATE\n SET metadata = jsonb_set(\n COALESCE(loader_field_enum_values.metadata, $4),\n '{type}', \n COALESCE($4->'type', loader_field_enum_values.metadata->'type')\n ),\n created = COALESCE($3, loader_field_enum_values.created)\n RETURNING id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4", + "Varchar", + "Timestamp", + "Jsonb" + ] + }, + "nullable": [ + false + ] + }, + "hash": "79c73369365ed7a09f4f48a87605d22db4a49ab5fd9943b54865448d0e9a8d67" +} diff --git a/apps/labrinth/.sqlx/query-7af44414304c8be404d32daa3cadf99fc4ecf97b74aeb5d39c890b0f35a51f96.json b/apps/labrinth/.sqlx/query-7af44414304c8be404d32daa3cadf99fc4ecf97b74aeb5d39c890b0f35a51f96.json new file mode 100644 index 000000000..86efcd480 --- /dev/null +++ b/apps/labrinth/.sqlx/query-7af44414304c8be404d32daa3cadf99fc4ecf97b74aeb5d39c890b0f35a51f96.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT n.id FROM notifications n\n WHERE n.user_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "7af44414304c8be404d32daa3cadf99fc4ecf97b74aeb5d39c890b0f35a51f96" +} diff --git a/apps/labrinth/.sqlx/query-7b6b76f383adcbe2afbd2a2e87e66fd2a0d9d05b68b27823c1395e7cc3b8c0a2.json b/apps/labrinth/.sqlx/query-7b6b76f383adcbe2afbd2a2e87e66fd2a0d9d05b68b27823c1395e7cc3b8c0a2.json new file mode 100644 index 000000000..9768eb692 --- /dev/null +++ b/apps/labrinth/.sqlx/query-7b6b76f383adcbe2afbd2a2e87e66fd2a0d9d05b68b27823c1395e7cc3b8c0a2.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE collections\n SET status = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "7b6b76f383adcbe2afbd2a2e87e66fd2a0d9d05b68b27823c1395e7cc3b8c0a2" +} diff --git a/apps/labrinth/.sqlx/query-7c4b975bea12f16d66ed5f663fcd40097d9a09aad8bec6eec9639d56a197aeca.json b/apps/labrinth/.sqlx/query-7c4b975bea12f16d66ed5f663fcd40097d9a09aad8bec6eec9639d56a197aeca.json new file mode 100644 index 000000000..d1f67a3bd --- /dev/null +++ b/apps/labrinth/.sqlx/query-7c4b975bea12f16d66ed5f663fcd40097d9a09aad8bec6eec9639d56a197aeca.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO oauth_clients (\n id, name, icon_url, raw_icon_url, max_scopes, secret_hash, created_by\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Text", + "Text", + "Text", + "Int8", + "Text", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "7c4b975bea12f16d66ed5f663fcd40097d9a09aad8bec6eec9639d56a197aeca" +} diff --git a/apps/labrinth/.sqlx/query-7c61fee015231f0a97c25d24f2c6be24821e39e330ab82344ad3b985d0d2aaea.json b/apps/labrinth/.sqlx/query-7c61fee015231f0a97c25d24f2c6be24821e39e330ab82344ad3b985d0d2aaea.json new file mode 100644 index 000000000..adda594e1 --- /dev/null +++ b/apps/labrinth/.sqlx/query-7c61fee015231f0a97c25d24f2c6be24821e39e330ab82344ad3b985d0d2aaea.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id FROM mods_gallery\n WHERE image_url = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "7c61fee015231f0a97c25d24f2c6be24821e39e330ab82344ad3b985d0d2aaea" +} diff --git a/apps/labrinth/.sqlx/query-7c88d4f3a4342676773ae0c90ec17703fe59b218c851aaee02ba89f30385a315.json b/apps/labrinth/.sqlx/query-7c88d4f3a4342676773ae0c90ec17703fe59b218c851aaee02ba89f30385a315.json new file mode 100644 index 000000000..7c9a68b52 --- /dev/null +++ b/apps/labrinth/.sqlx/query-7c88d4f3a4342676773ae0c90ec17703fe59b218c851aaee02ba89f30385a315.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT user_id FROM team_members\n WHERE team_id = $1 AND is_owner = TRUE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "7c88d4f3a4342676773ae0c90ec17703fe59b218c851aaee02ba89f30385a315" +} diff --git a/apps/labrinth/.sqlx/query-7e030d43f3412e7df63c970f873d0a73dd2deb9857aa6f201ec5eec628eb336c.json b/apps/labrinth/.sqlx/query-7e030d43f3412e7df63c970f873d0a73dd2deb9857aa6f201ec5eec628eb336c.json new file mode 100644 index 000000000..da1c89581 --- /dev/null +++ b/apps/labrinth/.sqlx/query-7e030d43f3412e7df63c970f873d0a73dd2deb9857aa6f201ec5eec628eb336c.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET github_id = $2\n WHERE (id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "7e030d43f3412e7df63c970f873d0a73dd2deb9857aa6f201ec5eec628eb336c" +} diff --git a/apps/labrinth/.sqlx/query-7f5cccc8927d3675f91c2b2f5c260466d989b5cd4a73926abacc3989b9e887ab.json b/apps/labrinth/.sqlx/query-7f5cccc8927d3675f91c2b2f5c260466d989b5cd4a73926abacc3989b9e887ab.json new file mode 100644 index 000000000..b02c6c747 --- /dev/null +++ b/apps/labrinth/.sqlx/query-7f5cccc8927d3675f91c2b2f5c260466d989b5cd4a73926abacc3989b9e887ab.json @@ -0,0 +1,36 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT mod_id, v.id as id, date_published\n FROM mods m\n INNER JOIN versions v ON m.id = v.mod_id AND v.status = ANY($3)\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "date_published", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8Array", + "TextArray", + "TextArray" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "7f5cccc8927d3675f91c2b2f5c260466d989b5cd4a73926abacc3989b9e887ab" +} diff --git a/apps/labrinth/.sqlx/query-7fa5098b1083af58b86083b659cb647498fcc20e38265b9d316ca8c0a2cbc02a.json b/apps/labrinth/.sqlx/query-7fa5098b1083af58b86083b659cb647498fcc20e38265b9d316ca8c0a2cbc02a.json new file mode 100644 index 000000000..da471b1da --- /dev/null +++ b/apps/labrinth/.sqlx/query-7fa5098b1083af58b86083b659cb647498fcc20e38265b9d316ca8c0a2cbc02a.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT mod_id, version_id, field_id, int_value, enum_value, string_value\n FROM versions v\n INNER JOIN version_fields vf ON v.id = vf.version_id\n WHERE v.id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "version_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "field_id", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "int_value", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "enum_value", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "string_value", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true + ] + }, + "hash": "7fa5098b1083af58b86083b659cb647498fcc20e38265b9d316ca8c0a2cbc02a" +} diff --git a/apps/labrinth/.sqlx/query-80734c33c16aeacca980cf40070bac035931a0bab8c0d0cf63888c8e5616f847.json b/apps/labrinth/.sqlx/query-80734c33c16aeacca980cf40070bac035931a0bab8c0d0cf63888c8e5616f847.json new file mode 100644 index 000000000..b874da66c --- /dev/null +++ b/apps/labrinth/.sqlx/query-80734c33c16aeacca980cf40070bac035931a0bab8c0d0cf63888c8e5616f847.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT m.id mod_id, u.username\n FROM mods m\n INNER JOIN team_members tm ON tm.is_owner = TRUE and tm.team_id = m.team_id\n INNER JOIN users u ON u.id = tm.user_id\n WHERE m.id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "80734c33c16aeacca980cf40070bac035931a0bab8c0d0cf63888c8e5616f847" +} diff --git a/apps/labrinth/.sqlx/query-81e2e17bfbaadbb3d25072cf6cb8e8d7b3842252b3c72fcbd24aadd2ad933472.json b/apps/labrinth/.sqlx/query-81e2e17bfbaadbb3d25072cf6cb8e8d7b3842252b3c72fcbd24aadd2ad933472.json new file mode 100644 index 000000000..f625be004 --- /dev/null +++ b/apps/labrinth/.sqlx/query-81e2e17bfbaadbb3d25072cf6cb8e8d7b3842252b3c72fcbd24aadd2ad933472.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET microsoft_id = $2\n WHERE (id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "81e2e17bfbaadbb3d25072cf6cb8e8d7b3842252b3c72fcbd24aadd2ad933472" +} diff --git a/apps/labrinth/.sqlx/query-83ad5d39f795c631e1cba90cadd24c872c72bb4f37f0c2c9bdd58ca76d41cb7f.json b/apps/labrinth/.sqlx/query-83ad5d39f795c631e1cba90cadd24c872c72bb4f37f0c2c9bdd58ca76d41cb7f.json new file mode 100644 index 000000000..8e30b9e9f --- /dev/null +++ b/apps/labrinth/.sqlx/query-83ad5d39f795c631e1cba90cadd24c872c72bb4f37f0c2c9bdd58ca76d41cb7f.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET badges = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "83ad5d39f795c631e1cba90cadd24c872c72bb4f37f0c2c9bdd58ca76d41cb7f" +} diff --git a/apps/labrinth/.sqlx/query-83f8d3fcc4ba1544f593abaf29c79157bc35e3fca79cc93f6512ca01acd8e5ce.json b/apps/labrinth/.sqlx/query-83f8d3fcc4ba1544f593abaf29c79157bc35e3fca79cc93f6512ca01acd8e5ce.json new file mode 100644 index 000000000..ac2af6819 --- /dev/null +++ b/apps/labrinth/.sqlx/query-83f8d3fcc4ba1544f593abaf29c79157bc35e3fca79cc93f6512ca01acd8e5ce.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, user_id, created, amount, status, method, method_address, platform_id, fee\n FROM payouts\n WHERE id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 3, + "name": "amount", + "type_info": "Numeric" + }, + { + "ordinal": 4, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "method", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "method_address", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "platform_id", + "type_info": "Text" + }, + { + "ordinal": 8, + "name": "fee", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + true, + true + ] + }, + "hash": "83f8d3fcc4ba1544f593abaf29c79157bc35e3fca79cc93f6512ca01acd8e5ce" +} diff --git a/apps/labrinth/.sqlx/query-846b66683e6abd40acd158195d8836a02ff5dc408c9fc233e8b5ad3b48125dc4.json b/apps/labrinth/.sqlx/query-846b66683e6abd40acd158195d8836a02ff5dc408c9fc233e8b5ad3b48125dc4.json new file mode 100644 index 000000000..8f42b4414 --- /dev/null +++ b/apps/labrinth/.sqlx/query-846b66683e6abd40acd158195d8836a02ff5dc408c9fc233e8b5ad3b48125dc4.json @@ -0,0 +1,56 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT lf.id, lf.field, lf.field_type, lf.optional, lf.min_val, lf.max_val, lf.enum_type\n FROM loader_fields lf\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "field", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "field_type", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "optional", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "min_val", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "max_val", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "enum_type", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true + ] + }, + "hash": "846b66683e6abd40acd158195d8836a02ff5dc408c9fc233e8b5ad3b48125dc4" +} diff --git a/apps/labrinth/.sqlx/query-8475c7cb94786576012b16d53a017cb250f0de99b76746d8725798daa3345c5e.json b/apps/labrinth/.sqlx/query-8475c7cb94786576012b16d53a017cb250f0de99b76746d8725798daa3345c5e.json new file mode 100644 index 000000000..6ba671b8f --- /dev/null +++ b/apps/labrinth/.sqlx/query-8475c7cb94786576012b16d53a017cb250f0de99b76746d8725798daa3345c5e.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO dependencies (dependent_id, dependency_type, dependency_id, mod_dependency_id, dependency_file_name)\n SELECT * FROM UNNEST ($1::bigint[], $2::varchar[], $3::bigint[], $4::bigint[], $5::varchar[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "VarcharArray", + "Int8Array", + "Int8Array", + "VarcharArray" + ] + }, + "nullable": [] + }, + "hash": "8475c7cb94786576012b16d53a017cb250f0de99b76746d8725798daa3345c5e" +} diff --git a/apps/labrinth/.sqlx/query-85463fa221147ee8d409fc92ed681fa27df683e7c80b8dd8616ae94dc1205c24.json b/apps/labrinth/.sqlx/query-85463fa221147ee8d409fc92ed681fa27df683e7c80b8dd8616ae94dc1205c24.json new file mode 100644 index 000000000..45b717dbf --- /dev/null +++ b/apps/labrinth/.sqlx/query-85463fa221147ee8d409fc92ed681fa27df683e7c80b8dd8616ae94dc1205c24.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE versions\n SET author_id = $1\n WHERE (author_id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "85463fa221147ee8d409fc92ed681fa27df683e7c80b8dd8616ae94dc1205c24" +} diff --git a/apps/labrinth/.sqlx/query-868ee76d507cc9e94cd3c2e44770faff127e2b3c5f49b8100a9a37ac4d7b1f1d.json b/apps/labrinth/.sqlx/query-868ee76d507cc9e94cd3c2e44770faff127e2b3c5f49b8100a9a37ac4d7b1f1d.json new file mode 100644 index 000000000..461da117d --- /dev/null +++ b/apps/labrinth/.sqlx/query-868ee76d507cc9e94cd3c2e44770faff127e2b3c5f49b8100a9a37ac4d7b1f1d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET username = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "868ee76d507cc9e94cd3c2e44770faff127e2b3c5f49b8100a9a37ac4d7b1f1d" +} diff --git a/apps/labrinth/.sqlx/query-86b5f8c13cf232d55a6f5053db2727036fd3ccc7bd31b32aa443993d4815ab8f.json b/apps/labrinth/.sqlx/query-86b5f8c13cf232d55a6f5053db2727036fd3ccc7bd31b32aa443993d4815ab8f.json new file mode 100644 index 000000000..e2b69c798 --- /dev/null +++ b/apps/labrinth/.sqlx/query-86b5f8c13cf232d55a6f5053db2727036fd3ccc7bd31b32aa443993d4815ab8f.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE pats\n SET expires = $1\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Timestamptz", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "86b5f8c13cf232d55a6f5053db2727036fd3ccc7bd31b32aa443993d4815ab8f" +} diff --git a/apps/labrinth/.sqlx/query-86ee460c74f0052a4945ab4df9829b3b077930d8e9e09dca76fde8983413adc6.json b/apps/labrinth/.sqlx/query-86ee460c74f0052a4945ab4df9829b3b077930d8e9e09dca76fde8983413adc6.json new file mode 100644 index 000000000..e146de6b9 --- /dev/null +++ b/apps/labrinth/.sqlx/query-86ee460c74f0052a4945ab4df9829b3b077930d8e9e09dca76fde8983413adc6.json @@ -0,0 +1,82 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, user_id, price_id, amount, currency_code, status, due, last_attempt, charge_type, subscription_id, subscription_interval\n FROM charges\n WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "price_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "amount", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "currency_code", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "due", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_attempt", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "charge_type", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "subscription_id", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "subscription_interval", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + true + ] + }, + "hash": "86ee460c74f0052a4945ab4df9829b3b077930d8e9e09dca76fde8983413adc6" +} diff --git a/apps/labrinth/.sqlx/query-872374755deb5d68092dff9fcbc355da7c5c9cea4b471e79b17c4a9cfd6f9954.json b/apps/labrinth/.sqlx/query-872374755deb5d68092dff9fcbc355da7c5c9cea4b471e79b17c4a9cfd6f9954.json new file mode 100644 index 000000000..1dac397e7 --- /dev/null +++ b/apps/labrinth/.sqlx/query-872374755deb5d68092dff9fcbc355da7c5c9cea4b471e79b17c4a9cfd6f9954.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM mods_links\n WHERE joining_mod_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "872374755deb5d68092dff9fcbc355da7c5c9cea4b471e79b17c4a9cfd6f9954" +} diff --git a/apps/labrinth/.sqlx/query-87a0f2f991d749876d90b8e4ab73727f638a019b64e6cb1d891b333c2f09099c.json b/apps/labrinth/.sqlx/query-87a0f2f991d749876d90b8e4ab73727f638a019b64e6cb1d891b333c2f09099c.json new file mode 100644 index 000000000..57038f239 --- /dev/null +++ b/apps/labrinth/.sqlx/query-87a0f2f991d749876d90b8e4ab73727f638a019b64e6cb1d891b333c2f09099c.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, image_url, raw_image_url FROM mods_gallery\n WHERE image_url = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "image_url", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "raw_image_url", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "87a0f2f991d749876d90b8e4ab73727f638a019b64e6cb1d891b333c2f09099c" +} diff --git a/apps/labrinth/.sqlx/query-887a217868178265ac9e1011a889173d608e064a3a1b69a135273de380efe44c.json b/apps/labrinth/.sqlx/query-887a217868178265ac9e1011a889173d608e064a3a1b69a135273de380efe44c.json new file mode 100644 index 000000000..abe6d4217 --- /dev/null +++ b/apps/labrinth/.sqlx/query-887a217868178265ac9e1011a889173d608e064a3a1b69a135273de380efe44c.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT id, field, field_type, enum_type, min_val, max_val, optional\n FROM loader_fields lf\n WHERE id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "field", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "field_type", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "enum_type", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "min_val", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "max_val", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "optional", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int4Array" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false + ] + }, + "hash": "887a217868178265ac9e1011a889173d608e064a3a1b69a135273de380efe44c" +} diff --git a/apps/labrinth/.sqlx/query-88a085c2f2b1aa11eacdeedb68b490a695ebf2a9efb24bd5715b8d903f57e2c5.json b/apps/labrinth/.sqlx/query-88a085c2f2b1aa11eacdeedb68b490a695ebf2a9efb24bd5715b8d903f57e2c5.json new file mode 100644 index 000000000..fd7b63b90 --- /dev/null +++ b/apps/labrinth/.sqlx/query-88a085c2f2b1aa11eacdeedb68b490a695ebf2a9efb24bd5715b8d903f57e2c5.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM mods_links\n WHERE joining_mod_id = $1 AND joining_platform_id IN (\n SELECT id FROM link_platforms WHERE name = ANY($2)\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "TextArray" + ] + }, + "nullable": [] + }, + "hash": "88a085c2f2b1aa11eacdeedb68b490a695ebf2a9efb24bd5715b8d903f57e2c5" +} diff --git a/apps/labrinth/.sqlx/query-8a67a27f45a743f8679ec6021ef125c242cb339db8914afcc3e2c90b0c307053.json b/apps/labrinth/.sqlx/query-8a67a27f45a743f8679ec6021ef125c242cb339db8914afcc3e2c90b0c307053.json new file mode 100644 index 000000000..a149c1cd9 --- /dev/null +++ b/apps/labrinth/.sqlx/query-8a67a27f45a743f8679ec6021ef125c242cb339db8914afcc3e2c90b0c307053.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id FROM threads_messages WHERE author_id = $1 AND hide_identity = FALSE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "8a67a27f45a743f8679ec6021ef125c242cb339db8914afcc3e2c90b0c307053" +} diff --git a/apps/labrinth/.sqlx/query-8a9bf48b3d4aa665136568a9bf9ddb8e5d81ed27ce587e26672dfb45a44c7b9c.json b/apps/labrinth/.sqlx/query-8a9bf48b3d4aa665136568a9bf9ddb8e5d81ed27ce587e26672dfb45a44c7b9c.json new file mode 100644 index 000000000..fe2690142 --- /dev/null +++ b/apps/labrinth/.sqlx/query-8a9bf48b3d4aa665136568a9bf9ddb8e5d81ed27ce587e26672dfb45a44c7b9c.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO notifications (\n id, user_id, body\n )\n SELECT * FROM UNNEST($1::bigint[], $2::bigint[], $3::jsonb[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "Int8Array", + "JsonbArray" + ] + }, + "nullable": [] + }, + "hash": "8a9bf48b3d4aa665136568a9bf9ddb8e5d81ed27ce587e26672dfb45a44c7b9c" +} diff --git a/apps/labrinth/.sqlx/query-8b99c759446f40e4ec9539cd368526ad9bcb1ddb266124c5f890e3b051c74c59.json b/apps/labrinth/.sqlx/query-8b99c759446f40e4ec9539cd368526ad9bcb1ddb266124c5f890e3b051c74c59.json new file mode 100644 index 000000000..fe7254405 --- /dev/null +++ b/apps/labrinth/.sqlx/query-8b99c759446f40e4ec9539cd368526ad9bcb1ddb266124c5f890e3b051c74c59.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM mods_gallery\n WHERE mod_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "8b99c759446f40e4ec9539cd368526ad9bcb1ddb266124c5f890e3b051c74c59" +} diff --git a/apps/labrinth/.sqlx/query-8bcf4589c429ab0abf2460f658fd91caafb733a5217b832ab9dcf7fde60d49dd.json b/apps/labrinth/.sqlx/query-8bcf4589c429ab0abf2460f658fd91caafb733a5217b832ab9dcf7fde60d49dd.json new file mode 100644 index 000000000..36befffd1 --- /dev/null +++ b/apps/labrinth/.sqlx/query-8bcf4589c429ab0abf2460f658fd91caafb733a5217b832ab9dcf7fde60d49dd.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO users_subscriptions (\n id, user_id, price_id, interval, created, status, metadata\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7\n )\n ON CONFLICT (id)\n DO UPDATE\n SET interval = EXCLUDED.interval,\n status = EXCLUDED.status,\n price_id = EXCLUDED.price_id,\n metadata = EXCLUDED.metadata\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Text", + "Timestamptz", + "Varchar", + "Jsonb" + ] + }, + "nullable": [] + }, + "hash": "8bcf4589c429ab0abf2460f658fd91caafb733a5217b832ab9dcf7fde60d49dd" +} diff --git a/apps/labrinth/.sqlx/query-8cfa1380907e20fe18180d4f2ae929b7178f81056788ffb207a6c5e4bbcc7a7d.json b/apps/labrinth/.sqlx/query-8cfa1380907e20fe18180d4f2ae929b7178f81056788ffb207a6c5e4bbcc7a7d.json new file mode 100644 index 000000000..874d0bc04 --- /dev/null +++ b/apps/labrinth/.sqlx/query-8cfa1380907e20fe18180d4f2ae929b7178f81056788ffb207a6c5e4bbcc7a7d.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO version_fields (field_id, version_id, int_value, string_value, enum_value)\n SELECT * FROM UNNEST($1::integer[], $2::bigint[], $3::integer[], $4::text[], $5::integer[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4Array", + "Int8Array", + "Int4Array", + "TextArray", + "Int4Array" + ] + }, + "nullable": [] + }, + "hash": "8cfa1380907e20fe18180d4f2ae929b7178f81056788ffb207a6c5e4bbcc7a7d" +} diff --git a/apps/labrinth/.sqlx/query-8d7746fedec4c2339352a3acd934b13c351b8a1fdb05bf982bab1a5b7ed17f3b.json b/apps/labrinth/.sqlx/query-8d7746fedec4c2339352a3acd934b13c351b8a1fdb05bf982bab1a5b7ed17f3b.json new file mode 100644 index 000000000..d621218cd --- /dev/null +++ b/apps/labrinth/.sqlx/query-8d7746fedec4c2339352a3acd934b13c351b8a1fdb05bf982bab1a5b7ed17f3b.json @@ -0,0 +1,21 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO collections (\n id, user_id, name, description, \n created, icon_url, raw_icon_url, status\n )\n VALUES (\n $1, $2, $3, $4, \n $5, $6, $7, $8\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Varchar", + "Varchar", + "Timestamptz", + "Varchar", + "Text", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "8d7746fedec4c2339352a3acd934b13c351b8a1fdb05bf982bab1a5b7ed17f3b" +} diff --git a/apps/labrinth/.sqlx/query-8ef92ce880a7fdac4fc3a5dee50b30dc966b10872581c5932ae36dd28e930c6b.json b/apps/labrinth/.sqlx/query-8ef92ce880a7fdac4fc3a5dee50b30dc966b10872581c5932ae36dd28e930c6b.json new file mode 100644 index 000000000..3007b6d2b --- /dev/null +++ b/apps/labrinth/.sqlx/query-8ef92ce880a7fdac4fc3a5dee50b30dc966b10872581c5932ae36dd28e930c6b.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT mc.joining_mod_id mod_id, c.category name, mc.is_additional is_additional\n FROM mods_categories mc\n INNER JOIN categories c ON mc.joining_category_id = c.id\n WHERE joining_mod_id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "is_additional", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "8ef92ce880a7fdac4fc3a5dee50b30dc966b10872581c5932ae36dd28e930c6b" +} diff --git a/apps/labrinth/.sqlx/query-8f74918aa923e516b6b2967b7d1afbd02c8bde5466d22ad60ad735f8358cbf04.json b/apps/labrinth/.sqlx/query-8f74918aa923e516b6b2967b7d1afbd02c8bde5466d22ad60ad735f8358cbf04.json new file mode 100644 index 000000000..21ca8148d --- /dev/null +++ b/apps/labrinth/.sqlx/query-8f74918aa923e516b6b2967b7d1afbd02c8bde5466d22ad60ad735f8358cbf04.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM team_members\n WHERE team_id = $1\n RETURNING user_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "8f74918aa923e516b6b2967b7d1afbd02c8bde5466d22ad60ad735f8358cbf04" +} diff --git a/apps/labrinth/.sqlx/query-912250d37f13a98a21165c72bfc1eaa8a85b9952dd6750c117dca7fbb1bb8962.json b/apps/labrinth/.sqlx/query-912250d37f13a98a21165c72bfc1eaa8a85b9952dd6750c117dca7fbb1bb8962.json new file mode 100644 index 000000000..3aa79de8c --- /dev/null +++ b/apps/labrinth/.sqlx/query-912250d37f13a98a21165c72bfc1eaa8a85b9952dd6750c117dca7fbb1bb8962.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM threads_members\n WHERE user_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "912250d37f13a98a21165c72bfc1eaa8a85b9952dd6750c117dca7fbb1bb8962" +} diff --git a/apps/labrinth/.sqlx/query-91d2ce7ee6a29a47a20655fef577c42f1cbb2f8de4d9ea8ab361fc210f08aa20.json b/apps/labrinth/.sqlx/query-91d2ce7ee6a29a47a20655fef577c42f1cbb2f8de4d9ea8ab361fc210f08aa20.json new file mode 100644 index 000000000..689e1c6c5 --- /dev/null +++ b/apps/labrinth/.sqlx/query-91d2ce7ee6a29a47a20655fef577c42f1cbb2f8de4d9ea8ab361fc210f08aa20.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT status FROM mods WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "status", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "91d2ce7ee6a29a47a20655fef577c42f1cbb2f8de4d9ea8ab361fc210f08aa20" +} diff --git a/apps/labrinth/.sqlx/query-92c00ebff25cfb0464947ea48faac417fabdb3cb3edd5ed45720598c7c12c689.json b/apps/labrinth/.sqlx/query-92c00ebff25cfb0464947ea48faac417fabdb3cb3edd5ed45720598c7c12c689.json new file mode 100644 index 000000000..133a13f0d --- /dev/null +++ b/apps/labrinth/.sqlx/query-92c00ebff25cfb0464947ea48faac417fabdb3cb3edd5ed45720598c7c12c689.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM payouts_values\n WHERE user_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "92c00ebff25cfb0464947ea48faac417fabdb3cb3edd5ed45720598c7c12c689" +} diff --git a/apps/labrinth/.sqlx/query-95b52480a3fc7257a95e1cbc0e05f13c7934e3019675c04d9d3f240eb590bdc4.json b/apps/labrinth/.sqlx/query-95b52480a3fc7257a95e1cbc0e05f13c7934e3019675c04d9d3f240eb590bdc4.json new file mode 100644 index 000000000..baa4b0c84 --- /dev/null +++ b/apps/labrinth/.sqlx/query-95b52480a3fc7257a95e1cbc0e05f13c7934e3019675c04d9d3f240eb590bdc4.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM charges WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "95b52480a3fc7257a95e1cbc0e05f13c7934e3019675c04d9d3f240eb590bdc4" +} diff --git a/apps/labrinth/.sqlx/query-95cb791af4ea4d5b959de9e451bb8875336db33238024812086b5237b4dac350.json b/apps/labrinth/.sqlx/query-95cb791af4ea4d5b959de9e451bb8875336db33238024812086b5237b4dac350.json new file mode 100644 index 000000000..2484adbd4 --- /dev/null +++ b/apps/labrinth/.sqlx/query-95cb791af4ea4d5b959de9e451bb8875336db33238024812086b5237b4dac350.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM pats WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "95cb791af4ea4d5b959de9e451bb8875336db33238024812086b5237b4dac350" +} diff --git a/apps/labrinth/.sqlx/query-95e17b2512494ffcbfe6278b87aa273edc5729633aeaa87f6239667d2f861e68.json b/apps/labrinth/.sqlx/query-95e17b2512494ffcbfe6278b87aa273edc5729633aeaa87f6239667d2f861e68.json new file mode 100644 index 000000000..063c2e0e5 --- /dev/null +++ b/apps/labrinth/.sqlx/query-95e17b2512494ffcbfe6278b87aa273edc5729633aeaa87f6239667d2f861e68.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET status = 'rejected'\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "95e17b2512494ffcbfe6278b87aa273edc5729633aeaa87f6239667d2f861e68" +} diff --git a/apps/labrinth/.sqlx/query-97690dda7edea8c985891cae5ad405f628ed81e333bc88df5493c928a4324d43.json b/apps/labrinth/.sqlx/query-97690dda7edea8c985891cae5ad405f628ed81e333bc88df5493c928a4324d43.json new file mode 100644 index 000000000..e6b76ea72 --- /dev/null +++ b/apps/labrinth/.sqlx/query-97690dda7edea8c985891cae5ad405f628ed81e333bc88df5493c928a4324d43.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM reports WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "97690dda7edea8c985891cae5ad405f628ed81e333bc88df5493c928a4324d43" +} diff --git a/apps/labrinth/.sqlx/query-99e7779380ebae726051ba8e2810f37bee36f3fb36729c07ef11d0ac1b611d7e.json b/apps/labrinth/.sqlx/query-99e7779380ebae726051ba8e2810f37bee36f3fb36729c07ef11d0ac1b611d7e.json new file mode 100644 index 000000000..dbbff0ee1 --- /dev/null +++ b/apps/labrinth/.sqlx/query-99e7779380ebae726051ba8e2810f37bee36f3fb36729c07ef11d0ac1b611d7e.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET totp_secret = NULL\n WHERE (id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "99e7779380ebae726051ba8e2810f37bee36f3fb36729c07ef11d0ac1b611d7e" +} diff --git a/apps/labrinth/.sqlx/query-9abdd9a2018e7bfe26836dd5463ba0923ef0a76c32ca258faf55fc3301c567bf.json b/apps/labrinth/.sqlx/query-9abdd9a2018e7bfe26836dd5463ba0923ef0a76c32ca258faf55fc3301c567bf.json new file mode 100644 index 000000000..92195b683 --- /dev/null +++ b/apps/labrinth/.sqlx/query-9abdd9a2018e7bfe26836dd5463ba0923ef0a76c32ca258faf55fc3301c567bf.json @@ -0,0 +1,83 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, team_id, role AS member_role, is_owner, permissions, organization_permissions,\n accepted, payouts_split, role,\n ordering, user_id\n \n FROM team_members\n WHERE (team_id = $1 AND user_id = $2)\n ORDER BY ordering\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "team_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "member_role", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "is_owner", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "permissions", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "organization_permissions", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "accepted", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "payouts_split", + "type_info": "Numeric" + }, + { + "ordinal": 8, + "name": "role", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "ordering", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "user_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false + ] + }, + "hash": "9abdd9a2018e7bfe26836dd5463ba0923ef0a76c32ca258faf55fc3301c567bf" +} diff --git a/apps/labrinth/.sqlx/query-9bf8862af8f636c4ef77e8c9f1f5d31d4f2d3f5b73fb6e6ca8a09ad5224250c3.json b/apps/labrinth/.sqlx/query-9bf8862af8f636c4ef77e8c9f1f5d31d4f2d3f5b73fb6e6ca8a09ad5224250c3.json new file mode 100644 index 000000000..7e1bebc74 --- /dev/null +++ b/apps/labrinth/.sqlx/query-9bf8862af8f636c4ef77e8c9f1f5d31d4f2d3f5b73fb6e6ca8a09ad5224250c3.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET totp_secret = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "9bf8862af8f636c4ef77e8c9f1f5d31d4f2d3f5b73fb6e6ca8a09ad5224250c3" +} diff --git a/apps/labrinth/.sqlx/query-9c1b6ba7cbe2619ff767ee7bbfb01725dc3324d284b2f20cf393574ab3bc655f.json b/apps/labrinth/.sqlx/query-9c1b6ba7cbe2619ff767ee7bbfb01725dc3324d284b2f20cf393574ab3bc655f.json new file mode 100644 index 000000000..5152ba222 --- /dev/null +++ b/apps/labrinth/.sqlx/query-9c1b6ba7cbe2619ff767ee7bbfb01725dc3324d284b2f20cf393574ab3bc655f.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET name = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "9c1b6ba7cbe2619ff767ee7bbfb01725dc3324d284b2f20cf393574ab3bc655f" +} diff --git a/apps/labrinth/.sqlx/query-9d46594c3dda50dc84defee87fa98210989dd59b06941a5e71b6661f059c9692.json b/apps/labrinth/.sqlx/query-9d46594c3dda50dc84defee87fa98210989dd59b06941a5e71b6661f059c9692.json new file mode 100644 index 000000000..089981a7c --- /dev/null +++ b/apps/labrinth/.sqlx/query-9d46594c3dda50dc84defee87fa98210989dd59b06941a5e71b6661f059c9692.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO threads_messages (\n id, author_id, body, thread_id, hide_identity\n )\n VALUES (\n $1, $2, $3, $4, $5\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Jsonb", + "Int8", + "Bool" + ] + }, + "nullable": [] + }, + "hash": "9d46594c3dda50dc84defee87fa98210989dd59b06941a5e71b6661f059c9692" +} diff --git a/apps/labrinth/.sqlx/query-9d68929e384db6dc734afca0dfdfef15f103b6eccdf0d1d144180b0d7d4e3400.json b/apps/labrinth/.sqlx/query-9d68929e384db6dc734afca0dfdfef15f103b6eccdf0d1d144180b0d7d4e3400.json new file mode 100644 index 000000000..a94d1a466 --- /dev/null +++ b/apps/labrinth/.sqlx/query-9d68929e384db6dc734afca0dfdfef15f103b6eccdf0d1d144180b0d7d4e3400.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM collections_mods\n WHERE collection_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "9d68929e384db6dc734afca0dfdfef15f103b6eccdf0d1d144180b0d7d4e3400" +} diff --git a/apps/labrinth/.sqlx/query-9dadd6926a8429e60cb5fd53285b81f2f47ccdded1e764c04d8b7651d9796ce0.json b/apps/labrinth/.sqlx/query-9dadd6926a8429e60cb5fd53285b81f2f47ccdded1e764c04d8b7651d9796ce0.json new file mode 100644 index 000000000..4c3c291ba --- /dev/null +++ b/apps/labrinth/.sqlx/query-9dadd6926a8429e60cb5fd53285b81f2f47ccdded1e764c04d8b7651d9796ce0.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO oauth_client_redirect_uris (id, client_id, uri)\n SELECT * FROM UNNEST($1::bigint[], $2::bigint[], $3::varchar[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "Int8Array", + "VarcharArray" + ] + }, + "nullable": [] + }, + "hash": "9dadd6926a8429e60cb5fd53285b81f2f47ccdded1e764c04d8b7651d9796ce0" +} diff --git a/apps/labrinth/.sqlx/query-a0148ff25855202e7bb220b6a2bc9220a95e309fb0dae41d9a05afa86e6b33af.json b/apps/labrinth/.sqlx/query-a0148ff25855202e7bb220b6a2bc9220a95e309fb0dae41d9a05afa86e6b33af.json new file mode 100644 index 000000000..a8ec2492d --- /dev/null +++ b/apps/labrinth/.sqlx/query-a0148ff25855202e7bb220b6a2bc9220a95e309fb0dae41d9a05afa86e6b33af.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM mods_categories\n WHERE joining_mod_id = $1 AND is_additional = FALSE\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "a0148ff25855202e7bb220b6a2bc9220a95e309fb0dae41d9a05afa86e6b33af" +} diff --git a/apps/labrinth/.sqlx/query-a0c91184d5a02b986decac3c34e78b61451ff90e103bcf1ec46f8da3bbcc1ff2.json b/apps/labrinth/.sqlx/query-a0c91184d5a02b986decac3c34e78b61451ff90e103bcf1ec46f8da3bbcc1ff2.json new file mode 100644 index 000000000..fc626c1e7 --- /dev/null +++ b/apps/labrinth/.sqlx/query-a0c91184d5a02b986decac3c34e78b61451ff90e103bcf1ec46f8da3bbcc1ff2.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM notifications_actions\n WHERE notification_id = ANY($1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "a0c91184d5a02b986decac3c34e78b61451ff90e103bcf1ec46f8da3bbcc1ff2" +} diff --git a/apps/labrinth/.sqlx/query-a11d613479d09dff5fcdc45ab7a0341fb1b4738f0ede71572d939ef0984bd65f.json b/apps/labrinth/.sqlx/query-a11d613479d09dff5fcdc45ab7a0341fb1b4738f0ede71572d939ef0984bd65f.json new file mode 100644 index 000000000..4b97bd691 --- /dev/null +++ b/apps/labrinth/.sqlx/query-a11d613479d09dff5fcdc45ab7a0341fb1b4738f0ede71572d939ef0984bd65f.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET approved = NOW()\n WHERE id = $1 AND approved IS NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "a11d613479d09dff5fcdc45ab7a0341fb1b4738f0ede71572d939ef0984bd65f" +} diff --git a/apps/labrinth/.sqlx/query-a1331f7c6f33234e413978c0d9318365e7de5948b93e8c0c85a1d179f4968517.json b/apps/labrinth/.sqlx/query-a1331f7c6f33234e413978c0d9318365e7de5948b93e8c0c85a1d179f4968517.json new file mode 100644 index 000000000..165e3c68a --- /dev/null +++ b/apps/labrinth/.sqlx/query-a1331f7c6f33234e413978c0d9318365e7de5948b93e8c0c85a1d179f4968517.json @@ -0,0 +1,65 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, name, access_token, scopes, user_id, created, expires, last_used\n FROM pats\n WHERE id = ANY($1) OR access_token = ANY($2)\n ORDER BY created DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "access_token", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "scopes", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "expires", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_used", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8Array", + "TextArray" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "a1331f7c6f33234e413978c0d9318365e7de5948b93e8c0c85a1d179f4968517" +} diff --git a/apps/labrinth/.sqlx/query-a1ba3b5cc50b1eb24f5529e06be1439f4a313c4ea8845c2733db752e53f5ae1c.json b/apps/labrinth/.sqlx/query-a1ba3b5cc50b1eb24f5529e06be1439f4a313c4ea8845c2733db752e53f5ae1c.json new file mode 100644 index 000000000..3d018fc42 --- /dev/null +++ b/apps/labrinth/.sqlx/query-a1ba3b5cc50b1eb24f5529e06be1439f4a313c4ea8845c2733db752e53f5ae1c.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT COUNT(f.id) FROM files f\n INNER JOIN versions v on f.version_id = v.id AND v.status = ANY($2)\n INNER JOIN mods m on v.mod_id = m.id AND m.status = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "TextArray", + "TextArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "a1ba3b5cc50b1eb24f5529e06be1439f4a313c4ea8845c2733db752e53f5ae1c" +} diff --git a/apps/labrinth/.sqlx/query-a25b09712476fa4b12d98e08a4d260260e250e46fc68d806bf6372130cc65e1b.json b/apps/labrinth/.sqlx/query-a25b09712476fa4b12d98e08a4d260260e250e46fc68d806bf6372130cc65e1b.json new file mode 100644 index 000000000..d491a0ab9 --- /dev/null +++ b/apps/labrinth/.sqlx/query-a25b09712476fa4b12d98e08a4d260260e250e46fc68d806bf6372130cc65e1b.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE team_members\n SET is_owner = $1\n WHERE (team_id = $2 AND user_id = $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bool", + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "a25b09712476fa4b12d98e08a4d260260e250e46fc68d806bf6372130cc65e1b" +} diff --git a/apps/labrinth/.sqlx/query-a25ee30b6968dc98b66b1beac5124f39c64ad8815ff0ec0a98903fee0b4167c7.json b/apps/labrinth/.sqlx/query-a25ee30b6968dc98b66b1beac5124f39c64ad8815ff0ec0a98903fee0b4167c7.json new file mode 100644 index 000000000..179313d3c --- /dev/null +++ b/apps/labrinth/.sqlx/query-a25ee30b6968dc98b66b1beac5124f39c64ad8815ff0ec0a98903fee0b4167c7.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n us.id, us.user_id, us.price_id, us.interval, us.created, us.status, us.metadata\n FROM users_subscriptions us\n WHERE us.id = ANY($1::bigint[])", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "price_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "interval", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "metadata", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "a25ee30b6968dc98b66b1beac5124f39c64ad8815ff0ec0a98903fee0b4167c7" +} diff --git a/apps/labrinth/.sqlx/query-a2f510708f04ad72fe36af9fa96bfb775fb088579fe23bcb87f50f5a8578f3c0.json b/apps/labrinth/.sqlx/query-a2f510708f04ad72fe36af9fa96bfb775fb088579fe23bcb87f50f5a8578f3c0.json new file mode 100644 index 000000000..897894b7a --- /dev/null +++ b/apps/labrinth/.sqlx/query-a2f510708f04ad72fe36af9fa96bfb775fb088579fe23bcb87f50f5a8578f3c0.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM version_fields vf\n WHERE vf.version_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "a2f510708f04ad72fe36af9fa96bfb775fb088579fe23bcb87f50f5a8578f3c0" +} diff --git a/apps/labrinth/.sqlx/query-a3448f22ec82f75ab2f3769b7d0a653a7d7315fb5e4696c26c6a96e6fc11e907.json b/apps/labrinth/.sqlx/query-a3448f22ec82f75ab2f3769b7d0a653a7d7315fb5e4696c26c6a96e6fc11e907.json new file mode 100644 index 000000000..d1a964156 --- /dev/null +++ b/apps/labrinth/.sqlx/query-a3448f22ec82f75ab2f3769b7d0a653a7d7315fb5e4696c26c6a96e6fc11e907.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT m.id FROM organizations o\n INNER JOIN mods m ON m.organization_id = o.id\n WHERE (o.id = $1 AND $1 IS NOT NULL) OR (o.slug = $2 AND $2 IS NOT NULL)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "a3448f22ec82f75ab2f3769b7d0a653a7d7315fb5e4696c26c6a96e6fc11e907" +} diff --git a/apps/labrinth/.sqlx/query-a40e4075ba1bff5b6fde104ed1557ad8d4a75d7d90d481decd222f31685c4981.json b/apps/labrinth/.sqlx/query-a40e4075ba1bff5b6fde104ed1557ad8d4a75d7d90d481decd222f31685c4981.json new file mode 100644 index 000000000..dd7086e80 --- /dev/null +++ b/apps/labrinth/.sqlx/query-a40e4075ba1bff5b6fde104ed1557ad8d4a75d7d90d481decd222f31685c4981.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM dependencies WHERE dependent_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "a40e4075ba1bff5b6fde104ed1557ad8d4a75d7d90d481decd222f31685c4981" +} diff --git a/apps/labrinth/.sqlx/query-a4745a3dc87c3a858819b208b0c3a010dc297425883113565d934b8a834014ce.json b/apps/labrinth/.sqlx/query-a4745a3dc87c3a858819b208b0c3a010dc297425883113565d934b8a834014ce.json new file mode 100644 index 000000000..10cba0801 --- /dev/null +++ b/apps/labrinth/.sqlx/query-a4745a3dc87c3a858819b208b0c3a010dc297425883113565d934b8a834014ce.json @@ -0,0 +1,25 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO versions (\n id, mod_id, author_id, name, version_number,\n changelog, date_published, downloads,\n version_type, featured, status, ordering\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8,\n $9, $10, $11, $12\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Int8", + "Varchar", + "Varchar", + "Varchar", + "Timestamptz", + "Int4", + "Varchar", + "Bool", + "Varchar", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "a4745a3dc87c3a858819b208b0c3a010dc297425883113565d934b8a834014ce" +} diff --git a/apps/labrinth/.sqlx/query-a48b717b74531dc457069ee811ec1adc1da195f00a42fff7f08667b139cd8fea.json b/apps/labrinth/.sqlx/query-a48b717b74531dc457069ee811ec1adc1da195f00a42fff7f08667b139cd8fea.json new file mode 100644 index 000000000..94cf2d25b --- /dev/null +++ b/apps/labrinth/.sqlx/query-a48b717b74531dc457069ee811ec1adc1da195f00a42fff7f08667b139cd8fea.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO user_backup_codes (\n user_id, code\n )\n VALUES (\n $1, $2\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "a48b717b74531dc457069ee811ec1adc1da195f00a42fff7f08667b139cd8fea" +} diff --git a/apps/labrinth/.sqlx/query-a5007d03b1b5b2a95814a3070d114c55731403dcd75d44420acce8df5bd2009b.json b/apps/labrinth/.sqlx/query-a5007d03b1b5b2a95814a3070d114c55731403dcd75d44420acce8df5bd2009b.json new file mode 100644 index 000000000..1b838c4ad --- /dev/null +++ b/apps/labrinth/.sqlx/query-a5007d03b1b5b2a95814a3070d114c55731403dcd75d44420acce8df5bd2009b.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, team_id, role AS member_role, is_owner, permissions, organization_permissions,\n accepted, payouts_split,\n ordering, user_id\n FROM team_members\n WHERE team_id = ANY($1)\n ORDER BY team_id, ordering;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "team_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "member_role", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "is_owner", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "permissions", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "organization_permissions", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "accepted", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "payouts_split", + "type_info": "Numeric" + }, + { + "ordinal": 8, + "name": "ordering", + "type_info": "Int8" + }, + { + "ordinal": 9, + "name": "user_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + false, + false, + false + ] + }, + "hash": "a5007d03b1b5b2a95814a3070d114c55731403dcd75d44420acce8df5bd2009b" +} diff --git a/apps/labrinth/.sqlx/query-a87c913916adf9177f8f38369975d5fc644d989293ccb42c1e06ec54dc2571f8.json b/apps/labrinth/.sqlx/query-a87c913916adf9177f8f38369975d5fc644d989293ccb42c1e06ec54dc2571f8.json new file mode 100644 index 000000000..66986fd92 --- /dev/null +++ b/apps/labrinth/.sqlx/query-a87c913916adf9177f8f38369975d5fc644d989293ccb42c1e06ec54dc2571f8.json @@ -0,0 +1,82 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, user_id, price_id, amount, currency_code, status, due, last_attempt, charge_type, subscription_id, subscription_interval\n FROM charges\n WHERE (status = 'cancelled' AND due < $1) OR (status = 'failed' AND last_attempt < $1 - INTERVAL '2 days')", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "price_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "amount", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "currency_code", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "due", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_attempt", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "charge_type", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "subscription_id", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "subscription_interval", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Timestamptz" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + true + ] + }, + "hash": "a87c913916adf9177f8f38369975d5fc644d989293ccb42c1e06ec54dc2571f8" +} diff --git a/apps/labrinth/.sqlx/query-a8bfce13de871daf0bb1cf73b4c5ded611ff58d94461404182942210492e8010.json b/apps/labrinth/.sqlx/query-a8bfce13de871daf0bb1cf73b4c5ded611ff58d94461404182942210492e8010.json new file mode 100644 index 000000000..f762fb0ec --- /dev/null +++ b/apps/labrinth/.sqlx/query-a8bfce13de871daf0bb1cf73b4c5ded611ff58d94461404182942210492e8010.json @@ -0,0 +1,36 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT m.id id, tm.user_id user_id, tm.payouts_split payouts_split\n FROM mods m\n INNER JOIN team_members tm on m.team_id = tm.team_id AND tm.accepted = TRUE\n WHERE m.id = ANY($1) AND m.monetization_status = $2 AND m.status = ANY($3)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "payouts_split", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "Int8Array", + "Text", + "TextArray" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "a8bfce13de871daf0bb1cf73b4c5ded611ff58d94461404182942210492e8010" +} diff --git a/apps/labrinth/.sqlx/query-a911bd1b5d19d305e5dae51941c169cba3afed4b6c7d9b99fc2d6a0db853cc5c.json b/apps/labrinth/.sqlx/query-a911bd1b5d19d305e5dae51941c169cba3afed4b6c7d9b99fc2d6a0db853cc5c.json new file mode 100644 index 000000000..483cd7940 --- /dev/null +++ b/apps/labrinth/.sqlx/query-a911bd1b5d19d305e5dae51941c169cba3afed4b6c7d9b99fc2d6a0db853cc5c.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT balance FROM users WHERE id = $1 FOR UPDATE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "balance", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "a911bd1b5d19d305e5dae51941c169cba3afed4b6c7d9b99fc2d6a0db853cc5c" +} diff --git a/apps/labrinth/.sqlx/query-aab77a2364f08b81d3e01bf396da0ea5162f0c11465dfeb50eb17ca6c8bd337b.json b/apps/labrinth/.sqlx/query-aab77a2364f08b81d3e01bf396da0ea5162f0c11465dfeb50eb17ca6c8bd337b.json new file mode 100644 index 000000000..bfbc024f1 --- /dev/null +++ b/apps/labrinth/.sqlx/query-aab77a2364f08b81d3e01bf396da0ea5162f0c11465dfeb50eb17ca6c8bd337b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET venmo_handle = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "aab77a2364f08b81d3e01bf396da0ea5162f0c11465dfeb50eb17ca6c8bd337b" +} diff --git a/apps/labrinth/.sqlx/query-aaec611bae08eac41c163367dc508208178170de91165095405f1b41e47f5e7f.json b/apps/labrinth/.sqlx/query-aaec611bae08eac41c163367dc508208178170de91165095405f1b41e47f5e7f.json new file mode 100644 index 000000000..76e5e3ec3 --- /dev/null +++ b/apps/labrinth/.sqlx/query-aaec611bae08eac41c163367dc508208178170de91165095405f1b41e47f5e7f.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT COUNT(DISTINCT u.id)\n FROM users u\n INNER JOIN team_members tm on u.id = tm.user_id AND tm.accepted = TRUE\n INNER JOIN mods m on tm.team_id = m.team_id AND m.status = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "TextArray" + ] + }, + "nullable": [ + null + ] + }, + "hash": "aaec611bae08eac41c163367dc508208178170de91165095405f1b41e47f5e7f" +} diff --git a/apps/labrinth/.sqlx/query-aaec67a66b58dec36339c14000b319aed1b0ebb1324fc85e34d14c6430c26657.json b/apps/labrinth/.sqlx/query-aaec67a66b58dec36339c14000b319aed1b0ebb1324fc85e34d14c6430c26657.json new file mode 100644 index 000000000..4c1ddeb3f --- /dev/null +++ b/apps/labrinth/.sqlx/query-aaec67a66b58dec36339c14000b319aed1b0ebb1324fc85e34d14c6430c26657.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id FROM categories\n WHERE category = $1 AND project_type = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text", + "Int4" + ] + }, + "nullable": [ + false + ] + }, + "hash": "aaec67a66b58dec36339c14000b319aed1b0ebb1324fc85e34d14c6430c26657" +} diff --git a/apps/labrinth/.sqlx/query-abf790170e3a807ffe8b3a188da620c89e6398f38ff066220fdadffe8e7481c1.json b/apps/labrinth/.sqlx/query-abf790170e3a807ffe8b3a188da620c89e6398f38ff066220fdadffe8e7481c1.json new file mode 100644 index 000000000..20e672605 --- /dev/null +++ b/apps/labrinth/.sqlx/query-abf790170e3a807ffe8b3a188da620c89e6398f38ff066220fdadffe8e7481c1.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(SELECT 1 FROM mods WHERE slug = LOWER($1))\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "abf790170e3a807ffe8b3a188da620c89e6398f38ff066220fdadffe8e7481c1" +} diff --git a/apps/labrinth/.sqlx/query-acd2e72610008d4fe240cdfadc1c70c997443f7319a5c535df967d56d24bd54a.json b/apps/labrinth/.sqlx/query-acd2e72610008d4fe240cdfadc1c70c997443f7319a5c535df967d56d24bd54a.json new file mode 100644 index 000000000..a80e8a365 --- /dev/null +++ b/apps/labrinth/.sqlx/query-acd2e72610008d4fe240cdfadc1c70c997443f7319a5c535df967d56d24bd54a.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM version_fields \n WHERE version_id = $1\n AND field_id = ANY($2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int4Array" + ] + }, + "nullable": [] + }, + "hash": "acd2e72610008d4fe240cdfadc1c70c997443f7319a5c535df967d56d24bd54a" +} diff --git a/apps/labrinth/.sqlx/query-ad27195af9964c34803343c22abcb9aa6b52f2d1a370550ed4fb68bce2297e71.json b/apps/labrinth/.sqlx/query-ad27195af9964c34803343c22abcb9aa6b52f2d1a370550ed4fb68bce2297e71.json new file mode 100644 index 000000000..dcd7d036d --- /dev/null +++ b/apps/labrinth/.sqlx/query-ad27195af9964c34803343c22abcb9aa6b52f2d1a370550ed4fb68bce2297e71.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM pats WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "ad27195af9964c34803343c22abcb9aa6b52f2d1a370550ed4fb68bce2297e71" +} diff --git a/apps/labrinth/.sqlx/query-ae1686b8b566dd7ecc57c653c9313a4b324a2ec3a63aa6a44ed1d8ea7999b115.json b/apps/labrinth/.sqlx/query-ae1686b8b566dd7ecc57c653c9313a4b324a2ec3a63aa6a44ed1d8ea7999b115.json new file mode 100644 index 000000000..69b2fe0c6 --- /dev/null +++ b/apps/labrinth/.sqlx/query-ae1686b8b566dd7ecc57c653c9313a4b324a2ec3a63aa6a44ed1d8ea7999b115.json @@ -0,0 +1,12 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM dependencies WHERE mod_dependency_id = NULL AND dependency_id = NULL AND dependency_file_name = NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [] + }, + "nullable": [] + }, + "hash": "ae1686b8b566dd7ecc57c653c9313a4b324a2ec3a63aa6a44ed1d8ea7999b115" +} diff --git a/apps/labrinth/.sqlx/query-ae99bfaea7f127d24b714302c9b1d6894d06485b3c62a8921e6e82086a425ad4.json b/apps/labrinth/.sqlx/query-ae99bfaea7f127d24b714302c9b1d6894d06485b3c62a8921e6e82086a425ad4.json new file mode 100644 index 000000000..5c23a0c0d --- /dev/null +++ b/apps/labrinth/.sqlx/query-ae99bfaea7f127d24b714302c9b1d6894d06485b3c62a8921e6e82086a425ad4.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM mod_follows\n WHERE mod_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "ae99bfaea7f127d24b714302c9b1d6894d06485b3c62a8921e6e82086a425ad4" +} diff --git a/apps/labrinth/.sqlx/query-aebefe598486b0e1a5c74eeb9e69f6322fd745fce730843337eb82d7ceff3a2f.json b/apps/labrinth/.sqlx/query-aebefe598486b0e1a5c74eeb9e69f6322fd745fce730843337eb82d7ceff3a2f.json new file mode 100644 index 000000000..9f8e6e37b --- /dev/null +++ b/apps/labrinth/.sqlx/query-aebefe598486b0e1a5c74eeb9e69f6322fd745fce730843337eb82d7ceff3a2f.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE collections\n SET icon_url = NULL, raw_icon_url = NULL, color = NULL\n WHERE (id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "aebefe598486b0e1a5c74eeb9e69f6322fd745fce730843337eb82d7ceff3a2f" +} diff --git a/apps/labrinth/.sqlx/query-af1a10f0fa88c7893cff3a451fd890762fd7068cab7822a5b60545b44e6ba775.json b/apps/labrinth/.sqlx/query-af1a10f0fa88c7893cff3a451fd890762fd7068cab7822a5b60545b44e6ba775.json new file mode 100644 index 000000000..84837d7a8 --- /dev/null +++ b/apps/labrinth/.sqlx/query-af1a10f0fa88c7893cff3a451fd890762fd7068cab7822a5b60545b44e6ba775.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n us.id, us.user_id, us.price_id, us.interval, us.created, us.status, us.metadata\n FROM users_subscriptions us\n WHERE us.user_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "price_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "interval", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "metadata", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "af1a10f0fa88c7893cff3a451fd890762fd7068cab7822a5b60545b44e6ba775" +} diff --git a/apps/labrinth/.sqlx/query-afdee0d5dce6cb037d3349aed3dc96fdc68092fca1a752c5df5c465406e2b4b5.json b/apps/labrinth/.sqlx/query-afdee0d5dce6cb037d3349aed3dc96fdc68092fca1a752c5df5c465406e2b4b5.json new file mode 100644 index 000000000..abde9ea15 --- /dev/null +++ b/apps/labrinth/.sqlx/query-afdee0d5dce6cb037d3349aed3dc96fdc68092fca1a752c5df5c465406e2b4b5.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET icon_url = NULL, raw_icon_url = NULL, color = NULL\n WHERE (id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "afdee0d5dce6cb037d3349aed3dc96fdc68092fca1a752c5df5c465406e2b4b5" +} diff --git a/apps/labrinth/.sqlx/query-b1bfb62f4a28ca0fae778738e8a8f325185a83c5d5a240c088a2397b35c55f2a.json b/apps/labrinth/.sqlx/query-b1bfb62f4a28ca0fae778738e8a8f325185a83c5d5a240c088a2397b35c55f2a.json new file mode 100644 index 000000000..5610c1030 --- /dev/null +++ b/apps/labrinth/.sqlx/query-b1bfb62f4a28ca0fae778738e8a8f325185a83c5d5a240c088a2397b35c55f2a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id FROM link_platforms\n WHERE name = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "b1bfb62f4a28ca0fae778738e8a8f325185a83c5d5a240c088a2397b35c55f2a" +} diff --git a/apps/labrinth/.sqlx/query-b26cbb11458743ba0677f4ca24ceaff0f9766ddac4a076010c98cf086dd1d7af.json b/apps/labrinth/.sqlx/query-b26cbb11458743ba0677f4ca24ceaff0f9766ddac4a076010c98cf086dd1d7af.json new file mode 100644 index 000000000..63d2ba63c --- /dev/null +++ b/apps/labrinth/.sqlx/query-b26cbb11458743ba0677f4ca24ceaff0f9766ddac4a076010c98cf086dd1d7af.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(SELECT 1 FROM organizations WHERE id=$1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "b26cbb11458743ba0677f4ca24ceaff0f9766ddac4a076010c98cf086dd1d7af" +} diff --git a/apps/labrinth/.sqlx/query-b28b380e2d728c4733b9654e433b716114a215240845345b168d832e75769398.json b/apps/labrinth/.sqlx/query-b28b380e2d728c4733b9654e433b716114a215240845345b168d832e75769398.json new file mode 100644 index 000000000..4e4c3ea67 --- /dev/null +++ b/apps/labrinth/.sqlx/query-b28b380e2d728c4733b9654e433b716114a215240845345b168d832e75769398.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM collections\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "b28b380e2d728c4733b9654e433b716114a215240845345b168d832e75769398" +} diff --git a/apps/labrinth/.sqlx/query-b297c97cd18785279cee369a1a269326ade765652ccf87405e6ee7dd3cbdaabf.json b/apps/labrinth/.sqlx/query-b297c97cd18785279cee369a1a269326ade765652ccf87405e6ee7dd3cbdaabf.json new file mode 100644 index 000000000..5b9e9e4cb --- /dev/null +++ b/apps/labrinth/.sqlx/query-b297c97cd18785279cee369a1a269326ade765652ccf87405e6ee7dd3cbdaabf.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE pats\n SET name = $1\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "b297c97cd18785279cee369a1a269326ade765652ccf87405e6ee7dd3cbdaabf" +} diff --git a/apps/labrinth/.sqlx/query-b30d0365bd116fceee5de03fb9e3087a587633783894a5041889b856d47a4ed5.json b/apps/labrinth/.sqlx/query-b30d0365bd116fceee5de03fb9e3087a587633783894a5041889b856d47a4ed5.json new file mode 100644 index 000000000..6142e7dca --- /dev/null +++ b/apps/labrinth/.sqlx/query-b30d0365bd116fceee5de03fb9e3087a587633783894a5041889b856d47a4ed5.json @@ -0,0 +1,88 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.updated updated, m.approved approved, m.published, m.license license, m.slug slug, m.color\n FROM mods m\n WHERE m.status = ANY($1)\n GROUP BY m.id;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "summary", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "downloads", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "follows", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "icon_url", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "updated", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "approved", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "published", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "license", + "type_info": "Varchar" + }, + { + "ordinal": 10, + "name": "slug", + "type_info": "Varchar" + }, + { + "ordinal": 11, + "name": "color", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "TextArray" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + true, + false, + false, + true, + true + ] + }, + "hash": "b30d0365bd116fceee5de03fb9e3087a587633783894a5041889b856d47a4ed5" +} diff --git a/apps/labrinth/.sqlx/query-b3345991457853c3f4c49dd68239bb23c3502d5c46008eb1b50233546a6ffa5d.json b/apps/labrinth/.sqlx/query-b3345991457853c3f4c49dd68239bb23c3502d5c46008eb1b50233546a6ffa5d.json new file mode 100644 index 000000000..1f890c631 --- /dev/null +++ b/apps/labrinth/.sqlx/query-b3345991457853c3f4c49dd68239bb23c3502d5c46008eb1b50233546a6ffa5d.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE payouts_values\n SET mod_id = NULL\n WHERE (mod_id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "b3345991457853c3f4c49dd68239bb23c3502d5c46008eb1b50233546a6ffa5d" +} diff --git a/apps/labrinth/.sqlx/query-b3475fbc7a4d8b353964e6c5f7d32c45947cfdc88be25ab04dff16eb289dcbcb.json b/apps/labrinth/.sqlx/query-b3475fbc7a4d8b353964e6c5f7d32c45947cfdc88be25ab04dff16eb289dcbcb.json new file mode 100644 index 000000000..2368cf644 --- /dev/null +++ b/apps/labrinth/.sqlx/query-b3475fbc7a4d8b353964e6c5f7d32c45947cfdc88be25ab04dff16eb289dcbcb.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE reports\n SET version_id = NULL\n WHERE version_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "b3475fbc7a4d8b353964e6c5f7d32c45947cfdc88be25ab04dff16eb289dcbcb" +} diff --git a/apps/labrinth/.sqlx/query-b49cd556b85c3e74ebb4f1b7d48930c0456321799f20e63f1c3fd3ea0f03f198.json b/apps/labrinth/.sqlx/query-b49cd556b85c3e74ebb4f1b7d48930c0456321799f20e63f1c3fd3ea0f03f198.json new file mode 100644 index 000000000..31772e96b --- /dev/null +++ b/apps/labrinth/.sqlx/query-b49cd556b85c3e74ebb4f1b7d48930c0456321799f20e63f1c3fd3ea0f03f198.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT version_id, f.id, f.url, f.filename, f.is_primary, f.size, f.file_type\n FROM files f\n WHERE f.version_id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "url", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "filename", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "is_primary", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "size", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "file_type", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true + ] + }, + "hash": "b49cd556b85c3e74ebb4f1b7d48930c0456321799f20e63f1c3fd3ea0f03f198" +} diff --git a/apps/labrinth/.sqlx/query-b641616b81b1cef2f95db719a492cc1f7aaba66da52efeadb05fc555611b174b.json b/apps/labrinth/.sqlx/query-b641616b81b1cef2f95db719a492cc1f7aaba66da52efeadb05fc555611b174b.json new file mode 100644 index 000000000..1181c1906 --- /dev/null +++ b/apps/labrinth/.sqlx/query-b641616b81b1cef2f95db719a492cc1f7aaba66da52efeadb05fc555611b174b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE collections\n SET description = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "b641616b81b1cef2f95db719a492cc1f7aaba66da52efeadb05fc555611b174b" +} diff --git a/apps/labrinth/.sqlx/query-b677e66031752e66d2219079a559e368c6cea1800da8a5f9d50ba5b1ac3a15fc.json b/apps/labrinth/.sqlx/query-b677e66031752e66d2219079a559e368c6cea1800da8a5f9d50ba5b1ac3a15fc.json new file mode 100644 index 000000000..a66547009 --- /dev/null +++ b/apps/labrinth/.sqlx/query-b677e66031752e66d2219079a559e368c6cea1800da8a5f9d50ba5b1ac3a15fc.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET summary = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "b677e66031752e66d2219079a559e368c6cea1800da8a5f9d50ba5b1ac3a15fc" +} diff --git a/apps/labrinth/.sqlx/query-b82d35429e009e515ae1e0332142b3bd0bec55f38807eded9130b932929f2ebe.json b/apps/labrinth/.sqlx/query-b82d35429e009e515ae1e0332142b3bd0bec55f38807eded9130b932929f2ebe.json new file mode 100644 index 000000000..d78e5d159 --- /dev/null +++ b/apps/labrinth/.sqlx/query-b82d35429e009e515ae1e0332142b3bd0bec55f38807eded9130b932929f2ebe.json @@ -0,0 +1,36 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT m.id id, tm.user_id user_id, tm.payouts_split payouts_split\n FROM mods m\n INNER JOIN organizations o ON m.organization_id = o.id\n INNER JOIN team_members tm on o.team_id = tm.team_id AND tm.accepted = TRUE\n WHERE m.id = ANY($1) AND m.monetization_status = $2 AND m.status = ANY($3) AND m.organization_id IS NOT NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "payouts_split", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "Int8Array", + "Text", + "TextArray" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "b82d35429e009e515ae1e0332142b3bd0bec55f38807eded9130b932929f2ebe" +} diff --git a/apps/labrinth/.sqlx/query-b86145932b1f919fc82414c303ade80f62d4c1bc155f948359b5f6578c680244.json b/apps/labrinth/.sqlx/query-b86145932b1f919fc82414c303ade80f62d4c1bc155f948359b5f6578c680244.json new file mode 100644 index 000000000..13c44f59b --- /dev/null +++ b/apps/labrinth/.sqlx/query-b86145932b1f919fc82414c303ade80f62d4c1bc155f948359b5f6578c680244.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO mods_categories (joining_mod_id, joining_category_id, is_additional)\n SELECT * FROM UNNEST ($1::bigint[], $2::int[], $3::bool[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "Int4Array", + "BoolArray" + ] + }, + "nullable": [] + }, + "hash": "b86145932b1f919fc82414c303ade80f62d4c1bc155f948359b5f6578c680244" +} diff --git a/apps/labrinth/.sqlx/query-b903ac4e686ef85ba28d698c668da07860e7f276b261d8f2cebb74e73b094970.json b/apps/labrinth/.sqlx/query-b903ac4e686ef85ba28d698c668da07860e7f276b261d8f2cebb74e73b094970.json new file mode 100644 index 000000000..8bb972397 --- /dev/null +++ b/apps/labrinth/.sqlx/query-b903ac4e686ef85ba28d698c668da07860e7f276b261d8f2cebb74e73b094970.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "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": [] + }, + "hash": "b903ac4e686ef85ba28d698c668da07860e7f276b261d8f2cebb74e73b094970" +} diff --git a/apps/labrinth/.sqlx/query-b9399840dbbf807a03d69b7fcb3bd479ef20920ab1e3c91706a1c2c7089f48e7.json b/apps/labrinth/.sqlx/query-b9399840dbbf807a03d69b7fcb3bd479ef20920ab1e3c91706a1c2c7089f48e7.json new file mode 100644 index 000000000..a1dcdec52 --- /dev/null +++ b/apps/labrinth/.sqlx/query-b9399840dbbf807a03d69b7fcb3bd479ef20920ab1e3c91706a1c2c7089f48e7.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO teams (id)\n VALUES ($1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "b9399840dbbf807a03d69b7fcb3bd479ef20920ab1e3c91706a1c2c7089f48e7" +} diff --git a/apps/labrinth/.sqlx/query-b971cecafab7046c5952447fd78a6e45856841256d812ce9ae3c07f903c5cc62.json b/apps/labrinth/.sqlx/query-b971cecafab7046c5952447fd78a6e45856841256d812ce9ae3c07f903c5cc62.json new file mode 100644 index 000000000..be3795083 --- /dev/null +++ b/apps/labrinth/.sqlx/query-b971cecafab7046c5952447fd78a6e45856841256d812ce9ae3c07f903c5cc62.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET status = requested_status\n WHERE status = $1 AND approved < CURRENT_DATE AND requested_status IS NOT NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "b971cecafab7046c5952447fd78a6e45856841256d812ce9ae3c07f903c5cc62" +} diff --git a/apps/labrinth/.sqlx/query-b99e906aa6ca18b9f3f111eae7bf0d360f42385ca99228a844387bf9456a6a31.json b/apps/labrinth/.sqlx/query-b99e906aa6ca18b9f3f111eae7bf0d360f42385ca99228a844387bf9456a6a31.json new file mode 100644 index 000000000..60a616241 --- /dev/null +++ b/apps/labrinth/.sqlx/query-b99e906aa6ca18b9f3f111eae7bf0d360f42385ca99228a844387bf9456a6a31.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM reports WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "b99e906aa6ca18b9f3f111eae7bf0d360f42385ca99228a844387bf9456a6a31" +} diff --git a/apps/labrinth/.sqlx/query-ba2e3eab0daba9698686cbf324351f5d0ddc7be1d1b650a86a43712786fd4a4d.json b/apps/labrinth/.sqlx/query-ba2e3eab0daba9698686cbf324351f5d0ddc7be1d1b650a86a43712786fd4a4d.json new file mode 100644 index 000000000..ee4696d21 --- /dev/null +++ b/apps/labrinth/.sqlx/query-ba2e3eab0daba9698686cbf324351f5d0ddc7be1d1b650a86a43712786fd4a4d.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, metadata, unitary\n FROM products\n WHERE 1 = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "metadata", + "type_info": "Jsonb" + }, + { + "ordinal": 2, + "name": "unitary", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "ba2e3eab0daba9698686cbf324351f5d0ddc7be1d1b650a86a43712786fd4a4d" +} diff --git a/apps/labrinth/.sqlx/query-ba2e730788fb7441a7f01f414eb79b6e73046af4123ac1756442eeb1a4f0f869.json b/apps/labrinth/.sqlx/query-ba2e730788fb7441a7f01f414eb79b6e73046af4123ac1756442eeb1a4f0f869.json new file mode 100644 index 000000000..b6c622055 --- /dev/null +++ b/apps/labrinth/.sqlx/query-ba2e730788fb7441a7f01f414eb79b6e73046af4123ac1756442eeb1a4f0f869.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM notifications_actions\n WHERE notification_id = ANY($1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "ba2e730788fb7441a7f01f414eb79b6e73046af4123ac1756442eeb1a4f0f869" +} diff --git a/apps/labrinth/.sqlx/query-bad800084766fcbaf584066304a5a7e4484859e1326c765e5ebd403fbc7e188b.json b/apps/labrinth/.sqlx/query-bad800084766fcbaf584066304a5a7e4484859e1326c765e5ebd403fbc7e188b.json new file mode 100644 index 000000000..d2e35e95b --- /dev/null +++ b/apps/labrinth/.sqlx/query-bad800084766fcbaf584066304a5a7e4484859e1326c765e5ebd403fbc7e188b.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM mods m INNER JOIN organizations o ON m.organization_id = o.id INNER JOIN team_members tm ON tm.team_id = o.team_id AND tm.user_id = $2 WHERE m.id = $1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "bad800084766fcbaf584066304a5a7e4484859e1326c765e5ebd403fbc7e188b" +} diff --git a/apps/labrinth/.sqlx/query-bbc96f470cb7819a5c8aa0725e630311cfa8da5b3b119b9ce791e8e978250c6e.json b/apps/labrinth/.sqlx/query-bbc96f470cb7819a5c8aa0725e630311cfa8da5b3b119b9ce791e8e978250c6e.json new file mode 100644 index 000000000..72792633c --- /dev/null +++ b/apps/labrinth/.sqlx/query-bbc96f470cb7819a5c8aa0725e630311cfa8da5b3b119b9ce791e8e978250c6e.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE payouts\n SET status = $1\n WHERE platform_id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "bbc96f470cb7819a5c8aa0725e630311cfa8da5b3b119b9ce791e8e978250c6e" +} diff --git a/apps/labrinth/.sqlx/query-bc2a2166718a2d2b23e57cde6b144e88f58fd2e1cc3e6da8d90708cbf242f761.json b/apps/labrinth/.sqlx/query-bc2a2166718a2d2b23e57cde6b144e88f58fd2e1cc3e6da8d90708cbf242f761.json new file mode 100644 index 000000000..0c4649814 --- /dev/null +++ b/apps/labrinth/.sqlx/query-bc2a2166718a2d2b23e57cde6b144e88f58fd2e1cc3e6da8d90708cbf242f761.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM charges\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "bc2a2166718a2d2b23e57cde6b144e88f58fd2e1cc3e6da8d90708cbf242f761" +} diff --git a/apps/labrinth/.sqlx/query-bd0d1da185dc7d21ccbbfde86fc093ce9eda7dd7e07f7a53882d427010fd58ca.json b/apps/labrinth/.sqlx/query-bd0d1da185dc7d21ccbbfde86fc093ce9eda7dd7e07f7a53882d427010fd58ca.json new file mode 100644 index 000000000..4d63c2589 --- /dev/null +++ b/apps/labrinth/.sqlx/query-bd0d1da185dc7d21ccbbfde86fc093ce9eda7dd7e07f7a53882d427010fd58ca.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM dependencies WHERE dependent_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "bd0d1da185dc7d21ccbbfde86fc093ce9eda7dd7e07f7a53882d427010fd58ca" +} diff --git a/apps/labrinth/.sqlx/query-bd26a27ce80ca796ae19bc709c92800a0a43dfef4a37a5725403d33ccb20d908.json b/apps/labrinth/.sqlx/query-bd26a27ce80ca796ae19bc709c92800a0a43dfef4a37a5725403d33ccb20d908.json new file mode 100644 index 000000000..c0c2cbe97 --- /dev/null +++ b/apps/labrinth/.sqlx/query-bd26a27ce80ca796ae19bc709c92800a0a43dfef4a37a5725403d33ccb20d908.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET badges = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "bd26a27ce80ca796ae19bc709c92800a0a43dfef4a37a5725403d33ccb20d908" +} diff --git a/apps/labrinth/.sqlx/query-bf7f721664f5e0ed41adc41b5483037256635f28ff6c4e5d3cbcec4387f9c8ef.json b/apps/labrinth/.sqlx/query-bf7f721664f5e0ed41adc41b5483037256635f28ff6c4e5d3cbcec4387f9c8ef.json new file mode 100644 index 000000000..112d7cde3 --- /dev/null +++ b/apps/labrinth/.sqlx/query-bf7f721664f5e0ed41adc41b5483037256635f28ff6c4e5d3cbcec4387f9c8ef.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM users WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "bf7f721664f5e0ed41adc41b5483037256635f28ff6c4e5d3cbcec4387f9c8ef" +} diff --git a/apps/labrinth/.sqlx/query-c03c8c00fe93569d0f464b7a058a903d9b3bbd09fc7f70136c3c09e1c15a9062.json b/apps/labrinth/.sqlx/query-c03c8c00fe93569d0f464b7a058a903d9b3bbd09fc7f70136c3c09e1c15a9062.json new file mode 100644 index 000000000..ccf739566 --- /dev/null +++ b/apps/labrinth/.sqlx/query-c03c8c00fe93569d0f464b7a058a903d9b3bbd09fc7f70136c3c09e1c15a9062.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM users WHERE paypal_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "c03c8c00fe93569d0f464b7a058a903d9b3bbd09fc7f70136c3c09e1c15a9062" +} diff --git a/apps/labrinth/.sqlx/query-c07277bcf62120ac4fac8678e09512f3984031919a71af59fc10995fb21f480c.json b/apps/labrinth/.sqlx/query-c07277bcf62120ac4fac8678e09512f3984031919a71af59fc10995fb21f480c.json new file mode 100644 index 000000000..f7b9866aa --- /dev/null +++ b/apps/labrinth/.sqlx/query-c07277bcf62120ac4fac8678e09512f3984031919a71af59fc10995fb21f480c.json @@ -0,0 +1,64 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT lf.id, lf.field, lf.field_type, lf.optional, lf.min_val, lf.max_val, lf.enum_type, lfl.loader_id\n FROM loader_fields lf\n LEFT JOIN loader_fields_loaders lfl ON lfl.loader_field_id = lf.id\n WHERE lfl.loader_id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "field", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "field_type", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "optional", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "min_val", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "max_val", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "enum_type", + "type_info": "Int4" + }, + { + "ordinal": 7, + "name": "loader_id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4Array" + ] + }, + "nullable": [ + false, + false, + false, + false, + true, + true, + true, + false + ] + }, + "hash": "c07277bcf62120ac4fac8678e09512f3984031919a71af59fc10995fb21f480c" +} diff --git a/apps/labrinth/.sqlx/query-c0bd8a50915398377b6e8a6c046a2d406c3d9e7721647c8a6f4fcf9e7c72bc25.json b/apps/labrinth/.sqlx/query-c0bd8a50915398377b6e8a6c046a2d406c3d9e7721647c8a6f4fcf9e7c72bc25.json new file mode 100644 index 000000000..4a7f901e9 --- /dev/null +++ b/apps/labrinth/.sqlx/query-c0bd8a50915398377b6e8a6c046a2d406c3d9e7721647c8a6f4fcf9e7c72bc25.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM team_members\n WHERE (team_id = $1 AND user_id = $2 AND NOT is_owner = TRUE)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "c0bd8a50915398377b6e8a6c046a2d406c3d9e7721647c8a6f4fcf9e7c72bc25" +} diff --git a/apps/labrinth/.sqlx/query-c100a3be0e1b7bf449576c4052d87494979cb89d194805a5ce9e928eef796ae9.json b/apps/labrinth/.sqlx/query-c100a3be0e1b7bf449576c4052d87494979cb89d194805a5ce9e928eef796ae9.json new file mode 100644 index 000000000..fff83ae5a --- /dev/null +++ b/apps/labrinth/.sqlx/query-c100a3be0e1b7bf449576c4052d87494979cb89d194805a5ce9e928eef796ae9.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET license_url = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "c100a3be0e1b7bf449576c4052d87494979cb89d194805a5ce9e928eef796ae9" +} diff --git a/apps/labrinth/.sqlx/query-c1a3f6dcef6110d6ea884670fb82bac14b98e922bb5673c048ccce7b7300539b.json b/apps/labrinth/.sqlx/query-c1a3f6dcef6110d6ea884670fb82bac14b98e922bb5673c048ccce7b7300539b.json new file mode 100644 index 000000000..1ad99f286 --- /dev/null +++ b/apps/labrinth/.sqlx/query-c1a3f6dcef6110d6ea884670fb82bac14b98e922bb5673c048ccce7b7300539b.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(SELECT 1 FROM reports WHERE id = $1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "c1a3f6dcef6110d6ea884670fb82bac14b98e922bb5673c048ccce7b7300539b" +} diff --git a/apps/labrinth/.sqlx/query-c2564faa5f5a7d8aa485f4becde16ebf54d16f2dc41a70471e3b4fc896f11fd1.json b/apps/labrinth/.sqlx/query-c2564faa5f5a7d8aa485f4becde16ebf54d16f2dc41a70471e3b4fc896f11fd1.json new file mode 100644 index 000000000..5551b1ca1 --- /dev/null +++ b/apps/labrinth/.sqlx/query-c2564faa5f5a7d8aa485f4becde16ebf54d16f2dc41a70471e3b4fc896f11fd1.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE versions\n SET version_type = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "c2564faa5f5a7d8aa485f4becde16ebf54d16f2dc41a70471e3b4fc896f11fd1" +} diff --git a/apps/labrinth/.sqlx/query-c2924fff035e92f7bd2279517310ba391ced72b38be97d462cdfe60048e947db.json b/apps/labrinth/.sqlx/query-c2924fff035e92f7bd2279517310ba391ced72b38be97d462cdfe60048e947db.json new file mode 100644 index 000000000..fba958d5e --- /dev/null +++ b/apps/labrinth/.sqlx/query-c2924fff035e92f7bd2279517310ba391ced72b38be97d462cdfe60048e947db.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE files\n SET metadata = $1\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "c2924fff035e92f7bd2279517310ba391ced72b38be97d462cdfe60048e947db" +} diff --git a/apps/labrinth/.sqlx/query-c3397fe8a9435d8c64283c8ae780a58b9f98e8c97c30e57d9c703619a6180917.json b/apps/labrinth/.sqlx/query-c3397fe8a9435d8c64283c8ae780a58b9f98e8c97c30e57d9c703619a6180917.json new file mode 100644 index 000000000..539049a10 --- /dev/null +++ b/apps/labrinth/.sqlx/query-c3397fe8a9435d8c64283c8ae780a58b9f98e8c97c30e57d9c703619a6180917.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM teams\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "c3397fe8a9435d8c64283c8ae780a58b9f98e8c97c30e57d9c703619a6180917" +} diff --git a/apps/labrinth/.sqlx/query-c3f594d8d0ffcf5df1b36759cf3088bfaec496c5dfdbf496d3b05f0b122a5d0c.json b/apps/labrinth/.sqlx/query-c3f594d8d0ffcf5df1b36759cf3088bfaec496c5dfdbf496d3b05f0b122a5d0c.json new file mode 100644 index 000000000..f666afe23 --- /dev/null +++ b/apps/labrinth/.sqlx/query-c3f594d8d0ffcf5df1b36759cf3088bfaec496c5dfdbf496d3b05f0b122a5d0c.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO reports (\n id, report_type_id, mod_id, version_id, user_id,\n body, reporter\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int4", + "Int8", + "Int8", + "Int8", + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "c3f594d8d0ffcf5df1b36759cf3088bfaec496c5dfdbf496d3b05f0b122a5d0c" +} diff --git a/apps/labrinth/.sqlx/query-c5319631c46ffa46e218fcf308f17ef99fae60e5fbff5f0396f70787156de322.json b/apps/labrinth/.sqlx/query-c5319631c46ffa46e218fcf308f17ef99fae60e5fbff5f0396f70787156de322.json new file mode 100644 index 000000000..722f05896 --- /dev/null +++ b/apps/labrinth/.sqlx/query-c5319631c46ffa46e218fcf308f17ef99fae60e5fbff5f0396f70787156de322.json @@ -0,0 +1,46 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, client_id, user_id, scopes, created\n FROM oauth_client_authorizations\n WHERE user_id=$1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "client_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "scopes", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false + ] + }, + "hash": "c5319631c46ffa46e218fcf308f17ef99fae60e5fbff5f0396f70787156de322" +} diff --git a/apps/labrinth/.sqlx/query-c55d2132e3e6e92dd50457affab758623dca175dc27a2d3cd4aace9cfdecf789.json b/apps/labrinth/.sqlx/query-c55d2132e3e6e92dd50457affab758623dca175dc27a2d3cd4aace9cfdecf789.json new file mode 100644 index 000000000..1d66c5b20 --- /dev/null +++ b/apps/labrinth/.sqlx/query-c55d2132e3e6e92dd50457affab758623dca175dc27a2d3cd4aace9cfdecf789.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO mod_follows (follower_id, mod_id)\n VALUES ($1, $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "c55d2132e3e6e92dd50457affab758623dca175dc27a2d3cd4aace9cfdecf789" +} diff --git a/apps/labrinth/.sqlx/query-c56dd77e35bf5372cd35ca981d248738b55f39d74428ed7d0c5ca2957a656eb6.json b/apps/labrinth/.sqlx/query-c56dd77e35bf5372cd35ca981d248738b55f39d74428ed7d0c5ca2957a656eb6.json new file mode 100644 index 000000000..5936aadad --- /dev/null +++ b/apps/labrinth/.sqlx/query-c56dd77e35bf5372cd35ca981d248738b55f39d74428ed7d0c5ca2957a656eb6.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM users WHERE microsoft_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "c56dd77e35bf5372cd35ca981d248738b55f39d74428ed7d0c5ca2957a656eb6" +} diff --git a/apps/labrinth/.sqlx/query-c7c400d74c478b4194f478f6d732a92c0b9a72e1cb6147009018b2712398c24f.json b/apps/labrinth/.sqlx/query-c7c400d74c478b4194f478f6d732a92c0b9a72e1cb6147009018b2712398c24f.json new file mode 100644 index 000000000..4e4a0ac69 --- /dev/null +++ b/apps/labrinth/.sqlx/query-c7c400d74c478b4194f478f6d732a92c0b9a72e1cb6147009018b2712398c24f.json @@ -0,0 +1,179 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows,\n m.icon_url icon_url, m.raw_icon_url raw_icon_url, m.description description, m.published published,\n m.updated updated, m.approved approved, m.queued, m.status status, m.requested_status requested_status,\n m.license_url license_url,\n m.team_id team_id, m.organization_id organization_id, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body,\n m.webhook_sent, m.color,\n t.id thread_id, m.monetization_status monetization_status,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories,\n ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories\n FROM mods m\n INNER JOIN threads t ON t.mod_id = m.id\n LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id\n LEFT JOIN categories c ON mc.joining_category_id = c.id\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n GROUP BY t.id, m.id;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "summary", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "downloads", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "follows", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "icon_url", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "raw_icon_url", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "description", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "published", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "updated", + "type_info": "Timestamptz" + }, + { + "ordinal": 10, + "name": "approved", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "queued", + "type_info": "Timestamptz" + }, + { + "ordinal": 12, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 13, + "name": "requested_status", + "type_info": "Varchar" + }, + { + "ordinal": 14, + "name": "license_url", + "type_info": "Varchar" + }, + { + "ordinal": 15, + "name": "team_id", + "type_info": "Int8" + }, + { + "ordinal": 16, + "name": "organization_id", + "type_info": "Int8" + }, + { + "ordinal": 17, + "name": "license", + "type_info": "Varchar" + }, + { + "ordinal": 18, + "name": "slug", + "type_info": "Varchar" + }, + { + "ordinal": 19, + "name": "moderation_message", + "type_info": "Varchar" + }, + { + "ordinal": 20, + "name": "moderation_message_body", + "type_info": "Varchar" + }, + { + "ordinal": 21, + "name": "webhook_sent", + "type_info": "Bool" + }, + { + "ordinal": 22, + "name": "color", + "type_info": "Int4" + }, + { + "ordinal": 23, + "name": "thread_id", + "type_info": "Int8" + }, + { + "ordinal": 24, + "name": "monetization_status", + "type_info": "Varchar" + }, + { + "ordinal": 25, + "name": "categories", + "type_info": "VarcharArray" + }, + { + "ordinal": 26, + "name": "additional_categories", + "type_info": "VarcharArray" + } + ], + "parameters": { + "Left": [ + "Int8Array", + "TextArray" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + true, + false, + false, + false, + true, + true, + false, + true, + true, + false, + true, + false, + true, + true, + true, + false, + true, + false, + false, + null, + null + ] + }, + "hash": "c7c400d74c478b4194f478f6d732a92c0b9a72e1cb6147009018b2712398c24f" +} diff --git a/apps/labrinth/.sqlx/query-c8a27a122160a0896914c786deef9e8193eb240501d30d5ffb4129e2103efd3d.json b/apps/labrinth/.sqlx/query-c8a27a122160a0896914c786deef9e8193eb240501d30d5ffb4129e2103efd3d.json new file mode 100644 index 000000000..bebb6425a --- /dev/null +++ b/apps/labrinth/.sqlx/query-c8a27a122160a0896914c786deef9e8193eb240501d30d5ffb4129e2103efd3d.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE versions\n SET status = requested_status\n WHERE status = $1 AND date_published < CURRENT_DATE AND requested_status IS NOT NULL\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [] + }, + "hash": "c8a27a122160a0896914c786deef9e8193eb240501d30d5ffb4129e2103efd3d" +} diff --git a/apps/labrinth/.sqlx/query-c8c0bf5d298810a7a30caf03d7437af757303fa9aa0f500b83476e65cec7f1e9.json b/apps/labrinth/.sqlx/query-c8c0bf5d298810a7a30caf03d7437af757303fa9aa0f500b83476e65cec7f1e9.json new file mode 100644 index 000000000..6d123202a --- /dev/null +++ b/apps/labrinth/.sqlx/query-c8c0bf5d298810a7a30caf03d7437af757303fa9aa0f500b83476e65cec7f1e9.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO threads_members (\n thread_id, user_id\n )\n SELECT * FROM UNNEST ($1::int8[], $2::int8[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "c8c0bf5d298810a7a30caf03d7437af757303fa9aa0f500b83476e65cec7f1e9" +} diff --git a/apps/labrinth/.sqlx/query-c8fde56e5d03eda085519b4407768de7ddf48cae18ce7138a97e8e8fba967e15.json b/apps/labrinth/.sqlx/query-c8fde56e5d03eda085519b4407768de7ddf48cae18ce7138a97e8e8fba967e15.json new file mode 100644 index 000000000..383c5d277 --- /dev/null +++ b/apps/labrinth/.sqlx/query-c8fde56e5d03eda085519b4407768de7ddf48cae18ce7138a97e8e8fba967e15.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id FROM reports\n WHERE id = ANY($1) AND reporter = $2\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array", + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "c8fde56e5d03eda085519b4407768de7ddf48cae18ce7138a97e8e8fba967e15" +} diff --git a/apps/labrinth/.sqlx/query-c91110c893f6d4cba9cb1cb149bca13ae8848d8eb02494fedee8fe4801529738.json b/apps/labrinth/.sqlx/query-c91110c893f6d4cba9cb1cb149bca13ae8848d8eb02494fedee8fe4801529738.json new file mode 100644 index 000000000..f90db3371 --- /dev/null +++ b/apps/labrinth/.sqlx/query-c91110c893f6d4cba9cb1cb149bca13ae8848d8eb02494fedee8fe4801529738.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE collections\n SET icon_url = $1, raw_icon_url = $2, color = $3\n WHERE (id = $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Int4", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "c91110c893f6d4cba9cb1cb149bca13ae8848d8eb02494fedee8fe4801529738" +} diff --git a/apps/labrinth/.sqlx/query-c920cc500f431a2b174d176c3a356d40295137fd87a5308d71aad173d18d9d91.json b/apps/labrinth/.sqlx/query-c920cc500f431a2b174d176c3a356d40295137fd87a5308d71aad173d18d9d91.json new file mode 100644 index 000000000..42c310a1a --- /dev/null +++ b/apps/labrinth/.sqlx/query-c920cc500f431a2b174d176c3a356d40295137fd87a5308d71aad173d18d9d91.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE uploaded_images\n SET version_id = $1\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "c920cc500f431a2b174d176c3a356d40295137fd87a5308d71aad173d18d9d91" +} diff --git a/apps/labrinth/.sqlx/query-caa4f261950f027cd34e2099e5489c02de214299004ea182f5eae93396e1d313.json b/apps/labrinth/.sqlx/query-caa4f261950f027cd34e2099e5489c02de214299004ea182f5eae93396e1d313.json new file mode 100644 index 000000000..0fc2034d5 --- /dev/null +++ b/apps/labrinth/.sqlx/query-caa4f261950f027cd34e2099e5489c02de214299004ea182f5eae93396e1d313.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT tm.id, tm.author_id, tm.thread_id, tm.body, tm.created, tm.hide_identity\n FROM threads_messages tm\n WHERE tm.id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "author_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "thread_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "body", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "hide_identity", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + true, + false, + false, + false, + false + ] + }, + "hash": "caa4f261950f027cd34e2099e5489c02de214299004ea182f5eae93396e1d313" +} diff --git a/apps/labrinth/.sqlx/query-cb57ae673f1a7e50cc319efddb9bdc82e2251596bcf85aea52e8def343e423b8.json b/apps/labrinth/.sqlx/query-cb57ae673f1a7e50cc319efddb9bdc82e2251596bcf85aea52e8def343e423b8.json new file mode 100644 index 000000000..2a441288f --- /dev/null +++ b/apps/labrinth/.sqlx/query-cb57ae673f1a7e50cc319efddb9bdc82e2251596bcf85aea52e8def343e423b8.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO hashes (file_id, algorithm, hash)\n VALUES ($1, $2, $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Bytea" + ] + }, + "nullable": [] + }, + "hash": "cb57ae673f1a7e50cc319efddb9bdc82e2251596bcf85aea52e8def343e423b8" +} diff --git a/apps/labrinth/.sqlx/query-cc1f2f568a0ba1d285a95fd9b6e3b118a0eaa26e2851bcc3f1920ae0140b48ae.json b/apps/labrinth/.sqlx/query-cc1f2f568a0ba1d285a95fd9b6e3b118a0eaa26e2851bcc3f1920ae0140b48ae.json new file mode 100644 index 000000000..953a60027 --- /dev/null +++ b/apps/labrinth/.sqlx/query-cc1f2f568a0ba1d285a95fd9b6e3b118a0eaa26e2851bcc3f1920ae0140b48ae.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n f.metadata, v.id version_id\n FROM versions v\n INNER JOIN files f ON f.version_id = v.id\n WHERE v.mod_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "metadata", + "type_info": "Jsonb" + }, + { + "ordinal": 1, + "name": "version_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + true, + false + ] + }, + "hash": "cc1f2f568a0ba1d285a95fd9b6e3b118a0eaa26e2851bcc3f1920ae0140b48ae" +} diff --git a/apps/labrinth/.sqlx/query-ccd913bb2f3006ffe881ce2fc4ef1e721d18fe2eed6ac62627046c955129610c.json b/apps/labrinth/.sqlx/query-ccd913bb2f3006ffe881ce2fc4ef1e721d18fe2eed6ac62627046c955129610c.json new file mode 100644 index 000000000..a9bb9bcf1 --- /dev/null +++ b/apps/labrinth/.sqlx/query-ccd913bb2f3006ffe881ce2fc4ef1e721d18fe2eed6ac62627046c955129610c.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM files WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "ccd913bb2f3006ffe881ce2fc4ef1e721d18fe2eed6ac62627046c955129610c" +} diff --git a/apps/labrinth/.sqlx/query-ccf57f9c1026927afc940a20ebad9fb58ded7171b21e91973d1f13c91eab9b37.json b/apps/labrinth/.sqlx/query-ccf57f9c1026927afc940a20ebad9fb58ded7171b21e91973d1f13c91eab9b37.json new file mode 100644 index 000000000..8b28b3d9f --- /dev/null +++ b/apps/labrinth/.sqlx/query-ccf57f9c1026927afc940a20ebad9fb58ded7171b21e91973d1f13c91eab9b37.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE files\n SET metadata = $1\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Jsonb", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "ccf57f9c1026927afc940a20ebad9fb58ded7171b21e91973d1f13c91eab9b37" +} diff --git a/apps/labrinth/.sqlx/query-cd564263de068c5e6e4b5f32587c65fa62d431aa0d7130427f27a809457be33e.json b/apps/labrinth/.sqlx/query-cd564263de068c5e6e4b5f32587c65fa62d431aa0d7130427f27a809457be33e.json new file mode 100644 index 000000000..8f20c07cb --- /dev/null +++ b/apps/labrinth/.sqlx/query-cd564263de068c5e6e4b5f32587c65fa62d431aa0d7130427f27a809457be33e.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE team_members\n SET user_id = $1\n WHERE (user_id = $2 AND is_owner = TRUE)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "cd564263de068c5e6e4b5f32587c65fa62d431aa0d7130427f27a809457be33e" +} diff --git a/apps/labrinth/.sqlx/query-cdb2f18f826097f0f17a1f7295d7c45eb1987b63c1a21666c6ca60c52217ba4d.json b/apps/labrinth/.sqlx/query-cdb2f18f826097f0f17a1f7295d7c45eb1987b63c1a21666c6ca60c52217ba4d.json new file mode 100644 index 000000000..e7f4eb17b --- /dev/null +++ b/apps/labrinth/.sqlx/query-cdb2f18f826097f0f17a1f7295d7c45eb1987b63c1a21666c6ca60c52217ba4d.json @@ -0,0 +1,50 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT l.id id, l.loader loader, l.icon icon, l.metadata metadata,\n ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types,\n ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games\n FROM loaders l \n LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id\n LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id\n LEFT OUTER JOIN loaders_project_types_games lptg ON lptg.loader_id = lpt.joining_loader_id AND lptg.project_type_id = lpt.joining_project_type_id\n LEFT OUTER JOIN games g ON lptg.game_id = g.id\n GROUP BY l.id;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "loader", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "icon", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "metadata", + "type_info": "Jsonb" + }, + { + "ordinal": 4, + "name": "project_types", + "type_info": "VarcharArray" + }, + { + "ordinal": 5, + "name": "games", + "type_info": "VarcharArray" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + false, + null, + null + ] + }, + "hash": "cdb2f18f826097f0f17a1f7295d7c45eb1987b63c1a21666c6ca60c52217ba4d" +} diff --git a/apps/labrinth/.sqlx/query-cdd7f8f95c308d9474e214d584c03be0466214da1e157f6bc577b76dbef7df86.json b/apps/labrinth/.sqlx/query-cdd7f8f95c308d9474e214d584c03be0466214da1e157f6bc577b76dbef7df86.json new file mode 100644 index 000000000..9edda84f3 --- /dev/null +++ b/apps/labrinth/.sqlx/query-cdd7f8f95c308d9474e214d584c03be0466214da1e157f6bc577b76dbef7df86.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM hashes\n WHERE file_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "cdd7f8f95c308d9474e214d584c03be0466214da1e157f6bc577b76dbef7df86" +} diff --git a/apps/labrinth/.sqlx/query-cef01012769dcd499a0d16ce65ffc1e94bce362a7246b6a0a38d133afb90d3b6.json b/apps/labrinth/.sqlx/query-cef01012769dcd499a0d16ce65ffc1e94bce362a7246b6a0a38d133afb90d3b6.json new file mode 100644 index 000000000..ca31ea87c --- /dev/null +++ b/apps/labrinth/.sqlx/query-cef01012769dcd499a0d16ce65ffc1e94bce362a7246b6a0a38d133afb90d3b6.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE team_members\n SET role = $1\n WHERE (team_id = $2 AND user_id = $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "cef01012769dcd499a0d16ce65ffc1e94bce362a7246b6a0a38d133afb90d3b6" +} diff --git a/apps/labrinth/.sqlx/query-cf84f5e2a594a90b2e7993758807aaaaf533a4409633cf00c071049bb6816c96.json b/apps/labrinth/.sqlx/query-cf84f5e2a594a90b2e7993758807aaaaf533a4409633cf00c071049bb6816c96.json new file mode 100644 index 000000000..0833383c0 --- /dev/null +++ b/apps/labrinth/.sqlx/query-cf84f5e2a594a90b2e7993758807aaaaf533a4409633cf00c071049bb6816c96.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM oauth_client_redirect_uris\n WHERE id IN\n (SELECT * FROM UNNEST($1::bigint[]))\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "cf84f5e2a594a90b2e7993758807aaaaf533a4409633cf00c071049bb6816c96" +} diff --git a/apps/labrinth/.sqlx/query-cfcc6970c0b469c4afd37bedfd386def7980f6b7006030d4783723861d0e3a38.json b/apps/labrinth/.sqlx/query-cfcc6970c0b469c4afd37bedfd386def7980f6b7006030d4783723861d0e3a38.json new file mode 100644 index 000000000..64c54a6e3 --- /dev/null +++ b/apps/labrinth/.sqlx/query-cfcc6970c0b469c4afd37bedfd386def7980f6b7006030d4783723861d0e3a38.json @@ -0,0 +1,34 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT v.id version_id, v.mod_id project_id, h.hash hash FROM hashes h\n INNER JOIN files f on h.file_id = f.id\n INNER JOIN versions v on f.version_id = v.id\n WHERE h.algorithm = 'sha1' AND h.hash = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "version_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "project_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "hash", + "type_info": "Bytea" + } + ], + "parameters": { + "Left": [ + "ByteaArray" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "cfcc6970c0b469c4afd37bedfd386def7980f6b7006030d4783723861d0e3a38" +} diff --git a/apps/labrinth/.sqlx/query-cfd80c4417c0534d24d65c782753927ba446e6ba542095c211ae5ee9b06b2753.json b/apps/labrinth/.sqlx/query-cfd80c4417c0534d24d65c782753927ba446e6ba542095c211ae5ee9b06b2753.json new file mode 100644 index 000000000..a36e8f936 --- /dev/null +++ b/apps/labrinth/.sqlx/query-cfd80c4417c0534d24d65c782753927ba446e6ba542095c211ae5ee9b06b2753.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET gitlab_id = $2\n WHERE (id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "cfd80c4417c0534d24d65c782753927ba446e6ba542095c211ae5ee9b06b2753" +} diff --git a/apps/labrinth/.sqlx/query-d0b2ddba90ce69a50d0260a191bf501784de06acdddeed1db8f570cb04755f1a.json b/apps/labrinth/.sqlx/query-d0b2ddba90ce69a50d0260a191bf501784de06acdddeed1db8f570cb04755f1a.json new file mode 100644 index 000000000..9a0a54921 --- /dev/null +++ b/apps/labrinth/.sqlx/query-d0b2ddba90ce69a50d0260a191bf501784de06acdddeed1db8f570cb04755f1a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM oauth_clients WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "d0b2ddba90ce69a50d0260a191bf501784de06acdddeed1db8f570cb04755f1a" +} diff --git a/apps/labrinth/.sqlx/query-d137055262526c5e9295a712430c528b9d0f37aacbb53aeb530d3a64fc49365e.json b/apps/labrinth/.sqlx/query-d137055262526c5e9295a712430c528b9d0f37aacbb53aeb530d3a64fc49365e.json new file mode 100644 index 000000000..130882028 --- /dev/null +++ b/apps/labrinth/.sqlx/query-d137055262526c5e9295a712430c528b9d0f37aacbb53aeb530d3a64fc49365e.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO team_members (id, team_id, user_id, role, is_owner, permissions, organization_permissions, accepted, payouts_split, ordering)\n SELECT * FROM UNNEST ($1::int8[], $2::int8[], $3::int8[], $4::varchar[], $5::bool[], $6::int8[], $7::int8[], $8::bool[], $9::numeric[], $10::int8[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "Int8Array", + "Int8Array", + "VarcharArray", + "BoolArray", + "Int8Array", + "Int8Array", + "BoolArray", + "NumericArray", + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "d137055262526c5e9295a712430c528b9d0f37aacbb53aeb530d3a64fc49365e" +} diff --git a/apps/labrinth/.sqlx/query-d1566672369ea22cb1f638f073f8e3fb467b354351ae71c67941323749ec9bcd.json b/apps/labrinth/.sqlx/query-d1566672369ea22cb1f638f073f8e3fb467b354351ae71c67941323749ec9bcd.json new file mode 100644 index 000000000..31d9b14c2 --- /dev/null +++ b/apps/labrinth/.sqlx/query-d1566672369ea22cb1f638f073f8e3fb467b354351ae71c67941323749ec9bcd.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT f.id id FROM hashes h\n INNER JOIN files f ON h.file_id = f.id\n WHERE h.algorithm = $2 AND h.hash = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Bytea", + "Text" + ] + }, + "nullable": [ + false + ] + }, + "hash": "d1566672369ea22cb1f638f073f8e3fb467b354351ae71c67941323749ec9bcd" +} diff --git a/apps/labrinth/.sqlx/query-d203b99bd23d16224348e4fae44296aa0e1ea6d6a3fac26908303069b36a8dd0.json b/apps/labrinth/.sqlx/query-d203b99bd23d16224348e4fae44296aa0e1ea6d6a3fac26908303069b36a8dd0.json new file mode 100644 index 000000000..63399f935 --- /dev/null +++ b/apps/labrinth/.sqlx/query-d203b99bd23d16224348e4fae44296aa0e1ea6d6a3fac26908303069b36a8dd0.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM threads_messages\n WHERE thread_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "d203b99bd23d16224348e4fae44296aa0e1ea6d6a3fac26908303069b36a8dd0" +} diff --git a/apps/labrinth/.sqlx/query-d331ca8f22da418cf654985c822ce4466824beaa00dea64cde90dc651a03024b.json b/apps/labrinth/.sqlx/query-d331ca8f22da418cf654985c822ce4466824beaa00dea64cde90dc651a03024b.json new file mode 100644 index 000000000..9df5df701 --- /dev/null +++ b/apps/labrinth/.sqlx/query-d331ca8f22da418cf654985c822ce4466824beaa00dea64cde90dc651a03024b.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET moderation_message = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "d331ca8f22da418cf654985c822ce4466824beaa00dea64cde90dc651a03024b" +} diff --git a/apps/labrinth/.sqlx/query-d3991923355b2e0ed7bbe6c85d9158754d7e7d28f5ac75ee5b4e782dbc5c38a9.json b/apps/labrinth/.sqlx/query-d3991923355b2e0ed7bbe6c85d9158754d7e7d28f5ac75ee5b4e782dbc5c38a9.json new file mode 100644 index 000000000..9efabc833 --- /dev/null +++ b/apps/labrinth/.sqlx/query-d3991923355b2e0ed7bbe6c85d9158754d7e7d28f5ac75ee5b4e782dbc5c38a9.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE team_members\n SET accepted = TRUE\n WHERE (team_id = $1 AND user_id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "d3991923355b2e0ed7bbe6c85d9158754d7e7d28f5ac75ee5b4e782dbc5c38a9" +} diff --git a/apps/labrinth/.sqlx/query-d3c5adda017df70a88983baa82e3feb0a3eb432ed2b9d3be0e7a0bc6b2421cdd.json b/apps/labrinth/.sqlx/query-d3c5adda017df70a88983baa82e3feb0a3eb432ed2b9d3be0e7a0bc6b2421cdd.json new file mode 100644 index 000000000..dd6dbd78a --- /dev/null +++ b/apps/labrinth/.sqlx/query-d3c5adda017df70a88983baa82e3feb0a3eb432ed2b9d3be0e7a0bc6b2421cdd.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT t.id FROM organizations o\n INNER JOIN mods m ON m.organization_id = o.id\n INNER JOIN teams t ON t.id = m.team_id\n WHERE o.id = $1 AND $1 IS NOT NULL\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "d3c5adda017df70a88983baa82e3feb0a3eb432ed2b9d3be0e7a0bc6b2421cdd" +} diff --git a/apps/labrinth/.sqlx/query-d3d1467a5dcfc3eb34d7e821b0de54a419d9a5391c13254478944f2f2cc78fe6.json b/apps/labrinth/.sqlx/query-d3d1467a5dcfc3eb34d7e821b0de54a419d9a5391c13254478944f2f2cc78fe6.json new file mode 100644 index 000000000..5dcb4bb7a --- /dev/null +++ b/apps/labrinth/.sqlx/query-d3d1467a5dcfc3eb34d7e821b0de54a419d9a5391c13254478944f2f2cc78fe6.json @@ -0,0 +1,19 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO pats (\n id, name, access_token, scopes, user_id,\n expires\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Varchar", + "Int8", + "Int8", + "Timestamptz" + ] + }, + "nullable": [] + }, + "hash": "d3d1467a5dcfc3eb34d7e821b0de54a419d9a5391c13254478944f2f2cc78fe6" +} diff --git a/apps/labrinth/.sqlx/query-d3f317f7d767f5188bace4064d548d3049df0d06420e3a23ebd8f326703a448e.json b/apps/labrinth/.sqlx/query-d3f317f7d767f5188bace4064d548d3049df0d06420e3a23ebd8f326703a448e.json new file mode 100644 index 000000000..a2330d8f9 --- /dev/null +++ b/apps/labrinth/.sqlx/query-d3f317f7d767f5188bace4064d548d3049df0d06420e3a23ebd8f326703a448e.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET discord_id = $2\n WHERE (id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "d3f317f7d767f5188bace4064d548d3049df0d06420e3a23ebd8f326703a448e" +} diff --git a/apps/labrinth/.sqlx/query-d6453e50041b5521fa9e919a9162e533bb9426f8c584d98474c6ad414db715c8.json b/apps/labrinth/.sqlx/query-d6453e50041b5521fa9e919a9162e533bb9426f8c584d98474c6ad414db715c8.json new file mode 100644 index 000000000..37dd55163 --- /dev/null +++ b/apps/labrinth/.sqlx/query-d6453e50041b5521fa9e919a9162e533bb9426f8c584d98474c6ad414db715c8.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "d6453e50041b5521fa9e919a9162e533bb9426f8c584d98474c6ad414db715c8" +} diff --git a/apps/labrinth/.sqlx/query-d698ca87442da9d26bd1f4636af9a58509c2687f7621765663bdf18988c9c79e.json b/apps/labrinth/.sqlx/query-d698ca87442da9d26bd1f4636af9a58509c2687f7621765663bdf18988c9c79e.json new file mode 100644 index 000000000..1682948aa --- /dev/null +++ b/apps/labrinth/.sqlx/query-d698ca87442da9d26bd1f4636af9a58509c2687f7621765663bdf18988c9c79e.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM organizations WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "d698ca87442da9d26bd1f4636af9a58509c2687f7621765663bdf18988c9c79e" +} diff --git a/apps/labrinth/.sqlx/query-d69ee7051e3bf4b66eab2010134e0d771f929c7a6ed96245ba5b37dc9d49844c.json b/apps/labrinth/.sqlx/query-d69ee7051e3bf4b66eab2010134e0d771f929c7a6ed96245ba5b37dc9d49844c.json new file mode 100644 index 000000000..859e603fe --- /dev/null +++ b/apps/labrinth/.sqlx/query-d69ee7051e3bf4b66eab2010134e0d771f929c7a6ed96245ba5b37dc9d49844c.json @@ -0,0 +1,56 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT id, field, field_type, enum_type, min_val, max_val, optional\n FROM loader_fields lf\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "field", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "field_type", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "enum_type", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "min_val", + "type_info": "Int4" + }, + { + "ordinal": 5, + "name": "max_val", + "type_info": "Int4" + }, + { + "ordinal": 6, + "name": "optional", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false + ] + }, + "hash": "d69ee7051e3bf4b66eab2010134e0d771f929c7a6ed96245ba5b37dc9d49844c" +} diff --git a/apps/labrinth/.sqlx/query-d75b73151ba84715c06bbada22b66c819de8eac87c088b0a501212ad3fe4d618.json b/apps/labrinth/.sqlx/query-d75b73151ba84715c06bbada22b66c819de8eac87c088b0a501212ad3fe4d618.json new file mode 100644 index 000000000..d86c71e19 --- /dev/null +++ b/apps/labrinth/.sqlx/query-d75b73151ba84715c06bbada22b66c819de8eac87c088b0a501212ad3fe4d618.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE reports\n SET closed = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bool", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "d75b73151ba84715c06bbada22b66c819de8eac87c088b0a501212ad3fe4d618" +} diff --git a/apps/labrinth/.sqlx/query-d7c65c30898110d801a5bdf092564e5726e35c1033c69dba69008989a087357c.json b/apps/labrinth/.sqlx/query-d7c65c30898110d801a5bdf092564e5726e35c1033c69dba69008989a087357c.json new file mode 100644 index 000000000..7bea8fc22 --- /dev/null +++ b/apps/labrinth/.sqlx/query-d7c65c30898110d801a5bdf092564e5726e35c1033c69dba69008989a087357c.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE team_members\n SET payouts_split = $1\n WHERE (team_id = $2 AND user_id = $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Numeric", + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "d7c65c30898110d801a5bdf092564e5726e35c1033c69dba69008989a087357c" +} diff --git a/apps/labrinth/.sqlx/query-d8b4e7e382c77a05395124d5a6a27cccb687d0e2c31b76d49b03aa364d099d42.json b/apps/labrinth/.sqlx/query-d8b4e7e382c77a05395124d5a6a27cccb687d0e2c31b76d49b03aa364d099d42.json new file mode 100644 index 000000000..703fe4a1a --- /dev/null +++ b/apps/labrinth/.sqlx/query-d8b4e7e382c77a05395124d5a6a27cccb687d0e2c31b76d49b03aa364d099d42.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM files\n WHERE files.version_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "d8b4e7e382c77a05395124d5a6a27cccb687d0e2c31b76d49b03aa364d099d42" +} diff --git a/apps/labrinth/.sqlx/query-d93a8727fa8c7af79529670bdeab27100a2cdeeb605c85d0f30fd4962e731157.json b/apps/labrinth/.sqlx/query-d93a8727fa8c7af79529670bdeab27100a2cdeeb605c85d0f30fd4962e731157.json new file mode 100644 index 000000000..448fe728a --- /dev/null +++ b/apps/labrinth/.sqlx/query-d93a8727fa8c7af79529670bdeab27100a2cdeeb605c85d0f30fd4962e731157.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM team_members\n WHERE team_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "d93a8727fa8c7af79529670bdeab27100a2cdeeb605c85d0f30fd4962e731157" +} diff --git a/apps/labrinth/.sqlx/query-d93ce03a186c03668d5eebab2bb4cbc4fc9dd002529e37575d94509b67908c8d.json b/apps/labrinth/.sqlx/query-d93ce03a186c03668d5eebab2bb4cbc4fc9dd002529e37575d94509b67908c8d.json new file mode 100644 index 000000000..187aabef0 --- /dev/null +++ b/apps/labrinth/.sqlx/query-d93ce03a186c03668d5eebab2bb4cbc4fc9dd002529e37575d94509b67908c8d.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, session, user_id\n FROM sessions\n WHERE refresh_expires <= NOW()\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "session", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "d93ce03a186c03668d5eebab2bb4cbc4fc9dd002529e37575d94509b67908c8d" +} diff --git a/apps/labrinth/.sqlx/query-d9c4d536ce0bea290f445c3bccb56b4743f2f3a9ce4b170fb439e0e135ca9d51.json b/apps/labrinth/.sqlx/query-d9c4d536ce0bea290f445c3bccb56b4743f2f3a9ce4b170fb439e0e135ca9d51.json new file mode 100644 index 000000000..7141f46a1 --- /dev/null +++ b/apps/labrinth/.sqlx/query-d9c4d536ce0bea290f445c3bccb56b4743f2f3a9ce4b170fb439e0e135ca9d51.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT id, enum_id, value, ordering, created, metadata\n FROM loader_field_enum_values lfev\n WHERE id = ANY($1)\n ORDER BY enum_id, ordering, created ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "enum_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "value", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "ordering", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "metadata", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int4Array" + ] + }, + "nullable": [ + false, + false, + false, + true, + false, + true + ] + }, + "hash": "d9c4d536ce0bea290f445c3bccb56b4743f2f3a9ce4b170fb439e0e135ca9d51" +} diff --git a/apps/labrinth/.sqlx/query-db1deb79fa509974f1cd68cacd541c55bf62928a96d9582d3e223d6473335428.json b/apps/labrinth/.sqlx/query-db1deb79fa509974f1cd68cacd541c55bf62928a96d9582d3e223d6473335428.json new file mode 100644 index 000000000..d80d7c906 --- /dev/null +++ b/apps/labrinth/.sqlx/query-db1deb79fa509974f1cd68cacd541c55bf62928a96d9582d3e223d6473335428.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM oauth_client_authorizations\n WHERE client_id=$1 AND user_id=$2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "db1deb79fa509974f1cd68cacd541c55bf62928a96d9582d3e223d6473335428" +} diff --git a/apps/labrinth/.sqlx/query-dbbfd789feb09459ef25b90eba9458e0d4bceb6389eae13a166556f828a6c3a6.json b/apps/labrinth/.sqlx/query-dbbfd789feb09459ef25b90eba9458e0d4bceb6389eae13a166556f828a6c3a6.json new file mode 100644 index 000000000..5865496f5 --- /dev/null +++ b/apps/labrinth/.sqlx/query-dbbfd789feb09459ef25b90eba9458e0d4bceb6389eae13a166556f828a6c3a6.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE organizations\n SET name = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "dbbfd789feb09459ef25b90eba9458e0d4bceb6389eae13a166556f828a6c3a6" +} diff --git a/apps/labrinth/.sqlx/query-dbdcaf9f2126e15892c28f782d4b5812d947582e3573da56e632f2b0b29fac7b.json b/apps/labrinth/.sqlx/query-dbdcaf9f2126e15892c28f782d4b5812d947582e3573da56e632f2b0b29fac7b.json new file mode 100644 index 000000000..eab35e7ca --- /dev/null +++ b/apps/labrinth/.sqlx/query-dbdcaf9f2126e15892c28f782d4b5812d947582e3573da56e632f2b0b29fac7b.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT v.id, v.mod_id\n FROM versions v\n WHERE mod_id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "mod_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "dbdcaf9f2126e15892c28f782d4b5812d947582e3573da56e632f2b0b29fac7b" +} diff --git a/apps/labrinth/.sqlx/query-dc05295852b5a1d49be7906cd248566ffdfe790d7b61bd69969b00d558b41804.json b/apps/labrinth/.sqlx/query-dc05295852b5a1d49be7906cd248566ffdfe790d7b61bd69969b00d558b41804.json new file mode 100644 index 000000000..a6e27474b --- /dev/null +++ b/apps/labrinth/.sqlx/query-dc05295852b5a1d49be7906cd248566ffdfe790d7b61bd69969b00d558b41804.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT n.id, n.user_id, n.name, n.text, n.link, n.created, n.read, n.type notification_type, n.body,\n JSONB_AGG(DISTINCT jsonb_build_object('id', na.id, 'notification_id', na.notification_id, 'name', na.name, 'action_route_method', na.action_route_method, 'action_route', na.action_route)) filter (where na.id is not null) actions\n FROM notifications n\n LEFT OUTER JOIN notifications_actions na on n.id = na.notification_id\n WHERE n.user_id = $1\n GROUP BY n.id, n.user_id;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "text", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "link", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "read", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "notification_type", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "body", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "actions", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + true, + true, + true, + false, + false, + true, + true, + null + ] + }, + "hash": "dc05295852b5a1d49be7906cd248566ffdfe790d7b61bd69969b00d558b41804" +} diff --git a/apps/labrinth/.sqlx/query-dc64653d72645b76e42a1834124ce3f9225c5b6b8b941812167b3b7002bfdb2a.json b/apps/labrinth/.sqlx/query-dc64653d72645b76e42a1834124ce3f9225c5b6b8b941812167b3b7002bfdb2a.json new file mode 100644 index 000000000..e1051cee0 --- /dev/null +++ b/apps/labrinth/.sqlx/query-dc64653d72645b76e42a1834124ce3f9225c5b6b8b941812167b3b7002bfdb2a.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE team_members\n SET \n is_owner = TRUE,\n accepted = TRUE,\n permissions = $2,\n organization_permissions = NULL,\n role = 'Inherited Owner'\n WHERE (id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "dc64653d72645b76e42a1834124ce3f9225c5b6b8b941812167b3b7002bfdb2a" +} diff --git a/apps/labrinth/.sqlx/query-dcc32d760692674180471e7b19a9a1f73e77bb170e92cc7d60da37596ef840b0.json b/apps/labrinth/.sqlx/query-dcc32d760692674180471e7b19a9a1f73e77bb170e92cc7d60da37596ef840b0.json new file mode 100644 index 000000000..4914a83fa --- /dev/null +++ b/apps/labrinth/.sqlx/query-dcc32d760692674180471e7b19a9a1f73e77bb170e92cc7d60da37596ef840b0.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM threads\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "dcc32d760692674180471e7b19a9a1f73e77bb170e92cc7d60da37596ef840b0" +} diff --git a/apps/labrinth/.sqlx/query-dccd2b918e3bc37aa10ff0dd475d804110d267f959a7b4f854b302e9ceba2e70.json b/apps/labrinth/.sqlx/query-dccd2b918e3bc37aa10ff0dd475d804110d267f959a7b4f854b302e9ceba2e70.json new file mode 100644 index 000000000..f3fc9026c --- /dev/null +++ b/apps/labrinth/.sqlx/query-dccd2b918e3bc37aa10ff0dd475d804110d267f959a7b4f854b302e9ceba2e70.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE dependencies\n SET dependency_id = NULL, mod_dependency_id = $2\n WHERE dependency_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "dccd2b918e3bc37aa10ff0dd475d804110d267f959a7b4f854b302e9ceba2e70" +} diff --git a/apps/labrinth/.sqlx/query-dd2d94d97e4cc991041d871acd695a80ac298d9a6b2ea29e0b5be8d1bb10609c.json b/apps/labrinth/.sqlx/query-dd2d94d97e4cc991041d871acd695a80ac298d9a6b2ea29e0b5be8d1bb10609c.json new file mode 100644 index 000000000..8116472f6 --- /dev/null +++ b/apps/labrinth/.sqlx/query-dd2d94d97e4cc991041d871acd695a80ac298d9a6b2ea29e0b5be8d1bb10609c.json @@ -0,0 +1,24 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO uploaded_images (\n id, url, raw_url, size, created, owner_id, context, mod_id, version_id, thread_message_id, report_id\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11\n );\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Text", + "Int4", + "Timestamptz", + "Int8", + "Varchar", + "Int8", + "Int8", + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "dd2d94d97e4cc991041d871acd695a80ac298d9a6b2ea29e0b5be8d1bb10609c" +} diff --git a/apps/labrinth/.sqlx/query-de1bf7e33a99a10154cefdbe3b8322e4c6a19448b6ee3c6087b1b8163bc52cb1.json b/apps/labrinth/.sqlx/query-de1bf7e33a99a10154cefdbe3b8322e4c6a19448b6ee3c6087b1b8163bc52cb1.json new file mode 100644 index 000000000..7a48589fc --- /dev/null +++ b/apps/labrinth/.sqlx/query-de1bf7e33a99a10154cefdbe3b8322e4c6a19448b6ee3c6087b1b8163bc52cb1.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM user_backup_codes\n WHERE user_id = $1 AND code = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "de1bf7e33a99a10154cefdbe3b8322e4c6a19448b6ee3c6087b1b8163bc52cb1" +} diff --git a/apps/labrinth/.sqlx/query-debb47a2718f79684c8776da7f289b8d178c302bb5a69562b963b8d008973b8d.json b/apps/labrinth/.sqlx/query-debb47a2718f79684c8776da7f289b8d178c302bb5a69562b963b8d008973b8d.json new file mode 100644 index 000000000..d29fb0215 --- /dev/null +++ b/apps/labrinth/.sqlx/query-debb47a2718f79684c8776da7f289b8d178c302bb5a69562b963b8d008973b8d.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE threads_messages\n SET body = '{\"type\": \"deleted\"}', author_id = $2\n WHERE author_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "debb47a2718f79684c8776da7f289b8d178c302bb5a69562b963b8d008973b8d" +} diff --git a/apps/labrinth/.sqlx/query-defc616ab6e602d87695371761563a023a96a860270a2f2afcdd48087e441dad.json b/apps/labrinth/.sqlx/query-defc616ab6e602d87695371761563a023a96a860270a2f2afcdd48087e441dad.json new file mode 100644 index 000000000..151a7fa38 --- /dev/null +++ b/apps/labrinth/.sqlx/query-defc616ab6e602d87695371761563a023a96a860270a2f2afcdd48087e441dad.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, version_number, version_type\n FROM versions\n WHERE mod_id = $1 AND status = ANY($2)\n ORDER BY ordering ASC NULLS LAST, date_published ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "version_number", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "version_type", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8", + "TextArray" + ] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "defc616ab6e602d87695371761563a023a96a860270a2f2afcdd48087e441dad" +} diff --git a/apps/labrinth/.sqlx/query-dfb4bd3db0d1cc2b2f811c267547a224ee4710e202cf1c8f3f35e49b54d6f2f9.json b/apps/labrinth/.sqlx/query-dfb4bd3db0d1cc2b2f811c267547a224ee4710e202cf1c8f3f35e49b54d6f2f9.json new file mode 100644 index 000000000..2515dfe43 --- /dev/null +++ b/apps/labrinth/.sqlx/query-dfb4bd3db0d1cc2b2f811c267547a224ee4710e202cf1c8f3f35e49b54d6f2f9.json @@ -0,0 +1,37 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT mod_id, SUM(amount) amount_sum, DATE_BIN($4::interval, created, TIMESTAMP '2001-01-01') AS interval_start\n FROM payouts_values\n WHERE user_id = $1 AND created BETWEEN $2 AND $3\n GROUP by mod_id, interval_start ORDER BY interval_start\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "amount_sum", + "type_info": "Numeric" + }, + { + "ordinal": 2, + "name": "interval_start", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Timestamptz", + "Timestamptz", + "Interval" + ] + }, + "nullable": [ + true, + null, + null + ] + }, + "hash": "dfb4bd3db0d1cc2b2f811c267547a224ee4710e202cf1c8f3f35e49b54d6f2f9" +} diff --git a/apps/labrinth/.sqlx/query-e15ff50bd75a49d50975d337f61f3412349dd6bc5c836d2634bbcb376a6f7c12.json b/apps/labrinth/.sqlx/query-e15ff50bd75a49d50975d337f61f3412349dd6bc5c836d2634bbcb376a6f7c12.json new file mode 100644 index 000000000..5ed8687e5 --- /dev/null +++ b/apps/labrinth/.sqlx/query-e15ff50bd75a49d50975d337f61f3412349dd6bc5c836d2634bbcb376a6f7c12.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM oauth_access_tokens WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "e15ff50bd75a49d50975d337f61f3412349dd6bc5c836d2634bbcb376a6f7c12" +} diff --git a/apps/labrinth/.sqlx/query-e1f69ecbeece313444909552d1e857a9e7795981b6a7b0d2ce92d6c59d61c9c9.json b/apps/labrinth/.sqlx/query-e1f69ecbeece313444909552d1e857a9e7795981b6a7b0d2ce92d6c59d61c9c9.json new file mode 100644 index 000000000..cea8c364f --- /dev/null +++ b/apps/labrinth/.sqlx/query-e1f69ecbeece313444909552d1e857a9e7795981b6a7b0d2ce92d6c59d61c9c9.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET avatar_url = $1, raw_avatar_url = $2\n WHERE (id = $3)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "e1f69ecbeece313444909552d1e857a9e7795981b6a7b0d2ce92d6c59d61c9c9" +} diff --git a/apps/labrinth/.sqlx/query-e3235e872f98eb85d3eb4a2518fb9dc88049ce62362bfd02623e9b49ac2e9fed.json b/apps/labrinth/.sqlx/query-e3235e872f98eb85d3eb4a2518fb9dc88049ce62362bfd02623e9b49ac2e9fed.json new file mode 100644 index 000000000..a8df89577 --- /dev/null +++ b/apps/labrinth/.sqlx/query-e3235e872f98eb85d3eb4a2518fb9dc88049ce62362bfd02623e9b49ac2e9fed.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT name FROM report_types\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "name", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false + ] + }, + "hash": "e3235e872f98eb85d3eb4a2518fb9dc88049ce62362bfd02623e9b49ac2e9fed" +} diff --git a/apps/labrinth/.sqlx/query-e3cc1fd070b97c4cc36bdb2f33080d4e0d7f3c3d81312d9d28a8c3c8213ad54b.json b/apps/labrinth/.sqlx/query-e3cc1fd070b97c4cc36bdb2f33080d4e0d7f3c3d81312d9d28a8c3c8213ad54b.json new file mode 100644 index 000000000..241178a3b --- /dev/null +++ b/apps/labrinth/.sqlx/query-e3cc1fd070b97c4cc36bdb2f33080d4e0d7f3c3d81312d9d28a8c3c8213ad54b.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM files\n WHERE files.id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "e3cc1fd070b97c4cc36bdb2f33080d4e0d7f3c3d81312d9d28a8c3c8213ad54b" +} diff --git a/apps/labrinth/.sqlx/query-e451e8ed4fb360f0f1bfe540a8e72a923dc0c4c1737387d97bb71076a29ea0f9.json b/apps/labrinth/.sqlx/query-e451e8ed4fb360f0f1bfe540a8e72a923dc0c4c1737387d97bb71076a29ea0f9.json new file mode 100644 index 000000000..92fb36e13 --- /dev/null +++ b/apps/labrinth/.sqlx/query-e451e8ed4fb360f0f1bfe540a8e72a923dc0c4c1737387d97bb71076a29ea0f9.json @@ -0,0 +1,20 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO mods_gallery (\n mod_id, image_url, raw_image_url, featured, name, description, ordering\n )\n SELECT * FROM UNNEST ($1::bigint[], $2::varchar[], $3::varchar[], $4::bool[], $5::varchar[], $6::varchar[], $7::bigint[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "VarcharArray", + "VarcharArray", + "BoolArray", + "VarcharArray", + "VarcharArray", + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "e451e8ed4fb360f0f1bfe540a8e72a923dc0c4c1737387d97bb71076a29ea0f9" +} diff --git a/apps/labrinth/.sqlx/query-e48c85a2b2e11691afae3799aa126bdd8b7338a973308bbab2760c18bb9cb0b7.json b/apps/labrinth/.sqlx/query-e48c85a2b2e11691afae3799aa126bdd8b7338a973308bbab2760c18bb9cb0b7.json new file mode 100644 index 000000000..8025daa8b --- /dev/null +++ b/apps/labrinth/.sqlx/query-e48c85a2b2e11691afae3799aa126bdd8b7338a973308bbab2760c18bb9cb0b7.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE versions\n SET featured = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Bool", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "e48c85a2b2e11691afae3799aa126bdd8b7338a973308bbab2760c18bb9cb0b7" +} diff --git a/apps/labrinth/.sqlx/query-e4dbbb18adfd748ab7659462f940a5d1741a16971b01662b9281eb5720e109b1.json b/apps/labrinth/.sqlx/query-e4dbbb18adfd748ab7659462f940a5d1741a16971b01662b9281eb5720e109b1.json new file mode 100644 index 000000000..516792104 --- /dev/null +++ b/apps/labrinth/.sqlx/query-e4dbbb18adfd748ab7659462f940a5d1741a16971b01662b9281eb5720e109b1.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT m.id FROM mods m\n INNER JOIN organizations o ON o.id = m.organization_id\n INNER JOIN team_members tm ON tm.team_id = o.team_id AND user_id = $2\n WHERE m.id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array", + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e4dbbb18adfd748ab7659462f940a5d1741a16971b01662b9281eb5720e109b1" +} diff --git a/apps/labrinth/.sqlx/query-e50e308826d1e7fa54cade7daf8120b4ae4068bd086dc08f572b33cfc2476354.json b/apps/labrinth/.sqlx/query-e50e308826d1e7fa54cade7daf8120b4ae4068bd086dc08f572b33cfc2476354.json new file mode 100644 index 000000000..dadf62968 --- /dev/null +++ b/apps/labrinth/.sqlx/query-e50e308826d1e7fa54cade7daf8120b4ae4068bd086dc08f572b33cfc2476354.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT m.id mod_id, u.username\n FROM mods m\n INNER JOIN organizations o ON o.id = m.organization_id\n INNER JOIN team_members tm ON tm.is_owner = TRUE and tm.team_id = o.team_id\n INNER JOIN users u ON u.id = tm.user_id\n WHERE m.id = ANY($1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false + ] + }, + "hash": "e50e308826d1e7fa54cade7daf8120b4ae4068bd086dc08f572b33cfc2476354" +} diff --git a/apps/labrinth/.sqlx/query-e60ea75112db37d3e73812e21b1907716e4762e06aa883af878e3be82e3f87d3.json b/apps/labrinth/.sqlx/query-e60ea75112db37d3e73812e21b1907716e4762e06aa883af878e3be82e3f87d3.json new file mode 100644 index 000000000..bb714e7ee --- /dev/null +++ b/apps/labrinth/.sqlx/query-e60ea75112db37d3e73812e21b1907716e4762e06aa883af878e3be82e3f87d3.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT c.id FROM collections c\n WHERE c.user_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "e60ea75112db37d3e73812e21b1907716e4762e06aa883af878e3be82e3f87d3" +} diff --git a/apps/labrinth/.sqlx/query-e60f725571e7b7b716d19735ab3b8f3133bea215a89964d78cb652f930465faf.json b/apps/labrinth/.sqlx/query-e60f725571e7b7b716d19735ab3b8f3133bea215a89964d78cb652f930465faf.json new file mode 100644 index 000000000..29200a656 --- /dev/null +++ b/apps/labrinth/.sqlx/query-e60f725571e7b7b716d19735ab3b8f3133bea215a89964d78cb652f930465faf.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM oauth_clients\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "e60f725571e7b7b716d19735ab3b8f3133bea215a89964d78cb652f930465faf" +} diff --git a/apps/labrinth/.sqlx/query-e68e27fcb3e85233be06e7435aaeb6b27d8dbe2ddaf211ba37a026eab3bb6926.json b/apps/labrinth/.sqlx/query-e68e27fcb3e85233be06e7435aaeb6b27d8dbe2ddaf211ba37a026eab3bb6926.json new file mode 100644 index 000000000..5f6fbb751 --- /dev/null +++ b/apps/labrinth/.sqlx/query-e68e27fcb3e85233be06e7435aaeb6b27d8dbe2ddaf211ba37a026eab3bb6926.json @@ -0,0 +1,82 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, user_id, price_id, amount, currency_code, status, due, last_attempt, charge_type, subscription_id, subscription_interval\n FROM charges\n WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled')", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "price_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "amount", + "type_info": "Int8" + }, + { + "ordinal": 4, + "name": "currency_code", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "due", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "last_attempt", + "type_info": "Timestamptz" + }, + { + "ordinal": 8, + "name": "charge_type", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "subscription_id", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "subscription_interval", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + true, + false, + true, + true + ] + }, + "hash": "e68e27fcb3e85233be06e7435aaeb6b27d8dbe2ddaf211ba37a026eab3bb6926" +} diff --git a/apps/labrinth/.sqlx/query-e74fad4e44759b82df6cde8a4e6df7dc0eb31968a7acfb5069d9e5202c1ad803.json b/apps/labrinth/.sqlx/query-e74fad4e44759b82df6cde8a4e6df7dc0eb31968a7acfb5069d9e5202c1ad803.json new file mode 100644 index 000000000..ee7965663 --- /dev/null +++ b/apps/labrinth/.sqlx/query-e74fad4e44759b82df6cde8a4e6df7dc0eb31968a7acfb5069d9e5202c1ad803.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE organizations\n SET description = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "e74fad4e44759b82df6cde8a4e6df7dc0eb31968a7acfb5069d9e5202c1ad803" +} diff --git a/apps/labrinth/.sqlx/query-e7d0a64a08df6783c942f2fcadd94dd45f8d96ad3d3736e52ce90f68d396cdab.json b/apps/labrinth/.sqlx/query-e7d0a64a08df6783c942f2fcadd94dd45f8d96ad3d3736e52ce90f68d396cdab.json new file mode 100644 index 000000000..ccef1d899 --- /dev/null +++ b/apps/labrinth/.sqlx/query-e7d0a64a08df6783c942f2fcadd94dd45f8d96ad3d3736e52ce90f68d396cdab.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM team_members WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "e7d0a64a08df6783c942f2fcadd94dd45f8d96ad3d3736e52ce90f68d396cdab" +} diff --git a/apps/labrinth/.sqlx/query-e8d4589132b094df1e7a3ca0440344fc8013c0d20b3c71a1142ccbee91fb3c70.json b/apps/labrinth/.sqlx/query-e8d4589132b094df1e7a3ca0440344fc8013c0d20b3c71a1142ccbee91fb3c70.json new file mode 100644 index 000000000..b1b251d9e --- /dev/null +++ b/apps/labrinth/.sqlx/query-e8d4589132b094df1e7a3ca0440344fc8013c0d20b3c71a1142ccbee91fb3c70.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM teams WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "e8d4589132b094df1e7a3ca0440344fc8013c0d20b3c71a1142ccbee91fb3c70" +} diff --git a/apps/labrinth/.sqlx/query-e925b15ec46f0263c7775ba1ba00ed11cfd6749fa792d4eabed73b619f230585.json b/apps/labrinth/.sqlx/query-e925b15ec46f0263c7775ba1ba00ed11cfd6749fa792d4eabed73b619f230585.json new file mode 100644 index 000000000..52bc085af --- /dev/null +++ b/apps/labrinth/.sqlx/query-e925b15ec46f0263c7775ba1ba00ed11cfd6749fa792d4eabed73b619f230585.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET status = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "e925b15ec46f0263c7775ba1ba00ed11cfd6749fa792d4eabed73b619f230585" +} diff --git a/apps/labrinth/.sqlx/query-ea1525cbe7460d0d9e9da8f448c661f7209bc1a7a04e2ea0026fa69c3f550a14.json b/apps/labrinth/.sqlx/query-ea1525cbe7460d0d9e9da8f448c661f7209bc1a7a04e2ea0026fa69c3f550a14.json new file mode 100644 index 000000000..dcdaa6f99 --- /dev/null +++ b/apps/labrinth/.sqlx/query-ea1525cbe7460d0d9e9da8f448c661f7209bc1a7a04e2ea0026fa69c3f550a14.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT tm.user_id id\n FROM team_members tm\n WHERE tm.team_id = $1 AND tm.accepted\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ea1525cbe7460d0d9e9da8f448c661f7209bc1a7a04e2ea0026fa69c3f550a14" +} diff --git a/apps/labrinth/.sqlx/query-eb32f61d58b71eb55c348abe51bcc020a8ba20811d92cb6f2bcd225aa08b6210.json b/apps/labrinth/.sqlx/query-eb32f61d58b71eb55c348abe51bcc020a8ba20811d92cb6f2bcd225aa08b6210.json new file mode 100644 index 000000000..312ddc3e1 --- /dev/null +++ b/apps/labrinth/.sqlx/query-eb32f61d58b71eb55c348abe51bcc020a8ba20811d92cb6f2bcd225aa08b6210.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM products_prices WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "eb32f61d58b71eb55c348abe51bcc020a8ba20811d92cb6f2bcd225aa08b6210" +} diff --git a/apps/labrinth/.sqlx/query-ebb2f650d476af928d57d4c3f2601c0295aca54829d28c223895a063e1672770.json b/apps/labrinth/.sqlx/query-ebb2f650d476af928d57d4c3f2601c0295aca54829d28c223895a063e1672770.json new file mode 100644 index 000000000..55eae679b --- /dev/null +++ b/apps/labrinth/.sqlx/query-ebb2f650d476af928d57d4c3f2601c0295aca54829d28c223895a063e1672770.json @@ -0,0 +1,88 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT\n clients.id as \"id!\",\n clients.name as \"name!\",\n clients.icon_url as \"icon_url?\",\n clients.raw_icon_url as \"raw_icon_url?\",\n clients.max_scopes as \"max_scopes!\",\n clients.secret_hash as \"secret_hash!\",\n clients.created as \"created!\",\n clients.created_by as \"created_by!\",\n clients.url as \"url?\",\n clients.description as \"description?\",\n uris.uri_ids as \"uri_ids?\",\n uris.uri_vals as \"uri_vals?\"\n FROM oauth_clients clients\n LEFT JOIN (\n SELECT client_id, array_agg(id) as uri_ids, array_agg(uri) as uri_vals\n FROM oauth_client_redirect_uris\n GROUP BY client_id\n ) uris ON clients.id = uris.client_id\n WHERE created_by = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id!", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "name!", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "icon_url?", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "raw_icon_url?", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "max_scopes!", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "secret_hash!", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "created!", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "created_by!", + "type_info": "Int8" + }, + { + "ordinal": 8, + "name": "url?", + "type_info": "Text" + }, + { + "ordinal": 9, + "name": "description?", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "uri_ids?", + "type_info": "Int8Array" + }, + { + "ordinal": 11, + "name": "uri_vals?", + "type_info": "TextArray" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + true, + true, + false, + false, + false, + false, + true, + true, + null, + null + ] + }, + "hash": "ebb2f650d476af928d57d4c3f2601c0295aca54829d28c223895a063e1672770" +} diff --git a/apps/labrinth/.sqlx/query-ebdcc29fc24bd31514ccdf0202a35768180e8d4fc103239806d2da7ea2540e5d.json b/apps/labrinth/.sqlx/query-ebdcc29fc24bd31514ccdf0202a35768180e8d4fc103239806d2da7ea2540e5d.json new file mode 100644 index 000000000..e9788a309 --- /dev/null +++ b/apps/labrinth/.sqlx/query-ebdcc29fc24bd31514ccdf0202a35768180e8d4fc103239806d2da7ea2540e5d.json @@ -0,0 +1,78 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.is_owner, tm.permissions, tm.organization_permissions, tm.accepted, tm.payouts_split, tm.ordering\n FROM mods m\n INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2 AND accepted = ANY($3)\n WHERE m.id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "team_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 3, + "name": "role", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "is_owner", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "permissions", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "organization_permissions", + "type_info": "Int8" + }, + { + "ordinal": 7, + "name": "accepted", + "type_info": "Bool" + }, + { + "ordinal": 8, + "name": "payouts_split", + "type_info": "Numeric" + }, + { + "ordinal": 9, + "name": "ordering", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8", + "BoolArray" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + true, + false, + false, + false + ] + }, + "hash": "ebdcc29fc24bd31514ccdf0202a35768180e8d4fc103239806d2da7ea2540e5d" +} diff --git a/apps/labrinth/.sqlx/query-ec8f310133cef187e8a6d101105210d6fcc194f67f671a8c4021ac23e0fb5dfc.json b/apps/labrinth/.sqlx/query-ec8f310133cef187e8a6d101105210d6fcc194f67f671a8c4021ac23e0fb5dfc.json new file mode 100644 index 000000000..70169c5f1 --- /dev/null +++ b/apps/labrinth/.sqlx/query-ec8f310133cef187e8a6d101105210d6fcc194f67f671a8c4021ac23e0fb5dfc.json @@ -0,0 +1,83 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, team_id, role AS member_role, is_owner, permissions, organization_permissions,\n accepted, payouts_split, role,\n ordering, user_id\n FROM team_members\n WHERE (team_id = ANY($1) AND user_id = $2 AND accepted = TRUE)\n ORDER BY ordering\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "team_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "member_role", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "is_owner", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "permissions", + "type_info": "Int8" + }, + { + "ordinal": 5, + "name": "organization_permissions", + "type_info": "Int8" + }, + { + "ordinal": 6, + "name": "accepted", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "payouts_split", + "type_info": "Numeric" + }, + { + "ordinal": 8, + "name": "role", + "type_info": "Varchar" + }, + { + "ordinal": 9, + "name": "ordering", + "type_info": "Int8" + }, + { + "ordinal": 10, + "name": "user_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true, + false, + false, + false, + false, + false + ] + }, + "hash": "ec8f310133cef187e8a6d101105210d6fcc194f67f671a8c4021ac23e0fb5dfc" +} diff --git a/apps/labrinth/.sqlx/query-ed1d5d9433bc7f4a360431ecfdd9430c5e58cd6d1c623c187d8661200400b1a4.json b/apps/labrinth/.sqlx/query-ed1d5d9433bc7f4a360431ecfdd9430c5e58cd6d1c623c187d8661200400b1a4.json new file mode 100644 index 000000000..4bcf71fd8 --- /dev/null +++ b/apps/labrinth/.sqlx/query-ed1d5d9433bc7f4a360431ecfdd9430c5e58cd6d1c623c187d8661200400b1a4.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET moderation_message_body = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "ed1d5d9433bc7f4a360431ecfdd9430c5e58cd6d1c623c187d8661200400b1a4" +} diff --git a/apps/labrinth/.sqlx/query-ed3e866634135d4f4c8a513eae2856ad71212f6eec09bb4ccef1506912a3a44c.json b/apps/labrinth/.sqlx/query-ed3e866634135d4f4c8a513eae2856ad71212f6eec09bb4ccef1506912a3a44c.json new file mode 100644 index 000000000..778a30026 --- /dev/null +++ b/apps/labrinth/.sqlx/query-ed3e866634135d4f4c8a513eae2856ad71212f6eec09bb4ccef1506912a3a44c.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE mods\n SET follows = follows + 1\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "ed3e866634135d4f4c8a513eae2856ad71212f6eec09bb4ccef1506912a3a44c" +} diff --git a/apps/labrinth/.sqlx/query-ed47f363296ef7f8b3a8bedfd8108ca692811be1b9dce4a89ad151a6932e44c5.json b/apps/labrinth/.sqlx/query-ed47f363296ef7f8b3a8bedfd8108ca692811be1b9dce4a89ad151a6932e44c5.json new file mode 100644 index 000000000..8545123f6 --- /dev/null +++ b/apps/labrinth/.sqlx/query-ed47f363296ef7f8b3a8bedfd8108ca692811be1b9dce4a89ad151a6932e44c5.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id\n FROM sessions\n WHERE user_id = $1\n ORDER BY created DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ed47f363296ef7f8b3a8bedfd8108ca692811be1b9dce4a89ad151a6932e44c5" +} diff --git a/apps/labrinth/.sqlx/query-ed7cc47dc2acfcaf27c4e763390371dccddbeea902928f1382c9505742f0a9a9.json b/apps/labrinth/.sqlx/query-ed7cc47dc2acfcaf27c4e763390371dccddbeea902928f1382c9505742f0a9a9.json new file mode 100644 index 000000000..d688ab9f3 --- /dev/null +++ b/apps/labrinth/.sqlx/query-ed7cc47dc2acfcaf27c4e763390371dccddbeea902928f1382c9505742f0a9a9.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE organizations\n SET slug = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "ed7cc47dc2acfcaf27c4e763390371dccddbeea902928f1382c9505742f0a9a9" +} diff --git a/apps/labrinth/.sqlx/query-ee2924461357098fd535608f5219635bddfe43342ee549fad2433a271f8feeee.json b/apps/labrinth/.sqlx/query-ee2924461357098fd535608f5219635bddfe43342ee549fad2433a271f8feeee.json new file mode 100644 index 000000000..90f786435 --- /dev/null +++ b/apps/labrinth/.sqlx/query-ee2924461357098fd535608f5219635bddfe43342ee549fad2433a271f8feeee.json @@ -0,0 +1,44 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, slug, name, icon_url, banner_url FROM games\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "slug", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "icon_url", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "banner_url", + "type_info": "Varchar" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + true, + true + ] + }, + "hash": "ee2924461357098fd535608f5219635bddfe43342ee549fad2433a271f8feeee" +} diff --git a/apps/labrinth/.sqlx/query-ee2bca5618c3974147a4541bac1b2d8ca2c4a930769c11e10f6a97e3cac6ee2e.json b/apps/labrinth/.sqlx/query-ee2bca5618c3974147a4541bac1b2d8ca2c4a930769c11e10f6a97e3cac6ee2e.json new file mode 100644 index 000000000..30d23ee3f --- /dev/null +++ b/apps/labrinth/.sqlx/query-ee2bca5618c3974147a4541bac1b2d8ca2c4a930769c11e10f6a97e3cac6ee2e.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id FROM users WHERE discord_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ee2bca5618c3974147a4541bac1b2d8ca2c4a930769c11e10f6a97e3cac6ee2e" +} diff --git a/apps/labrinth/.sqlx/query-ee375e658423156a758cc372400961f627fa5a620a3f61e37ec09fee1d7bb4e3.json b/apps/labrinth/.sqlx/query-ee375e658423156a758cc372400961f627fa5a620a3f61e37ec09fee1d7bb4e3.json new file mode 100644 index 000000000..6a8b6e8ea --- /dev/null +++ b/apps/labrinth/.sqlx/query-ee375e658423156a758cc372400961f627fa5a620a3f61e37ec09fee1d7bb4e3.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM notifications\n WHERE id = ANY($1)\n RETURNING user_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false + ] + }, + "hash": "ee375e658423156a758cc372400961f627fa5a620a3f61e37ec09fee1d7bb4e3" +} diff --git a/apps/labrinth/.sqlx/query-eec6d4028d790e57a4d97fc5a200a9ae2b3d2cb60ee83c51fb05180b821558f5.json b/apps/labrinth/.sqlx/query-eec6d4028d790e57a4d97fc5a200a9ae2b3d2cb60ee83c51fb05180b821558f5.json new file mode 100644 index 000000000..a8b384a0d --- /dev/null +++ b/apps/labrinth/.sqlx/query-eec6d4028d790e57a4d97fc5a200a9ae2b3d2cb60ee83c51fb05180b821558f5.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE pats\n SET scopes = $1\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "eec6d4028d790e57a4d97fc5a200a9ae2b3d2cb60ee83c51fb05180b821558f5" +} diff --git a/apps/labrinth/.sqlx/query-efdaae627a24efdf522c913cfd3600d6331e30dffbba8c2d318e44e260ac5f59.json b/apps/labrinth/.sqlx/query-efdaae627a24efdf522c913cfd3600d6331e30dffbba8c2d318e44e260ac5f59.json new file mode 100644 index 000000000..ddbc42d70 --- /dev/null +++ b/apps/labrinth/.sqlx/query-efdaae627a24efdf522c913cfd3600d6331e30dffbba8c2d318e44e260ac5f59.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO collections_mods (collection_id, mod_id)\n SELECT * FROM UNNEST($1::bigint[], $2::bigint[])\n ON CONFLICT DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "efdaae627a24efdf522c913cfd3600d6331e30dffbba8c2d318e44e260ac5f59" +} diff --git a/apps/labrinth/.sqlx/query-f0068d4e1303bfa69bf1c8d536e74395de5d6b6f7ba7389e8c934eeb8c10286f.json b/apps/labrinth/.sqlx/query-f0068d4e1303bfa69bf1c8d536e74395de5d6b6f7ba7389e8c934eeb8c10286f.json new file mode 100644 index 000000000..b50eebe2e --- /dev/null +++ b/apps/labrinth/.sqlx/query-f0068d4e1303bfa69bf1c8d536e74395de5d6b6f7ba7389e8c934eeb8c10286f.json @@ -0,0 +1,76 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT n.id, n.user_id, n.name, n.text, n.link, n.created, n.read, n.type notification_type, n.body,\n JSONB_AGG(DISTINCT jsonb_build_object('id', na.id, 'notification_id', na.notification_id, 'name', na.name, 'action_route_method', na.action_route_method, 'action_route', na.action_route)) filter (where na.id is not null) actions\n FROM notifications n\n LEFT OUTER JOIN notifications_actions na on n.id = na.notification_id\n WHERE n.id = ANY($1)\n GROUP BY n.id, n.user_id\n ORDER BY n.created DESC;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "text", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "link", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "read", + "type_info": "Bool" + }, + { + "ordinal": 7, + "name": "notification_type", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "body", + "type_info": "Jsonb" + }, + { + "ordinal": 9, + "name": "actions", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + true, + true, + true, + false, + false, + true, + true, + null + ] + }, + "hash": "f0068d4e1303bfa69bf1c8d536e74395de5d6b6f7ba7389e8c934eeb8c10286f" +} diff --git a/apps/labrinth/.sqlx/query-f1441ead59f221901f34d3f7c8e2b27bb020c083c333a1be518827d6df79846e.json b/apps/labrinth/.sqlx/query-f1441ead59f221901f34d3f7c8e2b27bb020c083c333a1be518827d6df79846e.json new file mode 100644 index 000000000..9cfc51db2 --- /dev/null +++ b/apps/labrinth/.sqlx/query-f1441ead59f221901f34d3f7c8e2b27bb020c083c333a1be518827d6df79846e.json @@ -0,0 +1,35 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT user_id, amount, fee FROM payouts WHERE platform_id = $1 AND status = $2", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "amount", + "type_info": "Numeric" + }, + { + "ordinal": 2, + "name": "fee", + "type_info": "Numeric" + } + ], + "parameters": { + "Left": [ + "Text", + "Text" + ] + }, + "nullable": [ + false, + false, + true + ] + }, + "hash": "f1441ead59f221901f34d3f7c8e2b27bb020c083c333a1be518827d6df79846e" +} diff --git a/apps/labrinth/.sqlx/query-f1525930830e17b5ee8feb796d9950dd3741131965f050840fa75423b5a54f01.json b/apps/labrinth/.sqlx/query-f1525930830e17b5ee8feb796d9950dd3741131965f050840fa75423b5a54f01.json new file mode 100644 index 000000000..eecf4e3bf --- /dev/null +++ b/apps/labrinth/.sqlx/query-f1525930830e17b5ee8feb796d9950dd3741131965f050840fa75423b5a54f01.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO sessions (\n id, session, user_id, os, platform,\n city, country, ip, user_agent\n )\n VALUES (\n $1, $2, $3, $4, $5,\n $6, $7, $8, $9\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Varchar", + "Int8", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Varchar" + ] + }, + "nullable": [] + }, + "hash": "f1525930830e17b5ee8feb796d9950dd3741131965f050840fa75423b5a54f01" +} diff --git a/apps/labrinth/.sqlx/query-f17a109913015a7a5ab847bb2e73794d6261a08d450de24b450222755e520881.json b/apps/labrinth/.sqlx/query-f17a109913015a7a5ab847bb2e73794d6261a08d450de24b450222755e520881.json new file mode 100644 index 000000000..40600b2f7 --- /dev/null +++ b/apps/labrinth/.sqlx/query-f17a109913015a7a5ab847bb2e73794d6261a08d450de24b450222755e520881.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id FROM reports\n WHERE closed = FALSE AND reporter = $1\n ORDER BY created ASC\n LIMIT $2;\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "f17a109913015a7a5ab847bb2e73794d6261a08d450de24b450222755e520881" +} diff --git a/apps/labrinth/.sqlx/query-f25ad90c1ac8c0c2fd0e8ba8fa7134335b6244b0685eba15aef5c8c02cc70d70.json b/apps/labrinth/.sqlx/query-f25ad90c1ac8c0c2fd0e8ba8fa7134335b6244b0685eba15aef5c8c02cc70d70.json new file mode 100644 index 000000000..1503d758e --- /dev/null +++ b/apps/labrinth/.sqlx/query-f25ad90c1ac8c0c2fd0e8ba8fa7134335b6244b0685eba15aef5c8c02cc70d70.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE organizations\n SET icon_url = $1, raw_icon_url = $2, color = $3\n WHERE (id = $4)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Text", + "Int4", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "f25ad90c1ac8c0c2fd0e8ba8fa7134335b6244b0685eba15aef5c8c02cc70d70" +} diff --git a/apps/labrinth/.sqlx/query-f2711811ac8f67ead8e307259692b6a9bb08ac99448208895946cb010141cde2.json b/apps/labrinth/.sqlx/query-f2711811ac8f67ead8e307259692b6a9bb08ac99448208895946cb010141cde2.json new file mode 100644 index 000000000..9e6a21fe4 --- /dev/null +++ b/apps/labrinth/.sqlx/query-f2711811ac8f67ead8e307259692b6a9bb08ac99448208895946cb010141cde2.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM users_subscriptions WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "f2711811ac8f67ead8e307259692b6a9bb08ac99448208895946cb010141cde2" +} diff --git a/apps/labrinth/.sqlx/query-f297b517bc3bbd8628c0c222c0e3daf8f4efbe628ee2e8ddbbb4b9734cc9c915.json b/apps/labrinth/.sqlx/query-f297b517bc3bbd8628c0c222c0e3daf8f4efbe628ee2e8ddbbb4b9734cc9c915.json new file mode 100644 index 000000000..dc923578a --- /dev/null +++ b/apps/labrinth/.sqlx/query-f297b517bc3bbd8628c0c222c0e3daf8f4efbe628ee2e8ddbbb4b9734cc9c915.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO moderation_external_files (sha1, external_license_id)\n SELECT * FROM UNNEST ($1::bytea[], $2::bigint[])\n ON CONFLICT (sha1) DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "ByteaArray", + "Int8Array" + ] + }, + "nullable": [] + }, + "hash": "f297b517bc3bbd8628c0c222c0e3daf8f4efbe628ee2e8ddbbb4b9734cc9c915" +} diff --git a/apps/labrinth/.sqlx/query-f2c5eccd8099d6f527c1665cfc0f1204b8a0dab6f2b84f9f72fbf5462c6cb1f4.json b/apps/labrinth/.sqlx/query-f2c5eccd8099d6f527c1665cfc0f1204b8a0dab6f2b84f9f72fbf5462c6cb1f4.json new file mode 100644 index 000000000..8eaeb8327 --- /dev/null +++ b/apps/labrinth/.sqlx/query-f2c5eccd8099d6f527c1665cfc0f1204b8a0dab6f2b84f9f72fbf5462c6cb1f4.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE uploaded_images\n SET mod_id = $1\n WHERE id = $2\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "f2c5eccd8099d6f527c1665cfc0f1204b8a0dab6f2b84f9f72fbf5462c6cb1f4" +} diff --git a/apps/labrinth/.sqlx/query-f34bbe639ad21801258dc8beaab9877229a451761be07f85a1dd04d027832329.json b/apps/labrinth/.sqlx/query-f34bbe639ad21801258dc8beaab9877229a451761be07f85a1dd04d027832329.json new file mode 100644 index 000000000..ec3004f9c --- /dev/null +++ b/apps/labrinth/.sqlx/query-f34bbe639ad21801258dc8beaab9877229a451761be07f85a1dd04d027832329.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "f34bbe639ad21801258dc8beaab9877229a451761be07f85a1dd04d027832329" +} diff --git a/apps/labrinth/.sqlx/query-f453b43772c4d2d9d09dc389eb95482cc75e7f0eaf9dc7ff48cf40f22f1497cc.json b/apps/labrinth/.sqlx/query-f453b43772c4d2d9d09dc389eb95482cc75e7f0eaf9dc7ff48cf40f22f1497cc.json new file mode 100644 index 000000000..bd297db39 --- /dev/null +++ b/apps/labrinth/.sqlx/query-f453b43772c4d2d9d09dc389eb95482cc75e7f0eaf9dc7ff48cf40f22f1497cc.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET bio = $1\n WHERE (id = $2)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "f453b43772c4d2d9d09dc389eb95482cc75e7f0eaf9dc7ff48cf40f22f1497cc" +} diff --git a/apps/labrinth/.sqlx/query-f775506213dbf4bf0ee05fd53c693412e3baae64b6dc0aead8082059f16755bc.json b/apps/labrinth/.sqlx/query-f775506213dbf4bf0ee05fd53c693412e3baae64b6dc0aead8082059f16755bc.json new file mode 100644 index 000000000..72ffb9903 --- /dev/null +++ b/apps/labrinth/.sqlx/query-f775506213dbf4bf0ee05fd53c693412e3baae64b6dc0aead8082059f16755bc.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE notifications\n SET read = TRUE\n WHERE id = ANY($1)\n RETURNING user_id\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "user_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false + ] + }, + "hash": "f775506213dbf4bf0ee05fd53c693412e3baae64b6dc0aead8082059f16755bc" +} diff --git a/apps/labrinth/.sqlx/query-f786bd5afbde34fe166e5535a66ff53036be39958038eaf7c539fd8a9383b724.json b/apps/labrinth/.sqlx/query-f786bd5afbde34fe166e5535a66ff53036be39958038eaf7c539fd8a9383b724.json new file mode 100644 index 000000000..3e36d17fc --- /dev/null +++ b/apps/labrinth/.sqlx/query-f786bd5afbde34fe166e5535a66ff53036be39958038eaf7c539fd8a9383b724.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, product_id, prices, currency_code\n FROM products_prices\n WHERE id = ANY($1::bigint[])", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "product_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "prices", + "type_info": "Jsonb" + }, + { + "ordinal": 3, + "name": "currency_code", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "f786bd5afbde34fe166e5535a66ff53036be39958038eaf7c539fd8a9383b724" +} diff --git a/apps/labrinth/.sqlx/query-f793e96499ff35f8dc2e420484c2a0cdb54f25ffa27caa081691779ab896a709.json b/apps/labrinth/.sqlx/query-f793e96499ff35f8dc2e420484c2a0cdb54f25ffa27caa081691779ab896a709.json new file mode 100644 index 000000000..ccb6804be --- /dev/null +++ b/apps/labrinth/.sqlx/query-f793e96499ff35f8dc2e420484c2a0cdb54f25ffa27caa081691779ab896a709.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM mods\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "f793e96499ff35f8dc2e420484c2a0cdb54f25ffa27caa081691779ab896a709" +} diff --git a/apps/labrinth/.sqlx/query-f85fc13148aafc03a4df68eaa389945e9dc6472a759525a48cfb23d31181535c.json b/apps/labrinth/.sqlx/query-f85fc13148aafc03a4df68eaa389945e9dc6472a759525a48cfb23d31181535c.json new file mode 100644 index 000000000..554c73b64 --- /dev/null +++ b/apps/labrinth/.sqlx/query-f85fc13148aafc03a4df68eaa389945e9dc6472a759525a48cfb23d31181535c.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM threads_messages WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "f85fc13148aafc03a4df68eaa389945e9dc6472a759525a48cfb23d31181535c" +} diff --git a/apps/labrinth/.sqlx/query-f88215069dbadf906c68c554b563021a34a935ce45d221cdf955f6a2c197d8b9.json b/apps/labrinth/.sqlx/query-f88215069dbadf906c68c554b563021a34a935ce45d221cdf955f6a2c197d8b9.json new file mode 100644 index 000000000..babc82e0d --- /dev/null +++ b/apps/labrinth/.sqlx/query-f88215069dbadf906c68c554b563021a34a935ce45d221cdf955f6a2c197d8b9.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n DELETE FROM organizations\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "f88215069dbadf906c68c554b563021a34a935ce45d221cdf955f6a2c197d8b9" +} diff --git a/apps/labrinth/.sqlx/query-f899b378fad8fcfa1ebf527146b565b7c4466205e0bfd84f299123329926fe3f.json b/apps/labrinth/.sqlx/query-f899b378fad8fcfa1ebf527146b565b7c4466205e0bfd84f299123329926fe3f.json new file mode 100644 index 000000000..3adb97d48 --- /dev/null +++ b/apps/labrinth/.sqlx/query-f899b378fad8fcfa1ebf527146b565b7c4466205e0bfd84f299123329926fe3f.json @@ -0,0 +1,30 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO mods (\n id, team_id, name, summary, description,\n published, downloads, icon_url, raw_icon_url, status, requested_status,\n license_url, license,\n slug, color, monetization_status, organization_id\n )\n VALUES (\n $1, $2, $3, $4, $5, $6, \n $7, $8, $9, $10, $11,\n $12, $13,\n LOWER($14), $15, $16, $17\n )\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8", + "Varchar", + "Varchar", + "Varchar", + "Timestamptz", + "Int4", + "Varchar", + "Text", + "Varchar", + "Varchar", + "Varchar", + "Varchar", + "Text", + "Int4", + "Varchar", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "f899b378fad8fcfa1ebf527146b565b7c4466205e0bfd84f299123329926fe3f" +} diff --git a/apps/labrinth/.sqlx/query-f8be3053274b00ee9743e798886696062009c5f681baaf29dfc24cfbbda93742.json b/apps/labrinth/.sqlx/query-f8be3053274b00ee9743e798886696062009c5f681baaf29dfc24cfbbda93742.json new file mode 100644 index 000000000..c398d97d7 --- /dev/null +++ b/apps/labrinth/.sqlx/query-f8be3053274b00ee9743e798886696062009c5f681baaf29dfc24cfbbda93742.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS(SELECT 1 FROM mods WHERE slug = LOWER($1))\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "f8be3053274b00ee9743e798886696062009c5f681baaf29dfc24cfbbda93742" +} diff --git a/apps/labrinth/.sqlx/query-f8ede4a0cd843e8f5622d6e0fb26df14fbc3a47b17c4d628a1bb21cff387238e.json b/apps/labrinth/.sqlx/query-f8ede4a0cd843e8f5622d6e0fb26df14fbc3a47b17c4d628a1bb21cff387238e.json new file mode 100644 index 000000000..5be0b8379 --- /dev/null +++ b/apps/labrinth/.sqlx/query-f8ede4a0cd843e8f5622d6e0fb26df14fbc3a47b17c4d628a1bb21cff387238e.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id\n FROM payouts\n WHERE user_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "f8ede4a0cd843e8f5622d6e0fb26df14fbc3a47b17c4d628a1bb21cff387238e" +} diff --git a/apps/labrinth/.sqlx/query-f96967f8d0d7e4c7a9d424e075fe70b2a89efe74bde1db9730ac478749dc1b66.json b/apps/labrinth/.sqlx/query-f96967f8d0d7e4c7a9d424e075fe70b2a89efe74bde1db9730ac478749dc1b66.json new file mode 100644 index 000000000..0841edec8 --- /dev/null +++ b/apps/labrinth/.sqlx/query-f96967f8d0d7e4c7a9d424e075fe70b2a89efe74bde1db9730ac478749dc1b66.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE reports\n SET mod_id = NULL\n WHERE mod_id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "f96967f8d0d7e4c7a9d424e075fe70b2a89efe74bde1db9730ac478749dc1b66" +} diff --git a/apps/labrinth/.sqlx/query-f9bc19beaa70db45b058e80ba86599d393fad4c7d4af98426a8a9d9ca9b24035.json b/apps/labrinth/.sqlx/query-f9bc19beaa70db45b058e80ba86599d393fad4c7d4af98426a8a9d9ca9b24035.json new file mode 100644 index 000000000..16dd2e3eb --- /dev/null +++ b/apps/labrinth/.sqlx/query-f9bc19beaa70db45b058e80ba86599d393fad4c7d4af98426a8a9d9ca9b24035.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE users\n SET steam_id = $2\n WHERE (id = $1)\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + "Int8" + ] + }, + "nullable": [] + }, + "hash": "f9bc19beaa70db45b058e80ba86599d393fad4c7d4af98426a8a9d9ca9b24035" +} diff --git a/apps/labrinth/.sqlx/query-fa5b05775f18d1268bbeece1f5f1b0c1930289eb797cf340d961ac69d2c2ceba.json b/apps/labrinth/.sqlx/query-fa5b05775f18d1268bbeece1f5f1b0c1930289eb797cf340d961ac69d2c2ceba.json new file mode 100644 index 000000000..ff6b3926a --- /dev/null +++ b/apps/labrinth/.sqlx/query-fa5b05775f18d1268bbeece1f5f1b0c1930289eb797cf340d961ac69d2c2ceba.json @@ -0,0 +1,18 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO payouts_values (user_id, mod_id, amount, created, date_available)\n SELECT * FROM UNNEST ($1::bigint[], $2::bigint[], $3::numeric[], $4::timestamptz[], $5::timestamptz[])\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8Array", + "Int8Array", + "NumericArray", + "TimestamptzArray", + "TimestamptzArray" + ] + }, + "nullable": [] + }, + "hash": "fa5b05775f18d1268bbeece1f5f1b0c1930289eb797cf340d961ac69d2c2ceba" +} diff --git a/apps/labrinth/.sqlx/query-fb955ca41b95120f66c98c0b528b1db10c4be4a55e9641bb104d772e390c9bb7.json b/apps/labrinth/.sqlx/query-fb955ca41b95120f66c98c0b528b1db10c4be4a55e9641bb104d772e390c9bb7.json new file mode 100644 index 000000000..357bafa24 --- /dev/null +++ b/apps/labrinth/.sqlx/query-fb955ca41b95120f66c98c0b528b1db10c4be4a55e9641bb104d772e390c9bb7.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT EXISTS(SELECT 1 FROM notifications WHERE id=$1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "fb955ca41b95120f66c98c0b528b1db10c4be4a55e9641bb104d772e390c9bb7" +} diff --git a/apps/labrinth/.sqlx/query-fdfe36dcb85347a3a8228b5d5fc2d017b9baa307b5ae0ae9deaafab9dcdcb74a.json b/apps/labrinth/.sqlx/query-fdfe36dcb85347a3a8228b5d5fc2d017b9baa307b5ae0ae9deaafab9dcdcb74a.json new file mode 100644 index 000000000..aba671f54 --- /dev/null +++ b/apps/labrinth/.sqlx/query-fdfe36dcb85347a3a8228b5d5fc2d017b9baa307b5ae0ae9deaafab9dcdcb74a.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT follower_id FROM mod_follows\n WHERE mod_id = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "follower_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false + ] + }, + "hash": "fdfe36dcb85347a3a8228b5d5fc2d017b9baa307b5ae0ae9deaafab9dcdcb74a" +} diff --git a/apps/labrinth/.sqlx/query-fe34673ce6d7bcb616a5ab2e8900d7dfb4e0fa2ee640128d29d6e4beafe60f4c.json b/apps/labrinth/.sqlx/query-fe34673ce6d7bcb616a5ab2e8900d7dfb4e0fa2ee640128d29d6e4beafe60f4c.json new file mode 100644 index 000000000..0af23b855 --- /dev/null +++ b/apps/labrinth/.sqlx/query-fe34673ce6d7bcb616a5ab2e8900d7dfb4e0fa2ee640128d29d6e4beafe60f4c.json @@ -0,0 +1,50 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT id, enum_id, value, ordering, created, metadata\n FROM loader_field_enum_values lfev\n ORDER BY enum_id, ordering, created DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "enum_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "value", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "ordering", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "metadata", + "type_info": "Jsonb" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false, + true, + false, + true + ] + }, + "hash": "fe34673ce6d7bcb616a5ab2e8900d7dfb4e0fa2ee640128d29d6e4beafe60f4c" +} diff --git a/apps/labrinth/.sqlx/query-fef7802edefa1a6957fa43ccf64b987c3547294f0c8581028ac6b83de42b3a00.json b/apps/labrinth/.sqlx/query-fef7802edefa1a6957fa43ccf64b987c3547294f0c8581028ac6b83de42b3a00.json new file mode 100644 index 000000000..bcca3ff20 --- /dev/null +++ b/apps/labrinth/.sqlx/query-fef7802edefa1a6957fa43ccf64b987c3547294f0c8581028ac6b83de42b3a00.json @@ -0,0 +1,32 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, name, donation FROM link_platforms\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "donation", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [] + }, + "nullable": [ + false, + false, + false + ] + }, + "hash": "fef7802edefa1a6957fa43ccf64b987c3547294f0c8581028ac6b83de42b3a00" +} diff --git a/apps/labrinth/.sqlx/query-ff7ff5b202609f1e68dc4a18eed1183b1077b15577b52083c2cf0b2cc0818a29.json b/apps/labrinth/.sqlx/query-ff7ff5b202609f1e68dc4a18eed1183b1077b15577b52083c2cf0b2cc0818a29.json new file mode 100644 index 000000000..18441a795 --- /dev/null +++ b/apps/labrinth/.sqlx/query-ff7ff5b202609f1e68dc4a18eed1183b1077b15577b52083c2cf0b2cc0818a29.json @@ -0,0 +1,65 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT DISTINCT mod_id, mg.image_url, mg.raw_image_url, mg.featured, mg.name, mg.description, mg.created, mg.ordering\n FROM mods_gallery mg\n INNER JOIN mods m ON mg.mod_id = m.id\n WHERE m.id = ANY($1) OR m.slug = ANY($2)\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "image_url", + "type_info": "Varchar" + }, + { + "ordinal": 2, + "name": "raw_image_url", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "featured", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "name", + "type_info": "Varchar" + }, + { + "ordinal": 5, + "name": "description", + "type_info": "Varchar" + }, + { + "ordinal": 6, + "name": "created", + "type_info": "Timestamptz" + }, + { + "ordinal": 7, + "name": "ordering", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8Array", + "TextArray" + ] + }, + "nullable": [ + false, + false, + false, + true, + true, + true, + false, + false + ] + }, + "hash": "ff7ff5b202609f1e68dc4a18eed1183b1077b15577b52083c2cf0b2cc0818a29" +} diff --git a/apps/labrinth/Cargo.toml b/apps/labrinth/Cargo.toml new file mode 100644 index 000000000..09dcbf2f1 --- /dev/null +++ b/apps/labrinth/Cargo.toml @@ -0,0 +1,124 @@ +[package] +name = "labrinth" +version = "2.7.0" +authors = ["geometrically "] +edition = "2018" +license = "AGPL-3.0" + +# This seems redundant, but it's necessary for Docker to work +[[bin]] +name = "labrinth" +path = "src/main.rs" + +[dependencies] +actix-web = "4.4.1" +actix-rt = "2.9.0" +actix-multipart = "0.6.1" +actix-cors = "0.7.0" +actix-ws = "0.2.5" +actix-files = "0.6.5" +actix-web-prom = { version = "0.8.0", features = ["process"]} +governor = "0.6.3" + +tokio = { version = "1.35.1", features = ["sync"] } +tokio-stream = "0.1.14" + +futures = "0.3.30" +futures-timer = "3.0.2" +futures-util = "0.3.30" +async-trait = "0.1.70" +dashmap = "5.4.0" +lazy_static = "1.4.0" + +meilisearch-sdk = "0.24.3" +rust-s3 = "0.33.0" +reqwest = { version = "0.11.18", features = ["json", "multipart"] } +hyper = { version = "0.14", features = ["full"] } +hyper-tls = "0.5.0" + +serde_json = "1.0" +serde = { version = "1.0", features = ["derive"] } +serde_with = "3.0.0" +chrono = { version = "0.4.26", features = ["serde"] } +yaserde = "0.8.0" +yaserde_derive = "0.8.0" +xml-rs = "0.8.15" + +rand = "0.8.5" +rand_chacha = "0.3.1" +bytes = "1.4.0" +base64 = "0.21.7" +sha1 = { version = "0.6.1", features = ["std"] } +sha2 = "0.9.9" +hmac = "0.11.0" +argon2 = { version = "0.5.0", features = ["std"] } +murmur2 = "0.1.0" +bitflags = "2.4.0" +hex = "0.4.3" +zxcvbn = "2.2.2" +totp-rs = { version = "5.0.2", features = ["gen_secret"] } + +url = "2.4.0" +urlencoding = "2.1.2" + +zip = "0.6.6" + +itertools = "0.12.0" + +validator = { version = "0.16.1", features = ["derive", "phone"] } +regex = "1.10.2" +censor = "0.3.0" +spdx = { version = "0.10.3", features = ["text"] } + +dotenvy = "0.15.7" +log = "0.4.20" +env_logger = "0.10.1" +thiserror = "1.0.56" + +sqlx = { version = "0.8.2", features = [ + "runtime-tokio-rustls", + "postgres", + "chrono", + "macros", + "migrate", + "rust_decimal", + "json", +] } +rust_decimal = { version = "1.33.1", features = [ + "serde-with-float", + "serde-with-str", +] } +redis = { version = "0.27.5", features = ["tokio-comp", "ahash", "r2d2"]} +deadpool-redis = "0.18.0" +clickhouse = { version = "0.11.2", features = ["uuid", "time"] } +uuid = { version = "1.2.2", features = ["v4", "fast-rng", "serde"] } + +maxminddb = "0.24.0" +flate2 = "1.0.25" +tar = "0.4.38" + +sentry = { version = "0.32.1" } +sentry-actix = "0.32.1" + +image = "0.24.6" +color-thief = "0.2.2" +webp = "0.3.0" + +woothee = "0.13.0" + +lettre = "0.11.3" + +derive-new = "0.6.0" +rust_iso3166 = "0.1.11" + +jemallocator = {version = "0.5.4", optional = true} + +async-stripe = { version = "0.37.3", features = ["runtime-tokio-hyper-rustls"] } +rusty-money = "0.4.1" +json-patch = "*" + +[dev-dependencies] +actix-http = "3.4.0" + +[features] +jemalloc = ["jemallocator"] \ No newline at end of file diff --git a/apps/labrinth/Dockerfile b/apps/labrinth/Dockerfile new file mode 100644 index 000000000..e7126e6dc --- /dev/null +++ b/apps/labrinth/Dockerfile @@ -0,0 +1,27 @@ +FROM rust:1.81.0 as build +ENV PKG_CONFIG_ALLOW_CROSS=1 + +WORKDIR /usr/src/labrinth +COPY . . +RUN cargo build --release + + +FROM debian:bookworm-slim + +LABEL org.opencontainers.image.source=https://github.com/modrinth/code +LABEL org.opencontainers.image.description="Modrinth API" +LABEL org.opencontainers.image.licenses=AGPL-3.0 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates openssl \ + && apt-get clean \ + && rm -rf /var/lib/apt/lists/* + +RUN update-ca-certificates + +COPY --from=build /usr/src/labrinth/target/release/labrinth /labrinth/labrinth +COPY --from=build /usr/src/labrinth/migrations/* /labrinth/migrations/ +COPY --from=build /usr/src/labrinth/assets /labrinth/assets +WORKDIR /labrinth + +CMD /labrinth/labrinth \ No newline at end of file diff --git a/apps/labrinth/LICENSE.txt b/apps/labrinth/LICENSE.txt new file mode 100644 index 000000000..0ad25db4b --- /dev/null +++ b/apps/labrinth/LICENSE.txt @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published + by the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/apps/labrinth/README.md b/apps/labrinth/README.md new file mode 100644 index 000000000..9054e6f15 --- /dev/null +++ b/apps/labrinth/README.md @@ -0,0 +1,5 @@ +![labrinth banner](https://user-images.githubusercontent.com/12068027/100479891-d6bab300-30ac-11eb-8336-b4cad376a03d.png) + +## Modrinth's laboratory for its backend service & API! + +For contributing information, please see the labrinth section of the [Modrinth contributing guide](https://docs.modrinth.com/docs/details/contributing/#labrinth-backend-and-api). For documentation on the API itself, see the [API Spec](https://docs.modrinth.com/api-spec/). diff --git a/apps/labrinth/assets/auth/style.css b/apps/labrinth/assets/auth/style.css new file mode 100644 index 000000000..296526871 --- /dev/null +++ b/apps/labrinth/assets/auth/style.css @@ -0,0 +1,63 @@ +:root { + --color-bg: #16181c; + --color-fg: #b0bac5; + --color-section-bg: #26292f; + + --content-width: 30%; + --content-max-width: 300px; + --content-padding: 1.5rem; + --edge-rounding: 1rem; +} + +html, body { + height: 100%; + overflow: hidden; +} + +body { + color: var(--color-fg); + background-color: var(--color-bg); + display: flex; + justify-content: center; + align-items: center; + font-family: Inter, -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica Neue, Helvetica, + Oxygen, Ubuntu, Roboto, Cantarell, Fira Sans, Droid Sans, 'Apple Color Emoji', 'Segoe UI Emoji', + Arial, sans-serif; +} + +.content { + background-color: var(--color-section-bg); + width: var(--content-width); + max-width: var(--content-max-width); + border-radius: var(--edge-rounding); + padding: var(--content-padding); + justify-content: center; + align-items: center; + box-sizing: border-box; +} + +.content h2 { + margin-bottom: 0; +} + +.logo { + display: block; + width: 100%; + margin-left: auto; + margin-right: auto; + margin-bottom: 2rem; + border-radius: 1.5rem; +} + +a { + color: #4f9cff; + text-decoration: underline; +} + +a:visited { + color: #4f9cff +} + +img { + image-rendering: pixelated; +} diff --git a/apps/labrinth/assets/favicon.ico b/apps/labrinth/assets/favicon.ico new file mode 100644 index 000000000..c2ccbab90 Binary files /dev/null and b/apps/labrinth/assets/favicon.ico differ diff --git a/apps/labrinth/assets/logo.svg b/apps/labrinth/assets/logo.svg new file mode 100644 index 000000000..33df4bb51 --- /dev/null +++ b/apps/labrinth/assets/logo.svg @@ -0,0 +1,4 @@ + + + + \ No newline at end of file diff --git a/apps/labrinth/build.rs b/apps/labrinth/build.rs new file mode 100644 index 000000000..3a8149ef0 --- /dev/null +++ b/apps/labrinth/build.rs @@ -0,0 +1,3 @@ +fn main() { + println!("cargo:rerun-if-changed=migrations"); +} diff --git a/apps/labrinth/docker_utils/dummy.rs b/apps/labrinth/docker_utils/dummy.rs new file mode 100644 index 000000000..e71fdf554 --- /dev/null +++ b/apps/labrinth/docker_utils/dummy.rs @@ -0,0 +1 @@ +fn main() {} \ No newline at end of file diff --git a/apps/labrinth/migrations/20200716160921_init.sql b/apps/labrinth/migrations/20200716160921_init.sql new file mode 100644 index 000000000..a53fc3901 --- /dev/null +++ b/apps/labrinth/migrations/20200716160921_init.sql @@ -0,0 +1,102 @@ +CREATE TABLE users ( + -- TODO + id bigint PRIMARY KEY +); + +CREATE TABLE game_versions ( + id serial PRIMARY KEY, + version varchar(255) NOT NULL +); + +CREATE TABLE loaders ( + id serial PRIMARY KEY, + loader varchar(255) NOT NULL +); + +CREATE TABLE teams ( + id bigint PRIMARY KEY +); + +CREATE TABLE release_channel ( + id serial PRIMARY KEY, + channel varchar(255) +); + +CREATE TABLE mods ( + id bigint PRIMARY KEY, + team_id bigint REFERENCES teams NOT NULL, + title varchar(255) NOT NULL, + description varchar(2048) NOT NULL, + body_url varchar(2048) NOT NULL, + published timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + downloads integer NOT NULL DEFAULT 0, + + icon_url varchar(2048) NULL, + issues_url varchar(2048) NULL, + source_url varchar(2048) NULL, + wiki_url varchar(2048) NULL +); + + +CREATE TABLE versions ( + id bigint PRIMARY KEY, + mod_id bigint REFERENCES mods, + name varchar(255) NOT NULL, + version_number varchar(255) NOT NULL, + changelog_url varchar(255) NULL, + date_published timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + downloads integer NOT NULL DEFAULT 0, + + release_channel int REFERENCES release_channel ON UPDATE CASCADE NOT NULL +); + +CREATE TABLE loaders_versions ( + loader_id int REFERENCES loaders ON UPDATE CASCADE NOT NULL, + version_id bigint REFERENCES versions ON UPDATE CASCADE NOT NULL, + PRIMARY KEY (loader_id, version_id) +); + +CREATE TABLE game_versions_versions ( + game_version_id integer REFERENCES game_versions ON UPDATE CASCADE NOT NULL, + joining_version_id bigint REFERENCES versions ON UPDATE CASCADE NOT NULL, + PRIMARY KEY (game_version_id, joining_version_id) +); + +CREATE TABLE files ( + id bigint PRIMARY KEY, + version_id bigint REFERENCES versions NOT NULL, + url varchar(2048) NOT NULL +); + +CREATE TABLE hashes ( + file_id bigint REFERENCES files NOT NULL, + algorithm varchar(255) NOT NULL, + hash bytea NOT NULL, + PRIMARY KEY (file_id, algorithm) +); + +CREATE TABLE dependencies ( + id serial PRIMARY KEY, + dependent_id bigint REFERENCES versions ON UPDATE CASCADE NOT NULL, + dependency_id bigint REFERENCES versions ON UPDATE CASCADE NOT NULL, + CONSTRAINT valid_dependency CHECK (dependent_id <> dependency_id) -- No dependency on yourself +); + +CREATE TABLE team_members ( + id bigint PRIMARY KEY, + team_id bigint REFERENCES teams NOT NULL, + user_id bigint REFERENCES users NOT NULL, + member_name varchar(255) NOT NULL, + role varchar(255) NOT NULL +); + +CREATE TABLE categories ( + id serial PRIMARY KEY, + category varchar(255) UNIQUE +); + +CREATE TABLE mods_categories ( + joining_mod_id bigint REFERENCES mods ON UPDATE CASCADE NOT NULL, + joining_category_id int REFERENCES categories ON UPDATE CASCADE NOT NULL, + PRIMARY KEY (joining_mod_id, joining_category_id) +); diff --git a/apps/labrinth/migrations/20200717192808_Make_categories_non-null.sql b/apps/labrinth/migrations/20200717192808_Make_categories_non-null.sql new file mode 100644 index 000000000..d4edc4c78 --- /dev/null +++ b/apps/labrinth/migrations/20200717192808_Make_categories_non-null.sql @@ -0,0 +1,3 @@ +-- Add migration script here +ALTER TABLE categories +ALTER COLUMN category SET NOT NULL; diff --git a/apps/labrinth/migrations/20200722031742_initial-release-channels.sql b/apps/labrinth/migrations/20200722031742_initial-release-channels.sql new file mode 100644 index 000000000..ba42ad5c4 --- /dev/null +++ b/apps/labrinth/migrations/20200722031742_initial-release-channels.sql @@ -0,0 +1,7 @@ +-- Add migration script here +INSERT INTO release_channel (channel) VALUES ('release'); +INSERT INTO release_channel (channel) VALUES ('release-hidden'); +INSERT INTO release_channel (channel) VALUES ('beta'); +INSERT INTO release_channel (channel) VALUES ('beta-hidden'); +INSERT INTO release_channel (channel) VALUES ('alpha'); +INSERT INTO release_channel (channel) VALUES ('alpha-hidden'); \ No newline at end of file diff --git a/apps/labrinth/migrations/20200722033157_rename-release-channels.sql b/apps/labrinth/migrations/20200722033157_rename-release-channels.sql new file mode 100644 index 000000000..4a19ebf8c --- /dev/null +++ b/apps/labrinth/migrations/20200722033157_rename-release-channels.sql @@ -0,0 +1,2 @@ +-- Add migration script here +ALTER TABLE release_channel RENAME TO release_channels \ No newline at end of file diff --git a/apps/labrinth/migrations/20200722153930_version-filename.sql b/apps/labrinth/migrations/20200722153930_version-filename.sql new file mode 100644 index 000000000..1ba726439 --- /dev/null +++ b/apps/labrinth/migrations/20200722153930_version-filename.sql @@ -0,0 +1,3 @@ +-- Add migration script here +ALTER TABLE files +ADD filename varchar(2048) NOT NULL; diff --git a/apps/labrinth/migrations/20200730223151_more-not-null.sql b/apps/labrinth/migrations/20200730223151_more-not-null.sql new file mode 100644 index 000000000..2fb04bb11 --- /dev/null +++ b/apps/labrinth/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/apps/labrinth/migrations/20200812183213_unique-loaders.sql b/apps/labrinth/migrations/20200812183213_unique-loaders.sql new file mode 100644 index 000000000..9d44fbef2 --- /dev/null +++ b/apps/labrinth/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/apps/labrinth/migrations/20200928020509_states.sql b/apps/labrinth/migrations/20200928020509_states.sql new file mode 100644 index 000000000..96e8e1408 --- /dev/null +++ b/apps/labrinth/migrations/20200928020509_states.sql @@ -0,0 +1,4 @@ +CREATE TABLE states ( + id bigint PRIMARY KEY, + url varchar(500) +); \ No newline at end of file diff --git a/apps/labrinth/migrations/20200928033759_edit-states.sql b/apps/labrinth/migrations/20200928033759_edit-states.sql new file mode 100644 index 000000000..46fa24952 --- /dev/null +++ b/apps/labrinth/migrations/20200928033759_edit-states.sql @@ -0,0 +1,4 @@ +-- Add migration script here +ALTER TABLE states + +ADD COLUMN expires timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP + interval '1 hour'; \ No newline at end of file diff --git a/apps/labrinth/migrations/20200928053955_make-url-not-null.sql b/apps/labrinth/migrations/20200928053955_make-url-not-null.sql new file mode 100644 index 000000000..8649f57f6 --- /dev/null +++ b/apps/labrinth/migrations/20200928053955_make-url-not-null.sql @@ -0,0 +1,3 @@ +-- Add migration script here +ALTER TABLE states +ALTER COLUMN url SET NOT NULL; \ No newline at end of file diff --git a/apps/labrinth/migrations/20200928170310_create-users.sql b/apps/labrinth/migrations/20200928170310_create-users.sql new file mode 100644 index 000000000..50fb1c34f --- /dev/null +++ b/apps/labrinth/migrations/20200928170310_create-users.sql @@ -0,0 +1,8 @@ +ALTER TABLE users +ADD COLUMN github_id bigint NOT NULL default 0, +ADD COLUMN username varchar(255) NOT NULL default 'username', +ADD COLUMN name varchar(255) NOT NULL default 'John Doe', +ADD COLUMN email varchar(255) NULL default 'johndoe@modrinth.com', +ADD COLUMN avatar_url varchar(500) NOT NULL default '...', +ADD COLUMN bio varchar(160) NOT NULL default 'I make mods!', +ADD COLUMN created timestamptz default CURRENT_TIMESTAMP NOT NULL \ No newline at end of file diff --git a/apps/labrinth/migrations/20200928195220_add-roles-to-users.sql b/apps/labrinth/migrations/20200928195220_add-roles-to-users.sql new file mode 100644 index 000000000..bfbf6aa47 --- /dev/null +++ b/apps/labrinth/migrations/20200928195220_add-roles-to-users.sql @@ -0,0 +1,3 @@ +-- Add migration script here +ALTER TABLE users +ADD COLUMN role varchar(50) NOT NULL default 'developer' \ No newline at end of file diff --git a/apps/labrinth/migrations/20200929034101_add-author-to-versions.sql b/apps/labrinth/migrations/20200929034101_add-author-to-versions.sql new file mode 100644 index 000000000..ff2561156 --- /dev/null +++ b/apps/labrinth/migrations/20200929034101_add-author-to-versions.sql @@ -0,0 +1,3 @@ +-- Add migration script here +ALTER TABLE versions +ADD COLUMN author_id bigint REFERENCES users NOT NULL default 0 \ No newline at end of file diff --git a/apps/labrinth/migrations/20201001015631_not-null-github-avatar.sql b/apps/labrinth/migrations/20201001015631_not-null-github-avatar.sql new file mode 100644 index 000000000..be0dd29d8 --- /dev/null +++ b/apps/labrinth/migrations/20201001015631_not-null-github-avatar.sql @@ -0,0 +1,35 @@ + +-- Originally: +-- ALTER TABLE users +-- ADD COLUMN github_id bigint NOT NULL default 0, +-- ADD COLUMN username varchar(255) NOT NULL default 'username', +-- ADD COLUMN name varchar(255) NOT NULL default 'John Doe', +-- ADD COLUMN email varchar(255) NULL default 'johndoe@modrinth.com', +-- ADD COLUMN avatar_url varchar(500) NOT NULL default '...', +-- ADD COLUMN bio varchar(160) NOT NULL default 'I make mods!', +-- ADD COLUMN created timestamptz default CURRENT_TIMESTAMP NOT NULL + +-- We don't want garbage data when users are created incorrectly; +-- we just want it to fail. + +ALTER TABLE users +ALTER COLUMN github_id DROP NOT NULL; +ALTER TABLE users +ALTER COLUMN github_id DROP DEFAULT; + +ALTER TABLE users +ALTER COLUMN avatar_url DROP NOT NULL; +ALTER TABLE users +ALTER COLUMN avatar_url DROP DEFAULT; + +ALTER TABLE users +ALTER COLUMN username DROP DEFAULT; +ALTER TABLE users +ALTER COLUMN name DROP DEFAULT; +ALTER TABLE users +ALTER COLUMN email DROP DEFAULT; + +ALTER TABLE users +ALTER COLUMN bio DROP DEFAULT; +ALTER TABLE users +ALTER COLUMN bio DROP NOT NULL; diff --git a/apps/labrinth/migrations/20201003211651_make-name-null.sql b/apps/labrinth/migrations/20201003211651_make-name-null.sql new file mode 100644 index 000000000..2bfbfbe76 --- /dev/null +++ b/apps/labrinth/migrations/20201003211651_make-name-null.sql @@ -0,0 +1,5 @@ +-- Add migration script here +ALTER TABLE users +ALTER COLUMN name DROP NOT NULL; +ALTER TABLE users +ALTER COLUMN name DROP DEFAULT; diff --git a/apps/labrinth/migrations/20201014165954_create-statuses.sql b/apps/labrinth/migrations/20201014165954_create-statuses.sql new file mode 100644 index 000000000..2fcdcc7ef --- /dev/null +++ b/apps/labrinth/migrations/20201014165954_create-statuses.sql @@ -0,0 +1,15 @@ +CREATE TABLE statuses ( + id serial PRIMARY KEY UNIQUE NOT NULL, + status varchar(64) UNIQUE NOT NULL +); + +ALTER TABLE mods +ADD COLUMN status integer REFERENCES statuses NOT NULL; +ALTER TABLE mods +ADD COLUMN updated timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP; + +INSERT INTO statuses (status) VALUES ('approved'); +INSERT INTO statuses (status) VALUES ('rejected'); +INSERT INTO statuses (status) VALUES ('draft'); +INSERT INTO statuses (status) VALUES ('unlisted'); +INSERT INTO statuses (status) VALUES ('processing'); \ No newline at end of file diff --git a/apps/labrinth/migrations/20201021214908_extend-game-version.sql b/apps/labrinth/migrations/20201021214908_extend-game-version.sql new file mode 100644 index 000000000..d40cb420b --- /dev/null +++ b/apps/labrinth/migrations/20201021214908_extend-game-version.sql @@ -0,0 +1,3 @@ + +ALTER TABLE game_versions +ADD COLUMN type varchar(16) NOT NULL DEFAULT 'other'; diff --git a/apps/labrinth/migrations/20201029190804_add-game-version-datetime.sql b/apps/labrinth/migrations/20201029190804_add-game-version-datetime.sql new file mode 100644 index 000000000..d9f08943b --- /dev/null +++ b/apps/labrinth/migrations/20201029190804_add-game-version-datetime.sql @@ -0,0 +1,3 @@ + +ALTER TABLE game_versions +ADD COLUMN created timestamptz NOT NULL DEFAULT timezone('utc', now()); diff --git a/apps/labrinth/migrations/20201109200208_edit-teams.sql b/apps/labrinth/migrations/20201109200208_edit-teams.sql new file mode 100644 index 000000000..102d38e3e --- /dev/null +++ b/apps/labrinth/migrations/20201109200208_edit-teams.sql @@ -0,0 +1,5 @@ +-- Add migration script here +ALTER TABLE team_members +ADD COLUMN permissions bigint default 0 NOT NULL; +ALTER TABLE team_members +ADD COLUMN accepted boolean default false NOT NULL; \ No newline at end of file diff --git a/apps/labrinth/migrations/20201112052516_moderation.sql b/apps/labrinth/migrations/20201112052516_moderation.sql new file mode 100644 index 000000000..ab34d34a2 --- /dev/null +++ b/apps/labrinth/migrations/20201112052516_moderation.sql @@ -0,0 +1,7 @@ +-- Add migration script here +DELETE FROM release_channels WHERE channel = 'release-hidden'; +DELETE FROM release_channels WHERE channel = 'beta-hidden'; +DELETE FROM release_channels WHERE channel = 'alpha-hidden'; + +ALTER TABLE versions +ADD COLUMN accepted BOOLEAN NOT NULL default FALSE; \ No newline at end of file diff --git a/apps/labrinth/migrations/20201122043349_more-mod-data.sql b/apps/labrinth/migrations/20201122043349_more-mod-data.sql new file mode 100644 index 000000000..101dba36c --- /dev/null +++ b/apps/labrinth/migrations/20201122043349_more-mod-data.sql @@ -0,0 +1,63 @@ +CREATE TABLE donation_platforms ( + id serial PRIMARY KEY, + short varchar(100) UNIQUE NOT NULL, + name varchar(500) UNIQUE NOT NULL +); + +INSERT INTO donation_platforms (short, name) VALUES ('patreon', 'Patreon'); +INSERT INTO donation_platforms (short, name) VALUES ('bmac', 'Buy Me a Coffee'); +INSERT INTO donation_platforms (short, name) VALUES ('paypal', 'PayPal'); +INSERT INTO donation_platforms (short, name) VALUES ('github', 'GitHub Sponsors'); +INSERT INTO donation_platforms (short, name) VALUES ('ko-fi', 'Ko-fi'); +INSERT INTO donation_platforms (short, name) VALUES ('other', 'Other'); + +CREATE TABLE mods_donations ( + joining_mod_id bigint REFERENCES mods ON UPDATE CASCADE NOT NULL, + joining_platform_id int REFERENCES donation_platforms ON UPDATE CASCADE NOT NULL, + url varchar(2048) NOT NULL, + PRIMARY KEY (joining_mod_id, joining_platform_id) +); + +CREATE TABLE side_types ( + id serial PRIMARY KEY, + name varchar(64) UNIQUE NOT NULL +); + +INSERT INTO side_types (name) VALUES ('required'); +INSERT INTO side_types (name) VALUES ('no-functionality'); +INSERT INTO side_types (name) VALUES ('unsupported'); +INSERT INTO side_types (name) VALUES ('unknown'); + +CREATE TABLE licenses ( + id serial PRIMARY KEY, + short varchar(60) UNIQUE NOT NULL, + name varchar(1000) UNIQUE NOT NULL +); + +INSERT INTO licenses (short, name) VALUES ('custom', 'Custom License'); + +ALTER TABLE versions + ADD COLUMN featured BOOLEAN NOT NULL default FALSE; +ALTER TABLE files + ADD COLUMN is_primary BOOLEAN NOT NULL default FALSE; + +ALTER TABLE mods + ADD COLUMN license integer REFERENCES licenses NOT NULL default 1; +ALTER TABLE mods + ADD COLUMN license_url varchar(1000) NULL; +ALTER TABLE mods + ADD COLUMN client_side integer REFERENCES side_types NOT NULL default 4; +ALTER TABLE mods + ADD COLUMN server_side integer REFERENCES side_types NOT NULL default 4; +ALTER TABLE mods + ADD COLUMN discord_url varchar(255) NULL; +ALTER TABLE mods + ADD COLUMN slug varchar(255) NULL UNIQUE; + +CREATE TABLE downloads ( + id serial PRIMARY KEY, + version_id bigint REFERENCES versions ON UPDATE CASCADE NOT NULL, + date timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + -- A SHA1 hash of the downloader IP address + identifier varchar(40) NOT NULL +); \ No newline at end of file diff --git a/apps/labrinth/migrations/20201213013358_remove-member-name.sql b/apps/labrinth/migrations/20201213013358_remove-member-name.sql new file mode 100644 index 000000000..a70789544 --- /dev/null +++ b/apps/labrinth/migrations/20201213013358_remove-member-name.sql @@ -0,0 +1,5 @@ +-- Add migration script here +ALTER TABLE team_members + DROP COLUMN member_name; + +UPDATE side_types SET name = 'optional' WHERE name = 'no-functionality'; \ No newline at end of file diff --git a/apps/labrinth/migrations/20210113202021_add-descriptions.sql b/apps/labrinth/migrations/20210113202021_add-descriptions.sql new file mode 100644 index 000000000..5046c9906 --- /dev/null +++ b/apps/labrinth/migrations/20210113202021_add-descriptions.sql @@ -0,0 +1,15 @@ +ALTER TABLE mods + ADD COLUMN body varchar(65536) NOT NULL DEFAULT ''; +ALTER TABLE mods + ALTER COLUMN body_url DROP NOT NULL; +ALTER TABLE versions + ADD COLUMN changelog varchar(65536) NOT NULL DEFAULT ''; + +INSERT INTO users ( + id, github_id, username, name, email, + avatar_url, bio, created +) +VALUES ( + 127155982985829, 10137, 'Ghost', NULL, NULL, + 'https://avatars2.githubusercontent.com/u/10137', 'A deleted user', NOW() + ); \ No newline at end of file diff --git a/apps/labrinth/migrations/20210118161307_remove-version-access.sql b/apps/labrinth/migrations/20210118161307_remove-version-access.sql new file mode 100644 index 000000000..869ead47b --- /dev/null +++ b/apps/labrinth/migrations/20210118161307_remove-version-access.sql @@ -0,0 +1,3 @@ +-- Add migration script here +ALTER TABLE versions +DROP COLUMN accepted; \ No newline at end of file diff --git a/apps/labrinth/migrations/20210129224854_dependency-types.sql b/apps/labrinth/migrations/20210129224854_dependency-types.sql new file mode 100644 index 000000000..5fd065aa7 --- /dev/null +++ b/apps/labrinth/migrations/20210129224854_dependency-types.sql @@ -0,0 +1,3 @@ +-- Add migration script here +ALTER TABLE dependencies + ADD COLUMN dependency_type varchar(255) NOT NULL DEFAULT 'required'; \ No newline at end of file diff --git a/apps/labrinth/migrations/20210201001429_reports.sql b/apps/labrinth/migrations/20210201001429_reports.sql new file mode 100644 index 000000000..1d539d872 --- /dev/null +++ b/apps/labrinth/migrations/20210201001429_reports.sql @@ -0,0 +1,24 @@ +CREATE TABLE report_types ( + id serial PRIMARY KEY, + name varchar(64) UNIQUE NOT NULL +); + +INSERT INTO report_types (name) VALUES ('spam'); +INSERT INTO report_types (name) VALUES ('copyright'); +INSERT INTO report_types (name) VALUES ('inappropriate'); +INSERT INTO report_types (name) VALUES ('malicious'); +INSERT INTO report_types (name) VALUES ('name-squatting'); + +CREATE TABLE reports ( + id bigint PRIMARY KEY, + report_type_id int REFERENCES report_types ON UPDATE CASCADE NOT NULL, + mod_id bigint REFERENCES mods ON UPDATE CASCADE, + version_id bigint REFERENCES versions ON UPDATE CASCADE, + user_id bigint REFERENCES users ON UPDATE CASCADE, + body varchar(65536) NOT NULL, + reporter bigint REFERENCES users ON UPDATE CASCADE NOT NULL, + created timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL +); + +ALTER TABLE game_versions + ADD COLUMN major boolean NOT NULL DEFAULT FALSE; \ No newline at end of file diff --git a/apps/labrinth/migrations/20210224174945_notifications.sql b/apps/labrinth/migrations/20210224174945_notifications.sql new file mode 100644 index 000000000..33f535405 --- /dev/null +++ b/apps/labrinth/migrations/20210224174945_notifications.sql @@ -0,0 +1,18 @@ +-- Add migration script here +CREATE TABLE notifications ( + id bigint PRIMARY KEY, + user_id bigint REFERENCES users NOT NULL, + title varchar(255) NOT NULL, + text varchar(2048) NOT NULL, + link varchar(2048) NOT NULL, + created timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + read boolean DEFAULT FALSE NOT NULL +); + +CREATE TABLE notifications_actions ( + id serial PRIMARY KEY, + notification_id bigint REFERENCES notifications NOT NULL, + title varchar(255) NOT NULL, + action_route varchar(2048) NOT NULL, + action_route_method varchar(32) NOT NULL +); \ No newline at end of file diff --git a/apps/labrinth/migrations/20210301041252_follows.sql b/apps/labrinth/migrations/20210301041252_follows.sql new file mode 100644 index 000000000..6c822465e --- /dev/null +++ b/apps/labrinth/migrations/20210301041252_follows.sql @@ -0,0 +1,9 @@ +CREATE TABLE mod_follows( + follower_id bigint REFERENCES users NOT NULL, + mod_id bigint REFERENCES mods NOT NULL, + created timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + PRIMARY KEY (follower_id, mod_id) +); + +ALTER TABLE mods + ADD COLUMN follows integer NOT NULL default 0; \ No newline at end of file diff --git a/apps/labrinth/migrations/20210509010206_project_types.sql b/apps/labrinth/migrations/20210509010206_project_types.sql new file mode 100644 index 000000000..d772f0ae2 --- /dev/null +++ b/apps/labrinth/migrations/20210509010206_project_types.sql @@ -0,0 +1,31 @@ +ALTER TABLE users ADD CONSTRAINT username_unique UNIQUE (username); + +CREATE TABLE project_types ( + id serial PRIMARY KEY, + name varchar(64) UNIQUE NOT NULL +); + +INSERT INTO project_types (name) VALUES ('mod'); +INSERT INTO project_types (name) VALUES ('modpack'); + +CREATE TABLE loaders_project_types ( + joining_loader_id int REFERENCES loaders ON UPDATE CASCADE NOT NULL, + joining_project_type_id int REFERENCES project_types ON UPDATE CASCADE NOT NULL, + PRIMARY KEY (joining_loader_id, joining_project_type_id) +); + +ALTER TABLE mods + ADD COLUMN project_type integer REFERENCES project_types NOT NULL default 1; + +ALTER TABLE categories + ADD COLUMN project_type integer REFERENCES project_types NOT NULL default 1, + ADD COLUMN icon varchar(20000) NOT NULL default ''; + +ALTER TABLE loaders + ADD COLUMN icon varchar(20000) NOT NULL default ''; + +ALTER TABLE mods + ALTER COLUMN project_type DROP DEFAULT; + +ALTER TABLE categories + ALTER COLUMN project_type DROP DEFAULT; diff --git a/apps/labrinth/migrations/20210611024943_archived-status-notifications-icon-rejection-reasons.sql b/apps/labrinth/migrations/20210611024943_archived-status-notifications-icon-rejection-reasons.sql new file mode 100644 index 000000000..f64f13278 --- /dev/null +++ b/apps/labrinth/migrations/20210611024943_archived-status-notifications-icon-rejection-reasons.sql @@ -0,0 +1,18 @@ +INSERT INTO statuses (status) VALUES ('archived'); + +ALTER TABLE notifications + ADD COLUMN type varchar(256); + +ALTER TABLE mods + ADD COLUMN rejection_reason varchar(2000), + ADD COLUMN rejection_body varchar(65536); + +DROP TABLE dependencies; + +CREATE TABLE dependencies ( + id serial PRIMARY KEY, + dependent_id bigint REFERENCES versions ON UPDATE CASCADE NOT NULL, + dependency_type varchar(255) NOT NULL, + dependency_id bigint REFERENCES versions ON UPDATE CASCADE, + mod_dependency_id bigint REFERENCES mods ON UPDATE CASCADE +); \ No newline at end of file diff --git a/apps/labrinth/migrations/20210718223710_gallery.sql b/apps/labrinth/migrations/20210718223710_gallery.sql new file mode 100644 index 000000000..f578fd370 --- /dev/null +++ b/apps/labrinth/migrations/20210718223710_gallery.sql @@ -0,0 +1,6 @@ +-- Add migration script here +CREATE TABLE mods_gallery ( + id serial PRIMARY KEY, + mod_id bigint REFERENCES mods ON UPDATE CASCADE NOT NULL, + image_url varchar(2048) NOT NULL +); \ No newline at end of file diff --git a/apps/labrinth/migrations/20210727160151_gallery_featuring_rejection_rename.sql b/apps/labrinth/migrations/20210727160151_gallery_featuring_rejection_rename.sql new file mode 100644 index 000000000..6945bdd2a --- /dev/null +++ b/apps/labrinth/migrations/20210727160151_gallery_featuring_rejection_rename.sql @@ -0,0 +1,7 @@ +ALTER TABLE mods + RENAME COLUMN rejection_reason TO moderation_message; +ALTER TABLE mods + RENAME COLUMN rejection_body TO moderation_message_body; + +ALTER TABLE mods_gallery + ADD COLUMN featured boolean default false; \ No newline at end of file diff --git a/apps/labrinth/migrations/20210805044459_more_gallery_info.sql b/apps/labrinth/migrations/20210805044459_more_gallery_info.sql new file mode 100644 index 000000000..5877186b9 --- /dev/null +++ b/apps/labrinth/migrations/20210805044459_more_gallery_info.sql @@ -0,0 +1,6 @@ +ALTER TABLE mods_gallery + ADD COLUMN title varchar(255), + ADD COLUMN description varchar(2048), + ADD COLUMN created timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL; + + diff --git a/apps/labrinth/migrations/20210820053031_version-optimization.sql b/apps/labrinth/migrations/20210820053031_version-optimization.sql new file mode 100644 index 000000000..ef08e48c8 --- /dev/null +++ b/apps/labrinth/migrations/20210820053031_version-optimization.sql @@ -0,0 +1,7 @@ +ALTER TABLE versions ADD COLUMN version_type varchar(255) default 'release' NOT NULL; + +UPDATE versions SET version_type = (SELECT rc.channel FROM release_channels rc WHERE rc.id = release_channel); + +ALTER TABLE versions DROP COLUMN release_channel, ALTER COLUMN version_type DROP DEFAULT; + +DROP TABLE release_channels; \ No newline at end of file diff --git a/apps/labrinth/migrations/20220210032959_remove-categories-unique.sql b/apps/labrinth/migrations/20220210032959_remove-categories-unique.sql new file mode 100644 index 000000000..dd2cd4faf --- /dev/null +++ b/apps/labrinth/migrations/20220210032959_remove-categories-unique.sql @@ -0,0 +1 @@ +ALTER TABLE categories DROP CONSTRAINT IF EXISTS categories_category_key; \ No newline at end of file diff --git a/apps/labrinth/migrations/20220220035037_remove_downloads_table.sql b/apps/labrinth/migrations/20220220035037_remove_downloads_table.sql new file mode 100644 index 000000000..80288f7b2 --- /dev/null +++ b/apps/labrinth/migrations/20220220035037_remove_downloads_table.sql @@ -0,0 +1 @@ +DROP TABLE downloads; diff --git a/apps/labrinth/migrations/20220329182356_file-sizes.sql b/apps/labrinth/migrations/20220329182356_file-sizes.sql new file mode 100644 index 000000000..efd345d1e --- /dev/null +++ b/apps/labrinth/migrations/20220329182356_file-sizes.sql @@ -0,0 +1 @@ +ALTER TABLE files ADD COLUMN size integer NOT NULL default 0; \ No newline at end of file diff --git a/apps/labrinth/migrations/20220526040434_dep-file-names.sql b/apps/labrinth/migrations/20220526040434_dep-file-names.sql new file mode 100644 index 000000000..f6b94187b --- /dev/null +++ b/apps/labrinth/migrations/20220526040434_dep-file-names.sql @@ -0,0 +1,2 @@ +ALTER TABLE dependencies +ADD COLUMN dependency_file_name varchar(1024) NULL; \ No newline at end of file diff --git a/apps/labrinth/migrations/20220725204351_more-project-data.sql b/apps/labrinth/migrations/20220725204351_more-project-data.sql new file mode 100644 index 000000000..e18ff7ca5 --- /dev/null +++ b/apps/labrinth/migrations/20220725204351_more-project-data.sql @@ -0,0 +1,37 @@ +-- Add migration script here +ALTER TABLE mods_categories + ADD COLUMN is_additional BOOLEAN NOT NULL DEFAULT FALSE; + +ALTER TABLE mods + ADD COLUMN approved timestamptz NULL; + +ALTER TABLE categories + ADD COLUMN header varchar(256) NOT NULL DEFAULT 'Categories'; + +UPDATE mods + SET approved = published + WHERE status = 1 OR status = 4; + +CREATE INDEX mods_slug + ON mods (slug); + +CREATE INDEX versions_mod_id + ON versions (mod_id); + +CREATE INDEX files_version_id + ON files (version_id); + +CREATE INDEX dependencies_dependent_id + ON dependencies (dependent_id); + +CREATE INDEX mods_gallery_mod_id + ON mods_gallery(mod_id); + +CREATE INDEX game_versions_versions_joining_version_id + ON game_versions_versions(joining_version_id); + +CREATE INDEX loaders_versions_version_id + ON loaders_versions(version_id); + +CREATE INDEX notifications_user_id + ON notifications(user_id); \ No newline at end of file diff --git a/apps/labrinth/migrations/20220801184215_banned-users.sql b/apps/labrinth/migrations/20220801184215_banned-users.sql new file mode 100644 index 000000000..2b8d327af --- /dev/null +++ b/apps/labrinth/migrations/20220801184215_banned-users.sql @@ -0,0 +1,3 @@ +CREATE TABLE banned_users ( + github_id bigint NOT NULL PRIMARY KEY UNIQUE +) diff --git a/apps/labrinth/migrations/20220902025606_initial-payouts.sql b/apps/labrinth/migrations/20220902025606_initial-payouts.sql new file mode 100644 index 000000000..9048c2740 --- /dev/null +++ b/apps/labrinth/migrations/20220902025606_initial-payouts.sql @@ -0,0 +1,7 @@ +ALTER TABLE team_members ADD COLUMN payouts_split REAL NOT NULL DEFAULT 0; + +UPDATE team_members +SET permissions = 1023, payouts_split = 100 +WHERE role = 'Owner'; + +ALTER TABLE users ADD COLUMN badges bigint default 0 NOT NULL; diff --git a/apps/labrinth/migrations/20220928044123_payouts-scheduling.sql b/apps/labrinth/migrations/20220928044123_payouts-scheduling.sql new file mode 100644 index 000000000..e2e0d64c1 --- /dev/null +++ b/apps/labrinth/migrations/20220928044123_payouts-scheduling.sql @@ -0,0 +1,29 @@ +ALTER TABLE team_members DROP COLUMN payouts_split; +ALTER TABLE team_members ADD COLUMN payouts_split numeric(96, 48) NOT NULL DEFAULT 0; + +UPDATE team_members +SET payouts_split = 100 +WHERE role = 'Owner'; + +CREATE TABLE payouts_values ( + id bigserial PRIMARY KEY, + user_id bigint REFERENCES users NOT NULL, + mod_id bigint REFERENCES mods NULL, + amount numeric(96, 48) NOT NULL, + created timestamptz NOT NULL, + claimed BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE INDEX payouts_values_user_id + ON payouts_values (user_id); + +CREATE INDEX payouts_values_mod_id + ON payouts_values (mod_id); + +CREATE INDEX payouts_values_created + ON payouts_values (created); + +ALTER TABLE users ADD COLUMN midas_expires timestamptz NULL; +ALTER TABLE users ADD COLUMN is_overdue BOOLEAN NULL; +ALTER TABLE users ADD COLUMN stripe_customer_id varchar(255) NULL; +ALTER TABLE users ADD COLUMN paypal_email varchar(128) NULL; \ No newline at end of file diff --git a/apps/labrinth/migrations/20221107171016_payouts-overhaul.sql b/apps/labrinth/migrations/20221107171016_payouts-overhaul.sql new file mode 100644 index 000000000..b79424623 --- /dev/null +++ b/apps/labrinth/migrations/20221107171016_payouts-overhaul.sql @@ -0,0 +1,24 @@ +ALTER TABLE users DROP COLUMN paypal_email; +ALTER TABLE payouts_values DROP COLUMN claimed; + +ALTER TABLE users ADD COLUMN payout_wallet varchar(128) NULL; +ALTER TABLE users ADD COLUMN payout_wallet_type varchar(128) NULL; +ALTER TABLE users ADD COLUMN payout_address varchar(128) NULL; +ALTER TABLE users ADD COLUMN balance numeric(96, 48) NOT NULL DEFAULT 0; + +UPDATE users +SET balance = COALESCE((SELECT SUM(T2.amount) FROM payouts_values T2 WHERE T2.user_id = users.id), 0, 0) +WHERE id > 1; + +CREATE TABLE historical_payouts ( + id bigserial PRIMARY KEY, + user_id bigint REFERENCES users NOT NULL, + amount numeric(96, 48) NOT NULL, + created timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + status varchar(128) NOT NULL +); + +DELETE FROM payouts_values WHERE amount = 0; + +CREATE INDEX historical_payouts_user_id + ON historical_payouts (user_id); \ No newline at end of file diff --git a/apps/labrinth/migrations/20221111163753_fix-precision.sql b/apps/labrinth/migrations/20221111163753_fix-precision.sql new file mode 100644 index 000000000..ecf04642b --- /dev/null +++ b/apps/labrinth/migrations/20221111163753_fix-precision.sql @@ -0,0 +1 @@ +ALTER TABLE users ALTER balance TYPE numeric(40, 30); \ No newline at end of file diff --git a/apps/labrinth/migrations/20221111202802_fix-precision-again.sql b/apps/labrinth/migrations/20221111202802_fix-precision-again.sql new file mode 100644 index 000000000..aacf856af --- /dev/null +++ b/apps/labrinth/migrations/20221111202802_fix-precision-again.sql @@ -0,0 +1,2 @@ +-- Add migration script here +ALTER TABLE users ALTER balance TYPE numeric(40, 20); \ No newline at end of file diff --git a/apps/labrinth/migrations/20221116200727_flame-anvil-integration.sql b/apps/labrinth/migrations/20221116200727_flame-anvil-integration.sql new file mode 100644 index 000000000..f5fe8759d --- /dev/null +++ b/apps/labrinth/migrations/20221116200727_flame-anvil-integration.sql @@ -0,0 +1,6 @@ +-- Add migration script here +ALTER TABLE payouts_values ALTER amount TYPE numeric(40, 20); + +ALTER TABLE users ADD COLUMN flame_anvil_key varchar(40) NULL; +ALTER TABLE mods ADD COLUMN flame_anvil_project integer NULL; +ALTER TABLE mods ADD COLUMN flame_anvil_user bigint REFERENCES users NULL; diff --git a/apps/labrinth/migrations/20221126222222_spdx-licenses.sql b/apps/labrinth/migrations/20221126222222_spdx-licenses.sql new file mode 100644 index 000000000..fd088c3f9 --- /dev/null +++ b/apps/labrinth/migrations/20221126222222_spdx-licenses.sql @@ -0,0 +1,27 @@ +ALTER TABLE mods ADD COLUMN license_new varchar(2048) DEFAULT 'LicenseRef-All-Rights-Reserved' NOT NULL; + +UPDATE mods SET license_new = licenses.short FROM licenses WHERE mods.license = licenses.id; + +UPDATE mods SET license_new = 'LicenseRef-Custom' WHERE license_new = 'custom'; +UPDATE mods SET license_new = 'LicenseRef-All-Rights-Reserved' WHERE license_new = 'arr'; +UPDATE mods SET license_new = 'Apache-2.0' WHERE license_new = 'apache'; +UPDATE mods SET license_new = 'BSD-2-Clause' WHERE license_new = 'bsd-2-clause'; +UPDATE mods SET license_new = 'BSD-3-Clause' WHERE license_new = 'bsd-3-clause' OR license_new = 'bsd'; +UPDATE mods SET license_new = 'CC0-1.0' WHERE license_new = 'cc0'; +UPDATE mods SET license_new = 'Unlicense' WHERE license_new = 'unlicense'; +UPDATE mods SET license_new = 'MIT' WHERE license_new = 'mit'; +UPDATE mods SET license_new = 'LGPL-3.0-only' WHERE license_new = 'lgpl-3'; +UPDATE mods SET license_new = 'LGPL-2.1-only' WHERE license_new = 'lgpl-2.1' OR license_new = 'lgpl'; +UPDATE mods SET license_new = 'MPL-2.0' WHERE license_new = 'mpl-2'; +UPDATE mods SET license_new = 'ISC' WHERE license_new = 'isc'; +UPDATE mods SET license_new = 'Zlib' WHERE license_new = 'zlib'; +UPDATE mods SET license_new = 'GPL-2.0-only' WHERE license_new = 'gpl-2'; +UPDATE mods SET license_new = 'GPL-3.0-only' WHERE license_new = 'gpl-3'; +UPDATE mods SET license_new = 'AGPL-3.0-only' WHERE license_new = 'agpl'; + +UPDATE mods SET license_url = NULL WHERE license_url LIKE 'https://cdn.modrinth.com/licenses/%'; + +ALTER TABLE mods DROP COLUMN license; +ALTER TABLE mods RENAME COLUMN license_new TO license; + +DROP TABLE licenses; diff --git a/apps/labrinth/migrations/20221129161609_status-types-changes.sql b/apps/labrinth/migrations/20221129161609_status-types-changes.sql new file mode 100644 index 000000000..ba9c2de67 --- /dev/null +++ b/apps/labrinth/migrations/20221129161609_status-types-changes.sql @@ -0,0 +1,23 @@ +-- Add migration script here +ALTER TABLE mods ADD COLUMN updated_status varchar(128) NULL; +ALTER TABLE mods ADD COLUMN requested_status varchar(128) NULL; + +UPDATE mods +SET updated_status = ( + SELECT s.status + FROM statuses s + WHERE s.id = mods.status +); + +ALTER TABLE mods +DROP COLUMN status; + +ALTER TABLE mods +RENAME COLUMN updated_status TO status; + +DROP TABLE statuses; + +ALTER TABLE mods ALTER COLUMN status SET NOT NULL; + +ALTER TABlE versions ADD COLUMN status varchar(128) NOT NULL DEFAULT 'listed'; +ALTER TABLE versions ADD COLUMN requested_status varchar(128) NULL; \ No newline at end of file diff --git a/apps/labrinth/migrations/20221206221021_webhook-sent.sql b/apps/labrinth/migrations/20221206221021_webhook-sent.sql new file mode 100644 index 000000000..0dbe4eaa1 --- /dev/null +++ b/apps/labrinth/migrations/20221206221021_webhook-sent.sql @@ -0,0 +1,9 @@ +-- Add migration script here +ALTER TABLE mods ADD COLUMN webhook_sent BOOL NOT NULL DEFAULT FALSE; + +UPDATE mods +SET webhook_sent = (status = 'approved'); + +UPDATE mods +SET status = 'withheld' +WHERE status = 'unlisted'; \ No newline at end of file diff --git a/apps/labrinth/migrations/20221217041358_ordering-galore.sql b/apps/labrinth/migrations/20221217041358_ordering-galore.sql new file mode 100644 index 000000000..0157729d7 --- /dev/null +++ b/apps/labrinth/migrations/20221217041358_ordering-galore.sql @@ -0,0 +1,3 @@ +ALTER TABLE categories ADD COLUMN ordering BIGINT NOT NULL DEFAULT 0; +ALTER TABLE mods_gallery ADD COLUMN ordering BIGINT NOT NULL DEFAULT 0; +ALTER TABLE team_members ADD COLUMN ordering BIGINT NOT NULL DEFAULT 0; \ No newline at end of file diff --git a/apps/labrinth/migrations/20221217215337_drop-body_url-changelog_url.sql b/apps/labrinth/migrations/20221217215337_drop-body_url-changelog_url.sql new file mode 100644 index 000000000..fd0365171 --- /dev/null +++ b/apps/labrinth/migrations/20221217215337_drop-body_url-changelog_url.sql @@ -0,0 +1,2 @@ +ALTER TABLE mods DROP COLUMN body_url; +ALTER TABLE versions DROP COLUMN changelog_url; \ No newline at end of file diff --git a/apps/labrinth/migrations/20221223192812_file-labels.sql b/apps/labrinth/migrations/20221223192812_file-labels.sql new file mode 100644 index 000000000..7ff6c3114 --- /dev/null +++ b/apps/labrinth/migrations/20221223192812_file-labels.sql @@ -0,0 +1,2 @@ +-- Add migration script here +ALTER TABLE files ADD COLUMN file_type varchar(128) NULL; \ No newline at end of file diff --git a/apps/labrinth/migrations/20221227010515_project-colors.sql b/apps/labrinth/migrations/20221227010515_project-colors.sql new file mode 100644 index 000000000..8928d6565 --- /dev/null +++ b/apps/labrinth/migrations/20221227010515_project-colors.sql @@ -0,0 +1,2 @@ +-- Add migration script here +ALTER TABLE mods ADD COLUMN color integer NULL; \ No newline at end of file diff --git a/apps/labrinth/migrations/20230104214503_random-projects.sql b/apps/labrinth/migrations/20230104214503_random-projects.sql new file mode 100644 index 000000000..b99325a22 --- /dev/null +++ b/apps/labrinth/migrations/20230104214503_random-projects.sql @@ -0,0 +1,4 @@ +-- Add migration script here +DROP EXTENSION IF EXISTS tsm_system_rows; + +CREATE EXTENSION tsm_system_rows; diff --git a/apps/labrinth/migrations/20230127233123_loader-gv-mod.sql b/apps/labrinth/migrations/20230127233123_loader-gv-mod.sql new file mode 100644 index 000000000..85915c924 --- /dev/null +++ b/apps/labrinth/migrations/20230127233123_loader-gv-mod.sql @@ -0,0 +1,21 @@ +-- Add migration script here +ALTER TABLE mods ADD COLUMN loaders varchar(255)[] NOT NULL default array[]::varchar[]; +ALTER TABLE mods ADD COLUMN game_versions varchar(255)[] NOT NULL default array[]::varchar[]; + +UPDATE mods +SET loaders = ( + SELECT COALESCE(ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null), array[]::varchar[]) + FROM versions v + INNER JOIN loaders_versions lv ON lv.version_id = v.id + INNER JOIN loaders l on lv.loader_id = l.id + WHERE v.mod_id = mods.id +); + +UPDATE mods +SET game_versions = ( + SELECT COALESCE(ARRAY_AGG(DISTINCT gv.version) filter (where gv.version is not null), array[]::varchar[]) + FROM versions v + INNER JOIN game_versions_versions gvv ON v.id = gvv.joining_version_id + INNER JOIN game_versions gv on gvv.game_version_id = gv.id + WHERE v.mod_id = mods.id +); diff --git a/apps/labrinth/migrations/20230221212958_queue-date.sql b/apps/labrinth/migrations/20230221212958_queue-date.sql new file mode 100644 index 000000000..df051f33d --- /dev/null +++ b/apps/labrinth/migrations/20230221212958_queue-date.sql @@ -0,0 +1,2 @@ +-- Add migration script here +ALTER TABLE mods ADD COLUMN queued timestamptz NULL \ No newline at end of file diff --git a/apps/labrinth/migrations/20230324202117_messaging.sql b/apps/labrinth/migrations/20230324202117_messaging.sql new file mode 100644 index 000000000..250667ea7 --- /dev/null +++ b/apps/labrinth/migrations/20230324202117_messaging.sql @@ -0,0 +1,32 @@ +-- Add migration script here + +-- Add route for users to see their own reports + +CREATE TABLE threads ( + id bigint PRIMARY KEY, + -- can be either "report", "project", or "direct_message". direct message is unused for now + thread_type VARCHAR(64) NOT NULL +); + +CREATE TABLE threads_messages ( + id bigint PRIMARY KEY, + thread_id bigint REFERENCES threads ON UPDATE CASCADE NOT NULL, + -- If this is null, it's a system message + author_id bigint REFERENCES users ON UPDATE CASCADE NULL, + body jsonb NOT NULL, + created timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + show_in_mod_inbox BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE TABLE threads_members ( + thread_id bigint REFERENCES threads ON UPDATE CASCADE NOT NULL, + user_id bigint REFERENCES users ON UPDATE CASCADE NOT NULL, + PRIMARY KEY (thread_id, user_id) +); + +ALTER TABLE reports + ADD COLUMN closed boolean NOT NULL DEFAULT FALSE; +ALTER TABLE reports + ADD COLUMN thread_id bigint references threads ON UPDATE CASCADE; +ALTER TABLE mods + ADD COLUMN thread_id bigint references threads ON UPDATE CASCADE; diff --git a/apps/labrinth/migrations/20230414203933_threads-fix.sql b/apps/labrinth/migrations/20230414203933_threads-fix.sql new file mode 100644 index 000000000..20acc02d6 --- /dev/null +++ b/apps/labrinth/migrations/20230414203933_threads-fix.sql @@ -0,0 +1,10 @@ +ALTER TABLE threads ADD COLUMN show_in_mod_inbox BOOLEAN NOT NULL DEFAULT FALSE; +ALTER TABLE threads_messages DROP COLUMN show_in_mod_inbox; + +ALTER TABLE notifications ADD COLUMN body jsonb NULL; +ALTER TABLE notifications ALTER COLUMN title DROP NOT NULL; +ALTER TABLE notifications ALTER COLUMN text DROP NOT NULL; +ALTER TABLE notifications ALTER COLUMN link DROP NOT NULL; + +ALTER TABLE threads ADD COLUMN report_id bigint REFERENCES reports ON UPDATE CASCADE; +ALTER TABLE threads ADD COLUMN project_id bigint REFERENCES mods ON UPDATE CASCADE; \ No newline at end of file diff --git a/apps/labrinth/migrations/20230416033024_deps-project-mandatory.sql b/apps/labrinth/migrations/20230416033024_deps-project-mandatory.sql new file mode 100644 index 000000000..d7534f5e2 --- /dev/null +++ b/apps/labrinth/migrations/20230416033024_deps-project-mandatory.sql @@ -0,0 +1,10 @@ +UPDATE dependencies AS d +SET mod_dependency_id = v.mod_id +FROM versions AS v +WHERE v.id = d.dependency_id; + +ALTER TABLE users DROP COLUMN flame_anvil_key; +ALTER TABLE mods DROP COLUMN flame_anvil_project; +ALTER TABLE mods DROP COLUMN flame_anvil_user; + +ALTER TABLE mods ADD COLUMN monetization_status varchar(64) NOT NULL default 'monetized'; \ No newline at end of file diff --git a/apps/labrinth/migrations/20230421174120_remove-threads-ref.sql b/apps/labrinth/migrations/20230421174120_remove-threads-ref.sql new file mode 100644 index 000000000..13eeab8bb --- /dev/null +++ b/apps/labrinth/migrations/20230421174120_remove-threads-ref.sql @@ -0,0 +1,2 @@ +ALTER TABLE threads DROP COLUMN project_id; +ALTER TABLE threads DROP COLUMN report_id; \ No newline at end of file diff --git a/apps/labrinth/migrations/20230502141522_minos-support.sql b/apps/labrinth/migrations/20230502141522_minos-support.sql new file mode 100644 index 000000000..132de5743 --- /dev/null +++ b/apps/labrinth/migrations/20230502141522_minos-support.sql @@ -0,0 +1,16 @@ +-- No longer have banned users in Labrinth +DROP TABLE banned_users; + +-- Initialize kratos_id +ALTER TABLE users ADD COLUMN kratos_id varchar(40) UNIQUE; + +-- Add pats table +CREATE TABLE pats ( + id BIGINT PRIMARY KEY, + name VARCHAR(255), + user_id BIGINT NOT NULL REFERENCES users(id), + access_token VARCHAR(64) NOT NULL, + scope BIGINT NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMP NOT NULL +); \ No newline at end of file diff --git a/apps/labrinth/migrations/20230628180115_kill-ory.sql b/apps/labrinth/migrations/20230628180115_kill-ory.sql new file mode 100644 index 000000000..920ab9e1a --- /dev/null +++ b/apps/labrinth/migrations/20230628180115_kill-ory.sql @@ -0,0 +1,48 @@ +ALTER TABLE users DROP COLUMN kratos_id; + +ALTER TABLE states ADD COLUMN provider varchar(64) NOT NULL default 'github'; + +ALTER TABLE users ADD COLUMN discord_id bigint; +ALTER TABLE users ADD COLUMN gitlab_id bigint; +ALTER TABLE users ADD COLUMN google_id varchar(256); +ALTER TABLE users ADD COLUMN steam_id bigint; +ALTER TABLE users ADD COLUMN microsoft_id varchar(256); + +CREATE INDEX users_discord_id + ON users (discord_id); +CREATE INDEX users_gitlab_id + ON users (gitlab_id); +CREATE INDEX users_google_id + ON users (google_id); +CREATE INDEX users_steam_id + ON users (steam_id); +CREATE INDEX users_microsoft_id + ON users (microsoft_id); + +ALTER TABLE users ALTER COLUMN avatar_url TYPE varchar(1024); +ALTER TABLE users ADD COLUMN password TEXT NULL; +ALTER TABLE users ADD COLUMN email_verified BOOLEAN NOT NULL DEFAULT FALSE; + +CREATE TABLE sessions ( + id bigint NOT NULL PRIMARY KEY, + session varchar(64) NOT NULL UNIQUE, + user_id BIGINT NOT NULL REFERENCES users(id), + created timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + last_login timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + expires timestamptz DEFAULT CURRENT_TIMESTAMP + interval '14 days' NOT NULL, + refresh_expires timestamptz DEFAULT CURRENT_TIMESTAMP + interval '60 days' NOT NULL, + + city varchar(256) NULL, + country varchar(256) NULL, + ip varchar(512) NOT NULL, + + os varchar(256) NULL, + platform varchar(256) NULL, + user_agent varchar(1024) NOT NULL +); + +CREATE INDEX sessions_user_id + ON sessions (user_id); + +ALTER TABLE mods DROP COLUMN game_versions; +ALTER TABLE mods DROP COLUMN loaders; diff --git a/apps/labrinth/migrations/20230710034250_flows.sql b/apps/labrinth/migrations/20230710034250_flows.sql new file mode 100644 index 000000000..6fb0f1067 --- /dev/null +++ b/apps/labrinth/migrations/20230710034250_flows.sql @@ -0,0 +1,44 @@ +CREATE INDEX sessions_session + ON sessions (session); + +CREATE TABLE flows ( + id bigint NOT NULL PRIMARY KEY, + flow varchar(64) NOT NULL UNIQUE, + user_id BIGINT NOT NULL REFERENCES users(id), + expires timestamptz NOT NULL, + flow_type varchar(64) NOT NULL +); + +CREATE INDEX flows_flow + ON flows (flow); + +DROP TABLE pats; + +CREATE TABLE pats ( + id BIGINT PRIMARY KEY, + name VARCHAR(255) NOT NULL, + user_id BIGINT NOT NULL REFERENCES users(id), + access_token VARCHAR(64) NOT NULL UNIQUE, + scopes BIGINT NOT NULL, + created timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires timestamptz NOT NULL, + last_used timestamptz NULL +); + +CREATE INDEX pats_user_id + ON pats (user_id); + +CREATE INDEX pats_access_token + ON pats (access_token); + +ALTER TABLE mods DROP COLUMN thread_id; +ALTER TABLE reports DROP COLUMN thread_id; + +DELETE FROM threads_members; +DELETE FROM threads_messages; +DELETE FROM threads; + +ALTER TABLE threads + ADD COLUMN report_id bigint references reports ON UPDATE CASCADE NULL; +ALTER TABLE threads + ADD COLUMN mod_id bigint references mods ON UPDATE CASCADE NULL; \ No newline at end of file diff --git a/apps/labrinth/migrations/20230711004131_2fa.sql b/apps/labrinth/migrations/20230711004131_2fa.sql new file mode 100644 index 000000000..222959290 --- /dev/null +++ b/apps/labrinth/migrations/20230711004131_2fa.sql @@ -0,0 +1,15 @@ +-- Add migration script here +ALTER TABLE states ADD COLUMN user_id bigint references users ON UPDATE CASCADE NULL; + +ALTER TABLE users ADD COLUMN totp_secret varchar(24) null; + +ALTER TABLE users ADD CONSTRAINT email_unique UNIQUE (email); + +DROP TABLE flows; +DROP TABLE states; + +CREATE TABLE user_backup_codes ( + user_id BIGINT NOT NULL REFERENCES users(id), + code BIGINT NOT NULL, + PRIMARY KEY (user_id, code) +); \ No newline at end of file diff --git a/apps/labrinth/migrations/20230714235551_fix-2fa-type.sql b/apps/labrinth/migrations/20230714235551_fix-2fa-type.sql new file mode 100644 index 000000000..36144f3f3 --- /dev/null +++ b/apps/labrinth/migrations/20230714235551_fix-2fa-type.sql @@ -0,0 +1 @@ +ALTER TABLE users ALTER COLUMN totp_secret TYPE varchar(32); \ No newline at end of file diff --git a/apps/labrinth/migrations/20230808043323_threads-index.sql b/apps/labrinth/migrations/20230808043323_threads-index.sql new file mode 100644 index 000000000..130e5619d --- /dev/null +++ b/apps/labrinth/migrations/20230808043323_threads-index.sql @@ -0,0 +1,4 @@ +CREATE INDEX threads_report_id + ON threads (report_id); +CREATE INDEX threads_mod_id + ON threads (mod_id); \ No newline at end of file diff --git a/apps/labrinth/migrations/20230808162652_gv-loader-fixes.sql b/apps/labrinth/migrations/20230808162652_gv-loader-fixes.sql new file mode 100644 index 000000000..d5b477852 --- /dev/null +++ b/apps/labrinth/migrations/20230808162652_gv-loader-fixes.sql @@ -0,0 +1,20 @@ +ALTER TABLE mods ADD COLUMN loaders varchar(255)[] NOT NULL default array[]::varchar[]; +ALTER TABLE mods ADD COLUMN game_versions varchar(255)[] NOT NULL default array[]::varchar[]; + +UPDATE mods +SET loaders = ( + SELECT COALESCE(ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null), array[]::varchar[]) + FROM versions v + INNER JOIN loaders_versions lv ON lv.version_id = v.id + INNER JOIN loaders l on lv.loader_id = l.id + WHERE v.mod_id = mods.id +); + +UPDATE mods +SET game_versions = ( + SELECT COALESCE(ARRAY_AGG(DISTINCT gv.version) filter (where gv.version is not null), array[]::varchar[]) + FROM versions v + INNER JOIN game_versions_versions gvv ON v.id = gvv.joining_version_id + INNER JOIN game_versions gv on gvv.game_version_id = gv.id + WHERE v.mod_id = mods.id +); diff --git a/apps/labrinth/migrations/20230816085700_collections_and_more.sql b/apps/labrinth/migrations/20230816085700_collections_and_more.sql new file mode 100644 index 000000000..81efecdae --- /dev/null +++ b/apps/labrinth/migrations/20230816085700_collections_and_more.sql @@ -0,0 +1,37 @@ +CREATE TABLE collections ( + id bigint PRIMARY KEY, + title varchar(255) NOT NULL, + description varchar(2048) NOT NULL, + user_id bigint REFERENCES users NOT NULL, + created timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + + status varchar(64) NOT NULL DEFAULT 'listed', + + icon_url varchar(2048) NULL, + color integer NULL +); + +CREATE TABLE collections_mods ( + collection_id bigint REFERENCES collections NOT NULL, + mod_id bigint REFERENCES mods NOT NULL, + PRIMARY KEY (collection_id, mod_id) +); + +CREATE TABLE uploaded_images ( + id bigint PRIMARY KEY, + url varchar(2048) NOT NULL, + size integer NOT NULL, + created timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP, + owner_id bigint REFERENCES users NOT NULL, + + -- Type of contextual association + context varchar(64) NOT NULL, -- project, version, thread_message, report, etc. + + -- Only one of these should be set (based on 'context') + mod_id bigint NULL REFERENCES mods, + version_id bigint NULL REFERENCES versions, + thread_message_id bigint NULL REFERENCES threads_messages, + report_id bigint NULL REFERENCES reports + +); \ No newline at end of file diff --git a/apps/labrinth/migrations/20230913024611_organizations.sql b/apps/labrinth/migrations/20230913024611_organizations.sql new file mode 100644 index 000000000..b6e7ae9cf --- /dev/null +++ b/apps/labrinth/migrations/20230913024611_organizations.sql @@ -0,0 +1,17 @@ +CREATE TABLE organizations ( + id bigint PRIMARY KEY, + title varchar(255) NOT NULL, -- also slug + description text NOT NULL, + created_at timestamp NOT NULL DEFAULT now(), + updated_at timestamp NOT NULL DEFAULT now(), + team_id bigint NOT NULL REFERENCES teams(id) ON UPDATE CASCADE, + + icon_url varchar(255) NULL, + color integer NULL +); + +ALTER TABLE mods ADD COLUMN organization_id bigint NULL REFERENCES organizations(id) ON DELETE SET NULL; + +-- Organization permissions only apply to teams that are associated to an organization +-- If they do, 'permissions' is considered the fallback permissions for projects in the organization +ALTER TABLE team_members ADD COLUMN organization_permissions bigint NULL; diff --git a/apps/labrinth/migrations/20231005230721_dynamic-fields.sql b/apps/labrinth/migrations/20231005230721_dynamic-fields.sql new file mode 100644 index 000000000..5956802d5 --- /dev/null +++ b/apps/labrinth/migrations/20231005230721_dynamic-fields.sql @@ -0,0 +1,180 @@ +CREATE TABLE games ( + id int PRIMARY KEY, -- Only used in db + name varchar(64), + CONSTRAINT unique_game_name UNIQUE (name) +); +INSERT INTO games(id, name) VALUES (1, 'minecraft-java'); +INSERT INTO games(id, name) VALUES (2, 'minecraft-bedrock'); + +ALTER TABLE loaders ADD CONSTRAINT unique_loader_name UNIQUE (loader); + +CREATE TABLE loader_field_enums ( + id serial PRIMARY KEY, + enum_name varchar(64) NOT NULL, + ordering int NULL, + hidable BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE TABLE loader_field_enum_values ( + id serial PRIMARY KEY, + enum_id integer REFERENCES loader_field_enums NOT NULL, + value varchar(64) NOT NULL, + ordering int NULL, + created timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + -- metadata is json of all the extra data for this enum value + metadata jsonb NULL, + + original_id integer, -- This is for mapping only- it is dropped before the end of the migration + + CONSTRAINT unique_variant_per_enum UNIQUE (enum_id, value) + +); + +CREATE TABLE loader_fields ( + id serial PRIMARY KEY, + field varchar(64) UNIQUE NOT NULL, + -- "integer", "text", "enum", "bool", + -- "array_integer", "array_text", "array_enum", "array_bool" + field_type varchar(64) NOT NULL, + -- only for enum + enum_type integer REFERENCES loader_field_enums NULL, + optional BOOLEAN NOT NULL DEFAULT true, + -- for int- min/max val, for text- min len, for enum- min items, for bool- nothing + min_val integer NULL, + max_val integer NULL +); + +CREATE TABLE loader_fields_loaders ( + loader_id integer REFERENCES loaders NOT NULL, + loader_field_id integer REFERENCES loader_fields NOT NULL, + CONSTRAINT unique_loader_field UNIQUE (loader_id, loader_field_id) +); + +ALTER TABLE loaders ADD COLUMN hidable boolean NOT NULL default false; + +CREATE TABLE version_fields ( + version_id bigint REFERENCES versions NOT NULL, + field_id integer REFERENCES loader_fields NOT NULL, + -- for int/bool values + int_value integer NULL, + enum_value integer REFERENCES loader_field_enum_values NULL, + string_value text NULL +); + +-- Convert side_types +INSERT INTO loader_field_enums (id, enum_name, hidable) VALUES (1, 'side_types', true); +INSERT INTO loader_field_enum_values (original_id, enum_id, value) SELECT id, 1, name FROM side_types st; + +INSERT INTO loader_fields (field, field_type, enum_type, optional, min_val, max_val) SELECT 'client_side', 'enum', 1, false, 1, 1; +INSERT INTO loader_fields ( field, field_type, enum_type, optional, min_val, max_val) SELECT 'server_side', 'enum', 1, false, 1, 1; + +INSERT INTO loader_fields_loaders (loader_id, loader_field_id) SELECT l.id, lf.id FROM loaders l CROSS JOIN loader_fields lf WHERE lf.field = 'client_side' AND l.loader = ANY( ARRAY['forge', 'fabric', 'quilt', 'modloader','rift','liteloader', 'neoforge']); +INSERT INTO loader_fields_loaders (loader_id, loader_field_id) SELECT l.id, lf.id FROM loaders l CROSS JOIN loader_fields lf WHERE lf.field = 'server_side' AND l.loader = ANY( ARRAY['forge', 'fabric', 'quilt', 'modloader','rift','liteloader', 'neoforge']); + +INSERT INTO version_fields (version_id, field_id, enum_value) +SELECT v.id, lf.id, lfev.id -- Note: bug fix/edited 2023-11-27 +FROM versions v +INNER JOIN mods m ON v.mod_id = m.id +INNER JOIN loader_field_enum_values lfev ON m.client_side = lfev.original_id +CROSS JOIN loader_fields lf +WHERE client_side IS NOT NULL AND lfev.enum_id = 1 AND lf.field = 'client_side' AND NOT (ARRAY['vanilla', 'minecraft', 'optifine', 'iris', 'canvas', 'bukkit', 'folia', 'paper', 'purpur', 'spigot', 'sponge', 'datapack', 'bungeecord', 'velocity', 'waterfall'] @> m.loaders::text[]);; + +INSERT INTO version_fields (version_id, field_id, enum_value) +SELECT v.id, lf.id, lfev.id -- Note: bug fix/edited 2023-11-27 +FROM versions v +INNER JOIN mods m ON v.mod_id = m.id +INNER JOIN loader_field_enum_values lfev ON m.server_side = lfev.original_id +CROSS JOIN loader_fields lf +WHERE server_side IS NOT NULL AND lfev.enum_id = 1 AND lf.field = 'server_side' AND NOT (ARRAY['vanilla', 'minecraft', 'optifine', 'iris', 'canvas', 'bukkit', 'folia', 'paper', 'purpur', 'spigot', 'sponge', 'datapack', 'bungeecord', 'velocity', 'waterfall'] @> m.loaders::text[]); + +ALTER TABLE mods DROP COLUMN client_side; +ALTER TABLE mods DROP COLUMN server_side; +DROP TABLE side_types; + +-- Convert game_versions +INSERT INTO loader_field_enums (id, enum_name, hidable) VALUES (2, 'game_versions', true); +INSERT INTO loader_field_enum_values (original_id, enum_id, value, created, metadata) +SELECT id, 2, version, created, json_build_object('type', type, 'major', major) FROM game_versions; + +INSERT INTO loader_fields (field, field_type, enum_type, optional, min_val) VALUES('game_versions', 'array_enum', 2, false, 0); +INSERT INTO loader_fields_loaders (loader_id, loader_field_id) SELECT l.id, lf.id FROM loaders l CROSS JOIN loader_fields lf WHERE lf.field = 'game_versions' AND l.loader = ANY( ARRAY['forge', 'fabric', 'quilt', 'modloader','rift','liteloader', 'neoforge']); + +-- remove dangling game versions +DELETE FROM game_versions_versions +WHERE joining_version_id NOT IN (SELECT id FROM versions); + +INSERT INTO version_fields(version_id, field_id, enum_value) +SELECT gvv.joining_version_id, lf.id, lfev.id +FROM game_versions_versions gvv INNER JOIN loader_field_enum_values lfev ON gvv.game_version_id = lfev.original_id +CROSS JOIN loader_fields lf +WHERE lf.field = 'game_versions' AND lfev.enum_id = 2; + +ALTER TABLE mods DROP COLUMN loaders; +ALTER TABLE mods DROP COLUMN game_versions; +DROP TABLE game_versions_versions; +DROP TABLE game_versions; + +-- Convert project types +-- we are creating a new loader type- 'mrpack'- for minecraft modpacks +SELECT setval('loaders_id_seq', (SELECT MAX(id) FROM loaders) + 1, false); +INSERT INTO loaders (loader) VALUES ('mrpack'); + +-- For the loader 'mrpack', we create loader fields for every loader +-- That way we keep information like "this modpack is a fabric modpack" +INSERT INTO loader_field_enums (id, enum_name, hidable) VALUES (3, 'mrpack_loaders', true); +INSERT INTO loader_field_enum_values (original_id, enum_id, value) SELECT id, 3, loader FROM loaders WHERE loader != 'mrpack'; +INSERT INTO loader_fields (field, field_type, enum_type, optional, min_val) VALUES('mrpack_loaders', 'array_enum', 3, false, 0); +INSERT INTO loader_fields_loaders (loader_id, loader_field_id) +SELECT l.id, lf.id FROM loaders l CROSS JOIN loader_fields lf WHERE lf.field = 'mrpack_loaders' AND l.loader = 'mrpack'; + +INSERT INTO version_fields(version_id, field_id, enum_value) +SELECT v.id, lf.id, lfev.id +FROM versions v +INNER JOIN mods m ON v.mod_id = m.id +INNER JOIN loaders_versions lv ON v.id = lv.version_id +INNER JOIN loaders l ON lv.loader_id = l.id +CROSS JOIN loader_fields lf +LEFT JOIN loader_field_enum_values lfev ON lf.enum_type = lfev.enum_id AND lfev.original_id = l.id +WHERE m.project_type = (SELECT id FROM project_types WHERE name = 'modpack') AND lf.field = 'mrpack_loaders'; + +INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) SELECT DISTINCT l.id, pt.id FROM loaders l CROSS JOIN project_types pt WHERE pt.name = 'modpack' AND l.loader = 'mrpack'; + +-- Set those versions to mrpack as their version +INSERT INTO loaders_versions (version_id, loader_id) +SELECT DISTINCT vf.version_id, l.id +FROM version_fields vf +LEFT JOIN loader_fields lf ON lf.id = vf.field_id +CROSS JOIN loaders l +WHERE lf.field = 'mrpack_loaders' +AND l.loader = 'mrpack' +ON CONFLICT DO NOTHING; + +-- Delete the old versions that had mrpack added to them +DELETE FROM loaders_versions lv +WHERE lv.loader_id != (SELECT id FROM loaders WHERE loader = 'mrpack') +AND lv.version_id IN ( + SELECT version_id + FROM loaders_versions + WHERE loader_id = (SELECT id FROM loaders WHERE loader = 'mrpack') +); + + +--- Non-mrpack loaders no longer support modpacks +DELETE FROM loaders_project_types WHERE joining_loader_id != (SELECT id FROM loaders WHERE loader = 'mrpack') AND joining_project_type_id = (SELECT id FROM project_types WHERE name = 'modpack'); + +CREATE TABLE loaders_project_types_games ( + loader_id integer REFERENCES loaders NOT NULL, + project_type_id integer REFERENCES project_types NOT NULL, + game_id integer REFERENCES games NOT NULL, + PRIMARY KEY (loader_id, project_type_id, game_id) +); + +-- all past loader_project_types are minecraft-java as the only game before this migration is minecraft-java +INSERT INTO loaders_project_types_games (loader_id, project_type_id, game_id) SELECT joining_loader_id, joining_project_type_id, 1 FROM loaders_project_types; + +-- Now that loaders are inferred, we can drop the project_type column from mods +ALTER TABLE mods DROP COLUMN project_type; + + +-- Drop original_id columns +ALTER TABLE loader_field_enum_values DROP COLUMN original_id; \ No newline at end of file diff --git a/apps/labrinth/migrations/20231016190056_oauth_provider.sql b/apps/labrinth/migrations/20231016190056_oauth_provider.sql new file mode 100644 index 000000000..a6a9406e6 --- /dev/null +++ b/apps/labrinth/migrations/20231016190056_oauth_provider.sql @@ -0,0 +1,34 @@ +CREATE TABLE oauth_clients ( + id bigint PRIMARY KEY, + name text NOT NULL, + icon_url text NULL, + max_scopes bigint NOT NULL, + secret_hash text NOT NULL, + created timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + created_by bigint NOT NULL REFERENCES users(id) +); +CREATE TABLE oauth_client_redirect_uris ( + id bigint PRIMARY KEY, + client_id bigint NOT NULL REFERENCES oauth_clients (id) ON DELETE CASCADE, + uri text +); +CREATE TABLE oauth_client_authorizations ( + id bigint PRIMARY KEY, + client_id bigint NOT NULL REFERENCES oauth_clients (id) ON DELETE CASCADE, + user_id bigint NOT NULL REFERENCES users (id) ON DELETE CASCADE, + scopes bigint NOT NULL, + created timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (client_id, user_id) +); +CREATE TABLE oauth_access_tokens ( + id bigint PRIMARY KEY, + authorization_id bigint NOT NULL REFERENCES oauth_client_authorizations(id) ON DELETE CASCADE, + token_hash text NOT NULL UNIQUE, + scopes bigint NOT NULL, + created timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP + interval '14 days', + last_used timestamptz NULL +); +CREATE INDEX oauth_client_creator ON oauth_clients(created_by); +CREATE INDEX oauth_redirect_client ON oauth_client_redirect_uris(client_id); +CREATE INDEX oauth_access_token_hash ON oauth_access_tokens(token_hash); \ No newline at end of file diff --git a/apps/labrinth/migrations/20231027195838_version_ordering.sql b/apps/labrinth/migrations/20231027195838_version_ordering.sql new file mode 100644 index 000000000..873f2c1bb --- /dev/null +++ b/apps/labrinth/migrations/20231027195838_version_ordering.sql @@ -0,0 +1 @@ +ALTER TABLE versions ADD COLUMN ordering int NULL; \ No newline at end of file diff --git a/apps/labrinth/migrations/20231110010322_adds_game_version_minmax.sql b/apps/labrinth/migrations/20231110010322_adds_game_version_minmax.sql new file mode 100644 index 000000000..1fb438249 --- /dev/null +++ b/apps/labrinth/migrations/20231110010322_adds_game_version_minmax.sql @@ -0,0 +1 @@ +UPDATE loader_fields SET min_val = 1 WHERE field = 'game_versions'; \ No newline at end of file diff --git a/apps/labrinth/migrations/20231113104902_games_metadata.sql b/apps/labrinth/migrations/20231113104902_games_metadata.sql new file mode 100644 index 000000000..0dfed0684 --- /dev/null +++ b/apps/labrinth/migrations/20231113104902_games_metadata.sql @@ -0,0 +1,9 @@ +ALTER TABLE games ADD COLUMN slug varchar(64); +ALTER TABLE games ADD COLUMN icon_url varchar(2048) NULL; +ALTER TABLE games ADD COLUMN banner_url varchar(2048) NULL; + +-- 'minecraft-java' and 'minecraft-bedrock' are the only games- both slug and names (names are for translations) +UPDATE games SET slug = name; +ALTER TABLE games ALTER COLUMN slug SET NOT NULL; +ALTER TABLE games ALTER COLUMN name SET NOT NULL; +ALTER TABLE games ADD CONSTRAINT unique_game_slug UNIQUE (slug); \ No newline at end of file diff --git a/apps/labrinth/migrations/20231114175920_new-payment-methods.sql b/apps/labrinth/migrations/20231114175920_new-payment-methods.sql new file mode 100644 index 000000000..6e852e3f4 --- /dev/null +++ b/apps/labrinth/migrations/20231114175920_new-payment-methods.sql @@ -0,0 +1,30 @@ +ALTER TABLE users DROP COLUMN IF EXISTS paypal_email; + +ALTER TABLE users + ADD COLUMN paypal_country text NULL, + ADD COLUMN paypal_email text NULL, + ADD COLUMN paypal_id text NULL, + ADD COLUMN venmo_handle text NULL, + + DROP COLUMN midas_expires, + DROP COLUMN is_overdue, + DROP COLUMN stripe_customer_id, + DROP COLUMN payout_wallet, + DROP COLUMN payout_wallet_type, + DROP COLUMN payout_address; + +ALTER TABLE historical_payouts + RENAME TO payouts; + +ALTER TABLE payouts + ADD COLUMN method text NULL, + ADD COLUMN method_address text NULL, + ADD COLUMN platform_id text NULL, + ADD COLUMN fee numeric(40, 20) NULL, + ALTER COLUMN id TYPE bigint, + ALTER COLUMN id DROP DEFAULT; + +UPDATE payouts +SET status = 'success'; + +DROP SEQUENCE IF EXISTS historical_payouts_id_seq; diff --git a/apps/labrinth/migrations/20231115105022_plugins_datapacks_v3.sql b/apps/labrinth/migrations/20231115105022_plugins_datapacks_v3.sql new file mode 100644 index 000000000..27dadc468 --- /dev/null +++ b/apps/labrinth/migrations/20231115105022_plugins_datapacks_v3.sql @@ -0,0 +1,46 @@ +ALTER TABLE loaders ADD COLUMN metadata jsonb NOT NULL DEFAULT '{}'::jsonb; + +-- Set 'platform' to 'true' for all plugin loaders +-- From knossos v2 + -- pluginLoaders: ['bukkit', 'spigot', 'paper', 'purpur', 'sponge', 'folia'], + -- pluginPlatformLoaders: ['bungeecord', 'waterfall', 'velocity'], + -- allPluginLoaders: [ + -- 'bukkit', + -- 'spigot', + -- 'paper', + -- 'purpur', + -- 'sponge', + -- 'bungeecord', + -- 'waterfall', + -- 'velocity', + -- 'folia', + -- ], + -- dataPackLoaders: ['datapack'], + -- modLoaders: ['forge', 'fabric', 'quilt', 'liteloader', 'modloader', 'rift', 'neoforge'], +UPDATE loaders SET metadata = jsonb_set(metadata, '{platform}', 'false'::jsonb) WHERE loader in ('bukkit', 'spigot', 'paper', 'purpur', 'sponge', 'folia'); +UPDATE loaders SET metadata = jsonb_set(metadata, '{platform}', 'true'::jsonb) WHERE loader in ('bungeecord', 'waterfall', 'velocity'); + +INSERT INTO project_types (name) VALUES ('plugin'); +INSERT INTO project_types (name) VALUES ('datapack'); + +INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) +SELECT l.id, pt.id +FROM loaders l +CROSS JOIN project_types pt +WHERE l.loader in ('datapack') +AND pt.name = 'datapack'; + +INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) +SELECT l.id, pt.id +FROM loaders l +CROSS JOIN project_types pt +WHERE l.loader in ('bukkit', 'spigot', 'paper', 'purpur', 'sponge', 'bungeecord', 'waterfall', 'velocity', 'folia') +AND pt.name = 'plugin'; + +INSERT INTO loaders_project_types_games (loader_id, project_type_id, game_id) +SELECT joining_loader_id, joining_project_type_id, g.id +FROM loaders_project_types lpt +INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id +CROSS JOIN games g +WHERE g.name = 'minecraft' +AND pt.name in ('plugin', 'datapack'); diff --git a/apps/labrinth/migrations/20231116112800_side_types_overhaul.sql b/apps/labrinth/migrations/20231116112800_side_types_overhaul.sql new file mode 100644 index 000000000..ce168f79e --- /dev/null +++ b/apps/labrinth/migrations/20231116112800_side_types_overhaul.sql @@ -0,0 +1,114 @@ +CREATE INDEX version_fields_version_id ON version_fields (version_id); +CREATE INDEX hashes_file_id ON hashes (file_id); + +INSERT INTO loader_fields (field, field_type, optional) SELECT 'singleplayer', 'boolean', false; +INSERT INTO loader_fields (field, field_type, optional) SELECT 'client_and_server', 'boolean', false; +INSERT INTO loader_fields (field, field_type, optional) SELECT 'client_only', 'boolean', false; +INSERT INTO loader_fields (field, field_type, optional) SELECT 'server_only', 'boolean', false; + +-- Create 4 temporary columns for the four booleans (makes queries easier) +ALTER TABLE versions ADD COLUMN singleplayer boolean; +ALTER TABLE versions ADD COLUMN client_and_server boolean; +ALTER TABLE versions ADD COLUMN client_only boolean; +ALTER TABLE versions ADD COLUMN server_only boolean; + +-- Set singleplayer to be true if either client_side or server_side is 'required' OR 'optional' +UPDATE versions v SET singleplayer = true +FROM version_fields vf +INNER JOIN loader_fields lf ON vf.field_id = lf.id +INNER JOIN loader_field_enum_values lfev ON lf.enum_type = lfev.enum_id AND vf.enum_value = lfev.id +WHERE v.id = vf.version_id +AND (lf.field = 'client_side' OR lf.field = 'server_side') AND (lfev.value = 'required' OR lfev.value = 'optional'); + +-- Set client and server to be true if either client_side or server_side is 'required' OR 'optional' +UPDATE versions v SET client_and_server = true +FROM version_fields vf +INNER JOIN loader_fields lf ON vf.field_id = lf.id +INNER JOIN loader_field_enum_values lfev ON lf.enum_type = lfev.enum_id AND vf.enum_value = lfev.id +WHERE v.id = vf.version_id +AND (lf.field = 'client_side' OR lf.field = 'server_side') AND (lfev.value = 'required' OR lfev.value = 'optional'); + +-- -- Set client_only to be true if client_side is 'required' or 'optional', and server_side is 'optional', 'unsupported', or 'unknown' +UPDATE versions v +SET client_only = true +WHERE EXISTS ( + SELECT 1 + FROM version_fields vf + INNER JOIN loader_fields lf ON vf.field_id = lf.id + INNER JOIN loader_field_enum_values lfev ON lf.enum_type = lfev.enum_id AND vf.enum_value = lfev.id + WHERE v.id = vf.version_id + AND lf.field = 'client_side' AND (lfev.value = 'required' OR lfev.value = 'optional') +) +AND EXISTS ( + SELECT 1 + FROM version_fields vf2 + INNER JOIN loader_fields lf2 ON vf2.field_id = lf2.id + INNER JOIN loader_field_enum_values lfev2 ON lf2.enum_type = lfev2.enum_id AND vf2.enum_value = lfev2.id + WHERE v.id = vf2.version_id + AND lf2.field = 'server_side' AND (lfev2.value = 'optional' OR lfev2.value = 'unsupported' OR lfev2.value = 'unknown') +); + +-- -- Set server_only to be true if server_side is 'required' or 'optional', and client_side is 'optional', 'unsupported', or 'unknown' +UPDATE versions v +SET server_only = true +WHERE EXISTS ( + SELECT 1 + FROM version_fields vf + INNER JOIN loader_fields lf ON vf.field_id = lf.id + INNER JOIN loader_field_enum_values lfev ON lf.enum_type = lfev.enum_id AND vf.enum_value = lfev.id + WHERE v.id = vf.version_id + AND lf.field = 'server_side' AND (lfev.value = 'required' OR lfev.value = 'optional') +) +AND EXISTS ( + SELECT 1 + FROM version_fields vf2 + INNER JOIN loader_fields lf2 ON vf2.field_id = lf2.id + INNER JOIN loader_field_enum_values lfev2 ON lf2.enum_type = lfev2.enum_id AND vf2.enum_value = lfev2.id + WHERE v.id = vf2.version_id + AND lf2.field = 'client_side' AND (lfev2.value = 'optional' OR lfev2.value = 'unsupported' OR lfev2.value = 'unknown') +); + +-- Insert the values into the version_fields table +INSERT INTO version_fields (version_id, field_id, int_value) +SELECT v.id, lf.id, CASE WHEN v.singleplayer THEN 1 ELSE 0 END +FROM versions v +INNER JOIN loader_fields lf ON lf.field = 'singleplayer'; + +INSERT INTO version_fields (version_id, field_id, int_value) +SELECT v.id, lf.id, CASE WHEN v.client_and_server THEN 1 ELSE 0 END +FROM versions v +INNER JOIN loader_fields lf ON lf.field = 'client_and_server'; + +INSERT INTO version_fields (version_id, field_id, int_value) +SELECT v.id, lf.id, CASE WHEN v.client_only THEN 1 ELSE 0 END +FROM versions v +INNER JOIN loader_fields lf ON lf.field = 'client_only'; + +INSERT INTO version_fields (version_id, field_id, int_value) +SELECT v.id, lf.id, CASE WHEN v.server_only THEN 1 ELSE 0 END +FROM versions v +INNER JOIN loader_fields lf ON lf.field = 'server_only'; + +-- Drop the temporary columns +ALTER TABLE versions DROP COLUMN singleplayer; +ALTER TABLE versions DROP COLUMN client_and_server; +ALTER TABLE versions DROP COLUMN client_only; +ALTER TABLE versions DROP COLUMN server_only; + +-- For each loader where loader_fields_loaders is 'client_side' or 'server_side', add the new fields +INSERT INTO loader_fields_loaders (loader_id, loader_field_id) +SELECT lfl.loader_id, lf.id +FROM loader_fields_loaders lfl +CROSS JOIN loader_fields lf +WHERE lfl.loader_field_id IN (SELECT id FROM loader_fields WHERE field = 'client_side' OR field = 'server_side') +AND lf.field IN ('singleplayer', 'client_and_server', 'client_only', 'server_only') +ON CONFLICT DO NOTHING; + +-- Drop the old loader_fields_loaders entries +DELETE FROM loader_fields_loaders WHERE loader_field_id IN (SELECT id FROM loader_fields WHERE field = 'client_side' OR field = 'server_side'); + +-- Drop client_side and server_side loader fields +DELETE FROM version_fields WHERE field_id IN (SELECT id FROM loader_fields WHERE field = 'client_side' OR field = 'server_side'); +DELETE FROM loader_field_enum_values WHERE id IN (SELECT enum_type FROM loader_fields WHERE field = 'client_side' OR field = 'server_side'); +DELETE FROM loader_fields WHERE field = 'client_side' OR field = 'server_side'; +DELETE FROM loader_field_enums WHERE id IN (SELECT enum_type FROM loader_fields WHERE field = 'side_types'); diff --git a/apps/labrinth/migrations/20231117073600_links_overhaul.sql b/apps/labrinth/migrations/20231117073600_links_overhaul.sql new file mode 100644 index 000000000..727a5f845 --- /dev/null +++ b/apps/labrinth/migrations/20231117073600_links_overhaul.sql @@ -0,0 +1,69 @@ +CREATE TABLE link_platforms ( + id SERIAL PRIMARY KEY, + name VARCHAR(16) UNIQUE NOT NULL, + + -- Used for v2 conversion + donation BOOLEAN NOT NULL DEFAULT false, + -- Will be removed at the end of the migration + donation_platform_id INTEGER REFERENCES donation_platforms (id) +); + +INSERT INTO link_platforms (donation_platform_id, name, donation) +SELECT id, short, true FROM donation_platforms; + +INSERT INTO link_platforms (name, donation) VALUES ('issues', false); +INSERT INTO link_platforms (name, donation) VALUES ('wiki', false); +INSERT INTO link_platforms (name, donation) VALUES ('discord', false); +INSERT INTO link_platforms (name, donation) VALUES ('source', false); +INSERT INTO link_platforms (name, donation) VALUES ('site', false); + +CREATE TABLE mods_links ( + id SERIAL PRIMARY KEY, + joining_mod_id BIGINT NOT NULL REFERENCES mods (id), + joining_platform_id INTEGER NOT NULL REFERENCES link_platforms (id), + url VARCHAR(2048) NOT NULL +); + +INSERT INTO mods_links (joining_mod_id, joining_platform_id, url) +SELECT DISTINCT m.id, lp.id, md.url +FROM mods m +INNER JOIN mods_donations md ON m.id = md.joining_mod_id +INNER JOIN donation_platforms dp ON dp.id = md.joining_platform_id +INNER JOIN link_platforms lp ON lp.donation_platform_id = dp.id; + +INSERT INTO mods_links (joining_mod_id, joining_platform_id, url) +SELECT DISTINCT m.id, lp.id, issues_url +FROM mods m +CROSS JOIN link_platforms lp +WHERE issues_url IS NOT NULL +AND lp.name = 'issues'; + +INSERT INTO mods_links (joining_mod_id, joining_platform_id, url) +SELECT DISTINCT m.id, lp.id, wiki_url +FROM mods m +CROSS JOIN link_platforms lp +WHERE wiki_url IS NOT NULL +AND lp.name = 'wiki'; + +INSERT INTO mods_links (joining_mod_id, joining_platform_id, url) +SELECT DISTINCT m.id, lp.id, discord_url +FROM mods m +CROSS JOIN link_platforms lp +WHERE discord_url IS NOT NULL +AND lp.name = 'discord'; + +INSERT INTO mods_links (joining_mod_id, joining_platform_id, url) +SELECT DISTINCT m.id, lp.id, source_url +FROM mods m +CROSS JOIN link_platforms lp +WHERE source_url IS NOT NULL +AND lp.name = 'source'; + +ALTER TABLE mods DROP COLUMN issues_url; +ALTER TABLE mods DROP COLUMN wiki_url; +ALTER TABLE mods DROP COLUMN discord_url; +ALTER TABLE mods DROP COLUMN source_url; + +ALTER TABLE link_platforms DROP COLUMN donation_platform_id; +DROP TABLE mods_donations; +DROP TABLE donation_platforms; \ No newline at end of file diff --git a/apps/labrinth/migrations/20231122230639_oauth_client_metadata.sql b/apps/labrinth/migrations/20231122230639_oauth_client_metadata.sql new file mode 100644 index 000000000..dd05dbba2 --- /dev/null +++ b/apps/labrinth/migrations/20231122230639_oauth_client_metadata.sql @@ -0,0 +1,7 @@ +-- Add migration script here +ALTER TABLE + oauth_clients +ADD + COLUMN url text NULL, +ADD + COLUMN description text NULL; \ No newline at end of file diff --git a/apps/labrinth/migrations/20231124070100_renaming_consistency.sql b/apps/labrinth/migrations/20231124070100_renaming_consistency.sql new file mode 100644 index 000000000..272ec809b --- /dev/null +++ b/apps/labrinth/migrations/20231124070100_renaming_consistency.sql @@ -0,0 +1,16 @@ +-- rename 'title' to 'name' in all tables (collections, organizations, mods, mods_gallery, notifications, notifications_actions) +ALTER TABLE collections RENAME COLUMN title TO name; +ALTER TABLE organizations RENAME COLUMN title TO name; +ALTER TABLE mods RENAME COLUMN title TO name; +ALTER TABLE mods_gallery RENAME COLUMN title TO name; +ALTER TABLE notifications RENAME COLUMN title TO name; +ALTER TABLE notifications_actions RENAME COLUMN title TO name; + +-- rename project 'description' to 'summary' +-- rename project 'body' to 'description' +ALTER TABLE mods RENAME COLUMN description TO summary; +ALTER TABLE mods RENAME COLUMN body TO description; + +-- Adds 'is_owner' boolean to team members table- only one can be true. +ALTER TABLE team_members ADD COLUMN is_owner boolean NOT NULL DEFAULT false; +UPDATE team_members SET is_owner = true WHERE role = 'Owner'; \ No newline at end of file diff --git a/apps/labrinth/migrations/20231125080100_drops_mods_dp_plugins.sql b/apps/labrinth/migrations/20231125080100_drops_mods_dp_plugins.sql new file mode 100644 index 000000000..b9750bf2b --- /dev/null +++ b/apps/labrinth/migrations/20231125080100_drops_mods_dp_plugins.sql @@ -0,0 +1,31 @@ +-- For every loader that has a loaders_project_types entry that connects it to the project_types 'plugin', +-- remove all non plugin project_types entries for that loader. +-- This is to ensure that the plugin project_types is the only one that is used for the plugin loaders + +--plugin +DELETE FROM loaders_project_types +WHERE joining_loader_id IN ( + SELECT DISTINCT l.id + FROM loaders l + LEFT JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id + LEFT JOIN project_types pt ON pt.id = lpt.joining_project_type_id + WHERE pt.name = 'plugin' +) +AND joining_project_type_id NOT IN ( + SELECT id FROM project_types + WHERE name = 'plugin' +); + +--datapack +DELETE FROM loaders_project_types +WHERE joining_loader_id IN ( + SELECT DISTINCT l.id + FROM loaders l + LEFT JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id + LEFT JOIN project_types pt ON pt.id = lpt.joining_project_type_id + WHERE pt.name = 'datapack' +) +AND joining_project_type_id NOT IN ( + SELECT id FROM project_types + WHERE name = 'datapack' +); \ No newline at end of file diff --git a/apps/labrinth/migrations/20231130153100_loader_fields_loaders.sql b/apps/labrinth/migrations/20231130153100_loader_fields_loaders.sql new file mode 100644 index 000000000..2aea21dce --- /dev/null +++ b/apps/labrinth/migrations/20231130153100_loader_fields_loaders.sql @@ -0,0 +1,5 @@ +-- Adds missing loader_fields_loaders entries for mrpack loader +INSERT INTO loader_fields_loaders +SELECT l.id, lf.id FROM loaders l CROSS JOIN loader_fields lf +WHERE l.loader='mrpack' AND lf.field=ANY(ARRAY['game_versions','client_and_server','server_only','client_only','singleplayer']) +ON CONFLICT DO NOTHING; \ No newline at end of file diff --git a/apps/labrinth/migrations/20231205095400_remaining_loader_field_loaders.sql b/apps/labrinth/migrations/20231205095400_remaining_loader_field_loaders.sql new file mode 100644 index 000000000..33e82489f --- /dev/null +++ b/apps/labrinth/migrations/20231205095400_remaining_loader_field_loaders.sql @@ -0,0 +1,21 @@ +-- Adds loader_fields_loaders entries for all loaders +-- (at this point, they are all Minecraft loaders, and thus have the same fields) +-- These are loaders such as bukkit, minecraft, vanilla, waterfall, velocity... etc +-- This also allows v2 routes (which have things such as client_side to remain to work with these loaders) +INSERT INTO loader_fields_loaders +SELECT l.id, lf.id FROM loaders l CROSS JOIN loader_fields lf +WHERE lf.field=ANY(ARRAY['client_and_server','server_only','client_only','singleplayer']) +AND +l.loader NOT IN ('vanilla', 'minecraft', 'optifine', 'iris', 'canvas', 'bukkit', 'folia', 'paper', 'purpur', 'spigot', 'sponge', 'datapack', 'bungeecord', 'velocity', 'waterfall') +ON CONFLICT DO NOTHING; + +INSERT INTO loader_fields_loaders +SELECT l.id, lf.id FROM loaders l CROSS JOIN loader_fields lf +WHERE lf.field=ANY(ARRAY['game_versions']) +ON CONFLICT DO NOTHING; + +-- All existing loader_project_types so far should have a games entry as minecraft +INSERT INTO loaders_project_types_games +SELECT lpt.joining_loader_id, lpt.joining_project_type_id, g.id FROM loaders_project_types lpt CROSS JOIN games g +WHERE g.name='minecraft-java' +ON CONFLICT DO NOTHING; diff --git a/apps/labrinth/migrations/20231211184922_collections_description_nullable.sql b/apps/labrinth/migrations/20231211184922_collections_description_nullable.sql new file mode 100644 index 000000000..08b39c3ea --- /dev/null +++ b/apps/labrinth/migrations/20231211184922_collections_description_nullable.sql @@ -0,0 +1 @@ +ALTER TABLE collections ALTER COLUMN description DROP NOT NULL; \ No newline at end of file diff --git a/apps/labrinth/migrations/20231213103100_enforces-owner-unique.sql b/apps/labrinth/migrations/20231213103100_enforces-owner-unique.sql new file mode 100644 index 000000000..f02a03522 --- /dev/null +++ b/apps/labrinth/migrations/20231213103100_enforces-owner-unique.sql @@ -0,0 +1,10 @@ +-- Enforces that there can only be one owner per team +CREATE UNIQUE INDEX idx_one_owner_per_team +ON team_members (team_id) +WHERE is_owner = TRUE; + +-- Enforces one team_member per user/team +CREATE UNIQUE INDEX idx_unique_user_team +ON team_members (user_id, team_id); + + diff --git a/apps/labrinth/migrations/20240104203711_orgs-names.sql b/apps/labrinth/migrations/20240104203711_orgs-names.sql new file mode 100644 index 000000000..a4f8e0d9d --- /dev/null +++ b/apps/labrinth/migrations/20240104203711_orgs-names.sql @@ -0,0 +1,7 @@ +-- Add migration script here +ALTER TABLE organizations RENAME COLUMN name TO slug; + +ALTER TABLE organizations ADD COLUMN name text NULL; +UPDATE organizations SET name = slug; +ALTER TABLE organizations ALTER COLUMN name SET NOT NULL; + diff --git a/apps/labrinth/migrations/20240131224610_moderation_packs.sql b/apps/labrinth/migrations/20240131224610_moderation_packs.sql new file mode 100644 index 000000000..49040ec5f --- /dev/null +++ b/apps/labrinth/migrations/20240131224610_moderation_packs.sql @@ -0,0 +1,19 @@ +CREATE TABLE moderation_external_licenses ( + id bigint PRIMARY KEY, + title text not null, + status text not null, + link text null, + exceptions text null, + proof text null, + flame_project_id integer null +); + +CREATE TABLE moderation_external_files ( + sha1 bytea PRIMARY KEY, + external_license_id bigint references moderation_external_licenses not null +); + +ALTER TABLE files ADD COLUMN metadata jsonb NULL; + +INSERT INTO users (id, username, name, email, avatar_url, bio, role, badges, balance) +VALUES (0, 'AutoMod', 'AutoMod', 'support@modrinth.com', 'https://cdn.modrinth.com/user/2REoufqX/6aabaf2d1fca2935662eca4ce451cd9775054c22.png', 'An automated account performing moderation utilities for Modrinth.', 'moderator', 0, 0) \ No newline at end of file diff --git a/apps/labrinth/migrations/20240221215354_moderation_pack_fixes.sql b/apps/labrinth/migrations/20240221215354_moderation_pack_fixes.sql new file mode 100644 index 000000000..67eff6778 --- /dev/null +++ b/apps/labrinth/migrations/20240221215354_moderation_pack_fixes.sql @@ -0,0 +1,2 @@ +ALTER TABLE moderation_external_files ALTER COLUMN sha1 SET NOT NULL; +ALTER TABLE moderation_external_licenses ALTER COLUMN title DROP NOT NULL; diff --git a/apps/labrinth/migrations/20240319195753_threads-updates.sql b/apps/labrinth/migrations/20240319195753_threads-updates.sql new file mode 100644 index 000000000..4681958bb --- /dev/null +++ b/apps/labrinth/migrations/20240319195753_threads-updates.sql @@ -0,0 +1,9 @@ +ALTER TABLE threads DROP COLUMN show_in_mod_inbox; + +ALTER TABLE threads_messages ADD COLUMN hide_identity BOOLEAN default false NOT NULL; + +UPDATE threads_messages +SET hide_identity = TRUE +FROM users +WHERE threads_messages.author_id = users.id +AND users.role IN ('moderator', 'admin'); \ No newline at end of file diff --git a/apps/labrinth/migrations/20240701213559_remove-user-names.sql b/apps/labrinth/migrations/20240701213559_remove-user-names.sql new file mode 100644 index 000000000..e44cbb569 --- /dev/null +++ b/apps/labrinth/migrations/20240701213559_remove-user-names.sql @@ -0,0 +1 @@ +ALTER TABLE users DROP COLUMN name; \ No newline at end of file diff --git a/apps/labrinth/migrations/20240702213250_subscriptions.sql b/apps/labrinth/migrations/20240702213250_subscriptions.sql new file mode 100644 index 000000000..edad3ef84 --- /dev/null +++ b/apps/labrinth/migrations/20240702213250_subscriptions.sql @@ -0,0 +1,34 @@ +ALTER TABLE users ADD COLUMN stripe_customer_id TEXT NULL; + +CREATE TABLE products ( + id bigint PRIMARY KEY, + metadata jsonb NOT NULL, + unitary BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE TABLE products_prices ( + id bigint PRIMARY KEY, + product_id bigint REFERENCES products NOT NULL, + currency_code text not null, + prices jsonb NOT NULL +); + +CREATE TABLE users_subscriptions ( + id bigint PRIMARY KEY, + user_id bigint REFERENCES users NOT NULL, + price_id bigint REFERENCES products_prices NOT NULL, + interval text NOT NULL, + created timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + expires timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + last_charge timestamptz NULL, + status varchar(255) NOT NULL +); + +CREATE UNIQUE INDEX users_stripe_customer_id + ON users (stripe_customer_id); + +CREATE INDEX products_prices_product + ON products_prices (product_id); + +CREATE INDEX users_subscriptions_users + ON users_subscriptions (user_id); diff --git a/apps/labrinth/migrations/20240907192840_raw-images.sql b/apps/labrinth/migrations/20240907192840_raw-images.sql new file mode 100644 index 000000000..f929afa9f --- /dev/null +++ b/apps/labrinth/migrations/20240907192840_raw-images.sql @@ -0,0 +1,22 @@ +ALTER TABLE mods ADD COLUMN raw_icon_url TEXT NULL; +UPDATE mods SET raw_icon_url = icon_url; + +ALTER TABLE users ADD COLUMN raw_avatar_url TEXT NULL; +UPDATE users SET raw_avatar_url = avatar_url; + +ALTER TABLE oauth_clients ADD COLUMN raw_icon_url TEXT NULL; +UPDATE oauth_clients SET raw_icon_url = icon_url; + +ALTER TABLE organizations ADD COLUMN raw_icon_url TEXT NULL; +UPDATE organizations SET raw_icon_url = icon_url; + +ALTER TABLE collections ADD COLUMN raw_icon_url TEXT NULL; +UPDATE collections SET raw_icon_url = icon_url; + +ALTER TABLE mods_gallery ADD COLUMN raw_image_url TEXT NULL; +UPDATE mods_gallery SET raw_image_url = image_url; +ALTER TABLE mods_gallery ALTER COLUMN raw_image_url SET NOT NULL; + +ALTER TABLE uploaded_images ADD COLUMN raw_url TEXT NULL; +UPDATE uploaded_images SET raw_url = url; +ALTER TABLE uploaded_images ALTER COLUMN raw_url SET NOT NULL; \ No newline at end of file diff --git a/apps/labrinth/migrations/20240911044738_payouts-updates.sql b/apps/labrinth/migrations/20240911044738_payouts-updates.sql new file mode 100644 index 000000000..824c8204f --- /dev/null +++ b/apps/labrinth/migrations/20240911044738_payouts-updates.sql @@ -0,0 +1,2 @@ +ALTER TABLE payouts_values ADD COLUMN date_available timestamptz NOT NULL DEFAULT now(); +ALTER TABLE payouts_values ALTER COLUMN date_available DROP DEFAULT; \ No newline at end of file diff --git a/apps/labrinth/migrations/20240923163452_charges-fix.sql b/apps/labrinth/migrations/20240923163452_charges-fix.sql new file mode 100644 index 000000000..c494528ef --- /dev/null +++ b/apps/labrinth/migrations/20240923163452_charges-fix.sql @@ -0,0 +1,17 @@ +CREATE TABLE charges ( + id bigint PRIMARY KEY, + user_id bigint REFERENCES users NOT NULL, + price_id bigint REFERENCES products_prices NOT NULL, + amount bigint NOT NULL, + currency_code text NOT NULL, + status varchar(255) NOT NULL, + due timestamptz DEFAULT CURRENT_TIMESTAMP NOT NULL, + last_attempt timestamptz NULL, + charge_type text NOT NULL, + subscription_id bigint NULL, + subscription_interval text NULL +); + +ALTER TABLE users_subscriptions DROP COLUMN last_charge; +ALTER TABLE users_subscriptions ADD COLUMN metadata jsonb NULL; +ALTER TABLE users_subscriptions DROP COLUMN expires; \ No newline at end of file diff --git a/apps/labrinth/package.json b/apps/labrinth/package.json new file mode 100644 index 000000000..03e662511 --- /dev/null +++ b/apps/labrinth/package.json @@ -0,0 +1,11 @@ +{ + "name": "@modrinth/labrinth", + "scripts": { + "build": "cargo build --release", + "lint": "cargo fmt --check && cargo clippy --all-targets --all-features -- -D warnings", + "fix": "cargo fmt && cargo clippy --fix", + "dev": "cargo run", + "//": "CI will fail since test takes up too much disk space. So we have it named differently.", + "test-labrinth": "cargo test" + } +} diff --git a/apps/labrinth/src/auth/checks.rs b/apps/labrinth/src/auth/checks.rs new file mode 100644 index 000000000..d347f70ac --- /dev/null +++ b/apps/labrinth/src/auth/checks.rs @@ -0,0 +1,372 @@ +use crate::database; +use crate::database::models::project_item::QueryProject; +use crate::database::models::version_item::QueryVersion; +use crate::database::models::Collection; +use crate::database::redis::RedisPool; +use crate::database::{models, Project, Version}; +use crate::models::users::User; +use crate::routes::ApiError; +use itertools::Itertools; +use sqlx::PgPool; + +pub trait ValidateAuthorized { + fn validate_authorized( + &self, + user_option: Option<&User>, + ) -> Result<(), ApiError>; +} + +pub trait ValidateAllAuthorized { + fn validate_all_authorized( + self, + user_option: Option<&User>, + ) -> Result<(), ApiError>; +} + +impl<'a, T, A> ValidateAllAuthorized for T +where + T: IntoIterator, + A: ValidateAuthorized + 'a, +{ + fn validate_all_authorized( + self, + user_option: Option<&User>, + ) -> Result<(), ApiError> { + self.into_iter() + .try_for_each(|c| c.validate_authorized(user_option)) + } +} + +pub async fn is_visible_project( + project_data: &Project, + user_option: &Option, + pool: &PgPool, + hide_unlisted: bool, +) -> Result { + filter_visible_project_ids( + vec![project_data], + user_option, + pool, + hide_unlisted, + ) + .await + .map(|x| !x.is_empty()) +} + +pub async fn is_team_member_project( + project_data: &Project, + user_option: &Option, + pool: &PgPool, +) -> Result { + filter_enlisted_projects_ids(vec![project_data], user_option, pool) + .await + .map(|x| !x.is_empty()) +} + +pub async fn filter_visible_projects( + mut projects: Vec, + user_option: &Option, + pool: &PgPool, + hide_unlisted: bool, +) -> Result, ApiError> { + let filtered_project_ids = filter_visible_project_ids( + projects.iter().map(|x| &x.inner).collect_vec(), + user_option, + pool, + hide_unlisted, + ) + .await?; + projects.retain(|x| filtered_project_ids.contains(&x.inner.id)); + Ok(projects.into_iter().map(|x| x.into()).collect()) +} + +// Filters projects for which we can see, meaning one of the following is true: +// - it's not hidden +// - the user is enlisted on the project's team (filter_enlisted_projects) +// - the user is a mod +// This is essentially whether you can know of the project's existence +pub async fn filter_visible_project_ids( + projects: Vec<&Project>, + user_option: &Option, + pool: &PgPool, + hide_unlisted: bool, +) -> Result, ApiError> { + let mut return_projects = Vec::new(); + let mut check_projects = Vec::new(); + + // Return projects that are not hidden or we are a mod of + for project in projects { + if (if hide_unlisted { + project.status.is_searchable() + } else { + !project.status.is_hidden() + }) || user_option + .as_ref() + .map(|x| x.role.is_mod()) + .unwrap_or(false) + { + return_projects.push(project.id); + } else if user_option.is_some() { + check_projects.push(project); + } + } + + // For hidden projects, return a filtered list of projects for which we are enlisted on the team + if !check_projects.is_empty() { + return_projects.extend( + filter_enlisted_projects_ids(check_projects, user_option, pool) + .await?, + ); + } + + Ok(return_projects) +} + +// Filters out projects for which we are a member of the team (or a mod) +// These are projects we have internal access to and can potentially see even if they are hidden +// This is useful for getting visibility of versions, or seeing analytics or sensitive team-restricted data of a project +pub async fn filter_enlisted_projects_ids( + projects: Vec<&Project>, + user_option: &Option, + pool: &PgPool, +) -> Result, ApiError> { + let mut return_projects = vec![]; + + if let Some(user) = user_option { + let user_id: models::ids::UserId = user.id.into(); + + use futures::TryStreamExt; + + sqlx::query!( + " + SELECT m.id id, m.team_id team_id FROM team_members tm + INNER JOIN mods m ON m.team_id = tm.team_id + LEFT JOIN organizations o ON o.team_id = tm.team_id + WHERE tm.team_id = ANY($1) AND tm.user_id = $3 + UNION + SELECT m.id id, m.team_id team_id FROM team_members tm + INNER JOIN organizations o ON o.team_id = tm.team_id + INNER JOIN mods m ON m.organization_id = o.id + WHERE o.id = ANY($2) AND tm.user_id = $3 + ", + &projects.iter().map(|x| x.team_id.0).collect::>(), + &projects + .iter() + .filter_map(|x| x.organization_id.map(|x| x.0)) + .collect::>(), + user_id as database::models::ids::UserId, + ) + .fetch(pool) + .map_ok(|row| { + for x in projects.iter() { + let bool = + Some(x.id.0) == row.id && Some(x.team_id.0) == row.team_id; + if bool { + return_projects.push(x.id); + } + } + }) + .try_collect::>() + .await?; + } + Ok(return_projects) +} + +pub async fn is_visible_version( + version_data: &Version, + user_option: &Option, + pool: &PgPool, + redis: &RedisPool, +) -> Result { + filter_visible_version_ids(vec![version_data], user_option, pool, redis) + .await + .map(|x| !x.is_empty()) +} + +pub async fn is_team_member_version( + version_data: &Version, + user_option: &Option, + pool: &PgPool, + redis: &RedisPool, +) -> Result { + filter_enlisted_version_ids(vec![version_data], user_option, pool, redis) + .await + .map(|x| !x.is_empty()) +} + +pub async fn filter_visible_versions( + mut versions: Vec, + user_option: &Option, + pool: &PgPool, + redis: &RedisPool, +) -> Result, ApiError> { + let filtered_version_ids = filter_visible_version_ids( + versions.iter().map(|x| &x.inner).collect_vec(), + user_option, + pool, + redis, + ) + .await?; + versions.retain(|x| filtered_version_ids.contains(&x.inner.id)); + Ok(versions.into_iter().map(|x| x.into()).collect()) +} + +impl ValidateAuthorized for models::OAuthClient { + fn validate_authorized( + &self, + user_option: Option<&User>, + ) -> Result<(), ApiError> { + if let Some(user) = user_option { + return if user.role.is_mod() || user.id == self.created_by.into() { + Ok(()) + } else { + Err(ApiError::CustomAuthentication( + "You don't have sufficient permissions to interact with this OAuth application" + .to_string(), + )) + }; + } + + Ok(()) + } +} + +pub async fn filter_visible_version_ids( + versions: Vec<&Version>, + user_option: &Option, + pool: &PgPool, + redis: &RedisPool, +) -> Result, ApiError> { + let mut return_versions = Vec::new(); + let mut check_versions = Vec::new(); + + // First, filter out versions belonging to projects we can't see + // (ie: a hidden project, but public version, should still be hidden) + // Gets project ids of versions + let project_ids = versions.iter().map(|x| x.project_id).collect::>(); + + // Get visible projects- ones we are allowed to see public versions for. + let visible_project_ids = filter_visible_project_ids( + Project::get_many_ids(&project_ids, pool, redis) + .await? + .iter() + .map(|x| &x.inner) + .collect(), + user_option, + pool, + false, + ) + .await?; + + // Then, get enlisted versions (Versions that are a part of a project we are a member of) + let enlisted_version_ids = + filter_enlisted_version_ids(versions.clone(), user_option, pool, redis) + .await?; + + // Return versions that are not hidden, we are a mod of, or we are enlisted on the team of + for version in versions { + // We can see the version if: + // - it's not hidden and we can see the project + // - we are a mod + // - we are enlisted on the team of the mod + if (!version.status.is_hidden() + && visible_project_ids.contains(&version.project_id)) + || user_option + .as_ref() + .map(|x| x.role.is_mod()) + .unwrap_or(false) + || enlisted_version_ids.contains(&version.id) + { + return_versions.push(version.id); + } else if user_option.is_some() { + check_versions.push(version); + } + } + + Ok(return_versions) +} + +pub async fn filter_enlisted_version_ids( + versions: Vec<&Version>, + user_option: &Option, + pool: &PgPool, + redis: &RedisPool, +) -> Result, ApiError> { + let mut return_versions = Vec::new(); + + // Get project ids of versions + let project_ids = versions.iter().map(|x| x.project_id).collect::>(); + + // Get enlisted projects- ones we are allowed to see hidden versions for. + let authorized_project_ids = filter_enlisted_projects_ids( + Project::get_many_ids(&project_ids, pool, redis) + .await? + .iter() + .map(|x| &x.inner) + .collect(), + user_option, + pool, + ) + .await?; + + for version in versions { + if user_option + .as_ref() + .map(|x| x.role.is_mod()) + .unwrap_or(false) + || (user_option.is_some() + && authorized_project_ids.contains(&version.project_id)) + { + return_versions.push(version.id); + } + } + + Ok(return_versions) +} + +pub async fn is_visible_collection( + collection_data: &Collection, + user_option: &Option, +) -> Result { + let mut authorized = !collection_data.status.is_hidden(); + if let Some(user) = &user_option { + if !authorized + && (user.role.is_mod() || user.id == collection_data.user_id.into()) + { + authorized = true; + } + } + Ok(authorized) +} + +pub async fn filter_visible_collections( + collections: Vec, + user_option: &Option, +) -> Result, ApiError> { + let mut return_collections = Vec::new(); + let mut check_collections = Vec::new(); + + for collection in collections { + if !collection.status.is_hidden() + || user_option + .as_ref() + .map(|x| x.role.is_mod()) + .unwrap_or(false) + { + return_collections.push(collection.into()); + } else if user_option.is_some() { + check_collections.push(collection); + } + } + + for collection in check_collections { + // Collections are simple- if we are the owner or a mod, we can see it + if let Some(user) = user_option { + if user.role.is_mod() || user.id == collection.user_id.into() { + return_collections.push(collection.into()); + } + } + } + + Ok(return_collections) +} diff --git a/apps/labrinth/src/auth/email/auth_notif.html b/apps/labrinth/src/auth/email/auth_notif.html new file mode 100644 index 000000000..e367b2c84 --- /dev/null +++ b/apps/labrinth/src/auth/email/auth_notif.html @@ -0,0 +1,197 @@ + + + + {{ email_title }} + + + + + + + + + + + + + + + + + + + + +
{{ email_description }}͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏  ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏  ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏  ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏  ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏  ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏  ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏  ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏  ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏  ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ 
+
+
+
+
+
+
+
+ +
+
+
+
+

{{ email_title }}

+

{{ line_one }}

 

{{ line_two }}

+
+ +
+
+
+
+
+
+
modrinth logo +
+

Rinth, Inc.

+

410 N Scottsdale Road

Suite 1000

Tempe, AZ 85281

+
+
+
+
+
+
+
+ +
+
Discord +
+
+ +
+
Twitter +
+
+ +
+
Mastodon +
+
+ +
+
GitHub +
+
+ +
+
YouTube +
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +
+
+ + diff --git a/apps/labrinth/src/auth/email/button_notif.html b/apps/labrinth/src/auth/email/button_notif.html new file mode 100644 index 000000000..85c628e28 --- /dev/null +++ b/apps/labrinth/src/auth/email/button_notif.html @@ -0,0 +1,202 @@ + + + + {{ email_title }} + + + + + + + + + + + + + + + + + + + + +
{{ email_description }}͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏  ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏  ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏  ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏  ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏  ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏  ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏  ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏  ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏  ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ ͏ 
+
+
+
+
+
+
+
+ +
+
+
+
+

{{ email_title }}

+

{{ line_one }}

 

{{ line_two }}

+
+
{{ button_title }} +
+

{{ button_link }}

+
+ +
+
+
+
+
+
+
modrinth logo +
+

Rinth, Inc.

+

410 N Scottsdale Road

Suite 1000

Tempe, AZ 85281

+
+
+
+
+
+
+
+ +
+
Discord +
+
+ +
+
Twitter +
+
+ +
+
Mastodon +
+
+ +
+
GitHub +
+
+ +
+
YouTube +
+
+ +
+ +
+ +
+ +
+ +
+ +
+
+ +
+
+ + diff --git a/apps/labrinth/src/auth/email/mod.rs b/apps/labrinth/src/auth/email/mod.rs new file mode 100644 index 000000000..80c8bb8e1 --- /dev/null +++ b/apps/labrinth/src/auth/email/mod.rs @@ -0,0 +1,72 @@ +use lettre::message::header::ContentType; +use lettre::message::Mailbox; +use lettre::transport::smtp::authentication::Credentials; +use lettre::{Address, Message, SmtpTransport, Transport}; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum MailError { + #[error("Environment Error")] + Env(#[from] dotenvy::Error), + #[error("Mail Error: {0}")] + Mail(#[from] lettre::error::Error), + #[error("Address Parse Error: {0}")] + Address(#[from] lettre::address::AddressError), + #[error("SMTP Error: {0}")] + Smtp(#[from] lettre::transport::smtp::Error), +} + +pub fn send_email_raw( + to: String, + subject: String, + body: String, +) -> Result<(), MailError> { + let email = Message::builder() + .from(Mailbox::new( + Some("Modrinth".to_string()), + Address::new("no-reply", "mail.modrinth.com")?, + )) + .to(to.parse()?) + .subject(subject) + .header(ContentType::TEXT_HTML) + .body(body)?; + + let username = dotenvy::var("SMTP_USERNAME")?; + let password = dotenvy::var("SMTP_PASSWORD")?; + let host = dotenvy::var("SMTP_HOST")?; + let creds = Credentials::new(username, password); + + let mailer = SmtpTransport::relay(&host)?.credentials(creds).build(); + + mailer.send(&email)?; + + Ok(()) +} + +pub fn send_email( + to: String, + email_title: &str, + email_description: &str, + line_two: &str, + button_info: Option<(&str, &str)>, +) -> Result<(), MailError> { + let mut email = if button_info.is_some() { + include_str!("button_notif.html") + } else { + include_str!("auth_notif.html") + } + .replace("{{ email_title }}", email_title) + .replace("{{ email_description }}", email_description) + .replace("{{ line_one }}", email_description) + .replace("{{ line_two }}", line_two); + + if let Some((button_title, button_link)) = button_info { + email = email + .replace("{{ button_title }}", button_title) + .replace("{{ button_link }}", button_link); + } + + send_email_raw(to, email_title.to_string(), email)?; + + Ok(()) +} diff --git a/apps/labrinth/src/auth/mod.rs b/apps/labrinth/src/auth/mod.rs new file mode 100644 index 000000000..30eca4d15 --- /dev/null +++ b/apps/labrinth/src/auth/mod.rs @@ -0,0 +1,122 @@ +pub mod checks; +pub mod email; +pub mod oauth; +pub mod templates; +pub mod validate; +pub use crate::auth::email::send_email; +pub use checks::{ + filter_enlisted_projects_ids, filter_enlisted_version_ids, + filter_visible_collections, filter_visible_project_ids, + filter_visible_projects, +}; +use serde::{Deserialize, Serialize}; +// pub use pat::{generate_pat, PersonalAccessToken}; +pub use validate::{check_is_moderator_from_headers, get_user_from_headers}; + +use crate::file_hosting::FileHostingError; +use crate::models::error::ApiError; +use actix_web::http::StatusCode; +use actix_web::HttpResponse; +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum AuthenticationError { + #[error("Environment Error")] + Env(#[from] dotenvy::Error), + #[error("An unknown database error occurred: {0}")] + Sqlx(#[from] sqlx::Error), + #[error("Database Error: {0}")] + Database(#[from] crate::database::models::DatabaseError), + #[error("Error while parsing JSON: {0}")] + SerDe(#[from] serde_json::Error), + #[error("Error while communicating to external provider")] + Reqwest(#[from] reqwest::Error), + #[error("Error uploading user profile picture")] + FileHosting(#[from] FileHostingError), + #[error("Error while decoding PAT: {0}")] + Decoding(#[from] crate::models::ids::DecodingError), + #[error("{0}")] + Mail(#[from] email::MailError), + #[error("Invalid Authentication Credentials")] + InvalidCredentials, + #[error("Authentication method was not valid")] + InvalidAuthMethod, + #[error("GitHub Token from incorrect Client ID")] + InvalidClientId, + #[error("User email/account is already registered on Modrinth")] + DuplicateUser, + #[error("Invalid state sent, you probably need to get a new websocket")] + SocketError, + #[error("Invalid callback URL specified")] + Url, +} + +impl actix_web::ResponseError for AuthenticationError { + fn status_code(&self) -> StatusCode { + match self { + AuthenticationError::Env(..) => StatusCode::INTERNAL_SERVER_ERROR, + AuthenticationError::Sqlx(..) => StatusCode::INTERNAL_SERVER_ERROR, + AuthenticationError::Database(..) => { + StatusCode::INTERNAL_SERVER_ERROR + } + AuthenticationError::SerDe(..) => StatusCode::BAD_REQUEST, + AuthenticationError::Reqwest(..) => { + StatusCode::INTERNAL_SERVER_ERROR + } + AuthenticationError::InvalidCredentials => StatusCode::UNAUTHORIZED, + AuthenticationError::Decoding(..) => StatusCode::BAD_REQUEST, + AuthenticationError::Mail(..) => StatusCode::INTERNAL_SERVER_ERROR, + AuthenticationError::InvalidAuthMethod => StatusCode::UNAUTHORIZED, + AuthenticationError::InvalidClientId => StatusCode::UNAUTHORIZED, + AuthenticationError::Url => StatusCode::BAD_REQUEST, + AuthenticationError::FileHosting(..) => { + StatusCode::INTERNAL_SERVER_ERROR + } + AuthenticationError::DuplicateUser => StatusCode::BAD_REQUEST, + AuthenticationError::SocketError => StatusCode::BAD_REQUEST, + } + } + + fn error_response(&self) -> HttpResponse { + HttpResponse::build(self.status_code()).json(ApiError { + error: self.error_name(), + description: self.to_string(), + }) + } +} + +impl AuthenticationError { + pub fn error_name(&self) -> &'static str { + match self { + AuthenticationError::Env(..) => "environment_error", + AuthenticationError::Sqlx(..) => "database_error", + AuthenticationError::Database(..) => "database_error", + AuthenticationError::SerDe(..) => "invalid_input", + AuthenticationError::Reqwest(..) => "network_error", + AuthenticationError::InvalidCredentials => "invalid_credentials", + AuthenticationError::Decoding(..) => "decoding_error", + AuthenticationError::Mail(..) => "mail_error", + AuthenticationError::InvalidAuthMethod => "invalid_auth_method", + AuthenticationError::InvalidClientId => "invalid_client_id", + AuthenticationError::Url => "url_error", + AuthenticationError::FileHosting(..) => "file_hosting", + AuthenticationError::DuplicateUser => "duplicate_user", + AuthenticationError::SocketError => "socket", + } + } +} + +#[derive( + Serialize, Deserialize, Default, Eq, PartialEq, Clone, Copy, Debug, +)] +#[serde(rename_all = "lowercase")] +pub enum AuthProvider { + #[default] + GitHub, + Discord, + Microsoft, + GitLab, + Google, + Steam, + PayPal, +} diff --git a/apps/labrinth/src/auth/oauth/errors.rs b/apps/labrinth/src/auth/oauth/errors.rs new file mode 100644 index 000000000..dab6ff850 --- /dev/null +++ b/apps/labrinth/src/auth/oauth/errors.rs @@ -0,0 +1,187 @@ +use super::ValidatedRedirectUri; +use crate::auth::AuthenticationError; +use crate::models::error::ApiError; +use crate::models::ids::DecodingError; +use actix_web::http::{header::LOCATION, StatusCode}; +use actix_web::HttpResponse; + +#[derive(thiserror::Error, Debug)] +#[error("{}", .error_type)] +pub struct OAuthError { + #[source] + pub error_type: OAuthErrorType, + + pub state: Option, + pub valid_redirect_uri: Option, +} + +impl From for OAuthError +where + T: Into, +{ + fn from(value: T) -> Self { + OAuthError::error(value.into()) + } +} + +impl OAuthError { + /// The OAuth request failed either because of an invalid redirection URI + /// or before we could validate the one we were given, so return an error + /// directly to the caller + /// + /// See: IETF RFC 6749 4.1.2.1 (https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1) + pub fn error(error_type: impl Into) -> Self { + Self { + error_type: error_type.into(), + valid_redirect_uri: None, + state: None, + } + } + + /// The OAuth request failed for a reason other than an invalid redirection URI + /// So send the error in url-encoded form to the redirect URI + /// + /// See: IETF RFC 6749 4.1.2.1 (https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2.1) + pub fn redirect( + err: impl Into, + state: &Option, + valid_redirect_uri: &ValidatedRedirectUri, + ) -> Self { + Self { + error_type: err.into(), + state: state.clone(), + valid_redirect_uri: Some(valid_redirect_uri.clone()), + } + } +} + +impl actix_web::ResponseError for OAuthError { + fn status_code(&self) -> StatusCode { + match self.error_type { + OAuthErrorType::AuthenticationError(_) + | OAuthErrorType::FailedScopeParse(_) + | OAuthErrorType::ScopesTooBroad + | OAuthErrorType::AccessDenied => { + if self.valid_redirect_uri.is_some() { + StatusCode::OK + } else { + StatusCode::INTERNAL_SERVER_ERROR + } + } + OAuthErrorType::RedirectUriNotConfigured(_) + | OAuthErrorType::ClientMissingRedirectURI { client_id: _ } + | OAuthErrorType::InvalidAcceptFlowId + | OAuthErrorType::MalformedId(_) + | OAuthErrorType::InvalidClientId(_) + | OAuthErrorType::InvalidAuthCode + | OAuthErrorType::OnlySupportsAuthorizationCodeGrant(_) + | OAuthErrorType::RedirectUriChanged(_) + | OAuthErrorType::UnauthorizedClient => StatusCode::BAD_REQUEST, + OAuthErrorType::ClientAuthenticationFailed => { + StatusCode::UNAUTHORIZED + } + } + } + + fn error_response(&self) -> HttpResponse { + if let Some(ValidatedRedirectUri(mut redirect_uri)) = + self.valid_redirect_uri.clone() + { + redirect_uri = format!( + "{}?error={}&error_description={}", + redirect_uri, + self.error_type.error_name(), + self.error_type, + ); + + if let Some(state) = self.state.as_ref() { + redirect_uri = format!("{}&state={}", redirect_uri, state); + } + + HttpResponse::Ok() + .append_header((LOCATION, redirect_uri.clone())) + .body(redirect_uri) + } else { + HttpResponse::build(self.status_code()).json(ApiError { + error: &self.error_type.error_name(), + description: self.error_type.to_string(), + }) + } + } +} + +#[derive(thiserror::Error, Debug)] +pub enum OAuthErrorType { + #[error(transparent)] + AuthenticationError(#[from] AuthenticationError), + #[error("Client {} has no redirect URIs specified", .client_id.0)] + ClientMissingRedirectURI { + client_id: crate::database::models::OAuthClientId, + }, + #[error( + "The provided redirect URI did not match any configured in the client" + )] + RedirectUriNotConfigured(String), + #[error("The provided scope was malformed or did not correspond to known scopes ({0})")] + FailedScopeParse(bitflags::parser::ParseError), + #[error( + "The provided scope requested scopes broader than the developer app is configured with" + )] + ScopesTooBroad, + #[error("The provided flow id was invalid")] + InvalidAcceptFlowId, + #[error("The provided client id was invalid")] + InvalidClientId(crate::database::models::OAuthClientId), + #[error("The provided ID could not be decoded: {0}")] + MalformedId(#[from] DecodingError), + #[error("Failed to authenticate client")] + ClientAuthenticationFailed, + #[error("The provided authorization grant code was invalid")] + InvalidAuthCode, + #[error("The provided client id did not match the id this authorization code was granted to")] + UnauthorizedClient, + #[error("The provided redirect URI did not exactly match the uri originally provided when this flow began")] + RedirectUriChanged(Option), + #[error("The provided grant type ({0}) must be \"authorization_code\"")] + OnlySupportsAuthorizationCodeGrant(String), + #[error("The resource owner denied the request")] + AccessDenied, +} + +impl From for OAuthErrorType { + fn from(value: crate::database::models::DatabaseError) -> Self { + OAuthErrorType::AuthenticationError(value.into()) + } +} + +impl From for OAuthErrorType { + fn from(value: sqlx::Error) -> Self { + OAuthErrorType::AuthenticationError(value.into()) + } +} + +impl OAuthErrorType { + pub fn error_name(&self) -> String { + // IETF RFC 6749 4.1.2.1 (https://datatracker.ietf.org/doc/html/rfc6749#autoid-38) + // And 5.2 (https://datatracker.ietf.org/doc/html/rfc6749#section-5.2) + match self { + Self::RedirectUriNotConfigured(_) + | Self::ClientMissingRedirectURI { client_id: _ } => "invalid_uri", + Self::AuthenticationError(_) | Self::InvalidAcceptFlowId => { + "server_error" + } + Self::RedirectUriChanged(_) | Self::MalformedId(_) => { + "invalid_request" + } + Self::FailedScopeParse(_) | Self::ScopesTooBroad => "invalid_scope", + Self::InvalidClientId(_) | Self::ClientAuthenticationFailed => { + "invalid_client" + } + Self::InvalidAuthCode + | Self::OnlySupportsAuthorizationCodeGrant(_) => "invalid_grant", + Self::UnauthorizedClient => "unauthorized_client", + Self::AccessDenied => "access_denied", + } + .to_string() + } +} diff --git a/apps/labrinth/src/auth/oauth/mod.rs b/apps/labrinth/src/auth/oauth/mod.rs new file mode 100644 index 000000000..bc0a53884 --- /dev/null +++ b/apps/labrinth/src/auth/oauth/mod.rs @@ -0,0 +1,465 @@ +use crate::auth::get_user_from_headers; +use crate::auth::oauth::uris::{OAuthRedirectUris, ValidatedRedirectUri}; +use crate::auth::validate::extract_authorization_header; +use crate::database::models::flow_item::Flow; +use crate::database::models::oauth_client_authorization_item::OAuthClientAuthorization; +use crate::database::models::oauth_client_item::OAuthClient as DBOAuthClient; +use crate::database::models::oauth_token_item::OAuthAccessToken; +use crate::database::models::{ + generate_oauth_access_token_id, generate_oauth_client_authorization_id, + OAuthClientAuthorizationId, +}; +use crate::database::redis::RedisPool; +use crate::models; +use crate::models::ids::OAuthClientId; +use crate::models::pats::Scopes; +use crate::queue::session::AuthQueue; +use actix_web::http::header::LOCATION; +use actix_web::web::{Data, Query, ServiceConfig}; +use actix_web::{get, post, web, HttpRequest, HttpResponse}; +use chrono::Duration; +use rand::distributions::Alphanumeric; +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha20Rng; +use reqwest::header::{CACHE_CONTROL, PRAGMA}; +use serde::{Deserialize, Serialize}; +use sqlx::postgres::PgPool; + +use self::errors::{OAuthError, OAuthErrorType}; + +use super::AuthenticationError; + +pub mod errors; +pub mod uris; + +pub fn config(cfg: &mut ServiceConfig) { + cfg.service(init_oauth) + .service(accept_client_scopes) + .service(reject_client_scopes) + .service(request_token); +} + +#[derive(Serialize, Deserialize)] +pub struct OAuthInit { + pub client_id: OAuthClientId, + pub redirect_uri: Option, + pub scope: Option, + pub state: Option, +} + +#[derive(Serialize, Deserialize)] +pub struct OAuthClientAccessRequest { + pub flow_id: String, + pub client_id: OAuthClientId, + pub client_name: String, + pub client_icon: Option, + pub requested_scopes: Scopes, +} + +#[get("authorize")] +pub async fn init_oauth( + req: HttpRequest, + Query(oauth_info): Query, + pool: Data, + redis: Data, + session_queue: Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::USER_AUTH_WRITE]), + ) + .await? + .1; + + let client_id = oauth_info.client_id.into(); + let client = DBOAuthClient::get(client_id, &**pool).await?; + + if let Some(client) = client { + let redirect_uri = ValidatedRedirectUri::validate( + &oauth_info.redirect_uri, + client.redirect_uris.iter().map(|r| r.uri.as_ref()), + client.id, + )?; + + let requested_scopes = + oauth_info + .scope + .as_ref() + .map_or(Ok(client.max_scopes), |s| { + Scopes::parse_from_oauth_scopes(s).map_err(|e| { + OAuthError::redirect( + OAuthErrorType::FailedScopeParse(e), + &oauth_info.state, + &redirect_uri, + ) + }) + })?; + + if !client.max_scopes.contains(requested_scopes) { + return Err(OAuthError::redirect( + OAuthErrorType::ScopesTooBroad, + &oauth_info.state, + &redirect_uri, + )); + } + + let existing_authorization = + OAuthClientAuthorization::get(client.id, user.id.into(), &**pool) + .await + .map_err(|e| { + OAuthError::redirect(e, &oauth_info.state, &redirect_uri) + })?; + let redirect_uris = OAuthRedirectUris::new( + oauth_info.redirect_uri.clone(), + redirect_uri.clone(), + ); + match existing_authorization { + Some(existing_authorization) + if existing_authorization.scopes.contains(requested_scopes) => + { + init_oauth_code_flow( + user.id.into(), + client.id.into(), + existing_authorization.id, + requested_scopes, + redirect_uris, + oauth_info.state, + &redis, + ) + .await + } + _ => { + let flow_id = Flow::InitOAuthAppApproval { + user_id: user.id.into(), + client_id: client.id, + existing_authorization_id: existing_authorization + .map(|a| a.id), + scopes: requested_scopes, + redirect_uris, + state: oauth_info.state.clone(), + } + .insert(Duration::minutes(30), &redis) + .await + .map_err(|e| { + OAuthError::redirect(e, &oauth_info.state, &redirect_uri) + })?; + + let access_request = OAuthClientAccessRequest { + client_id: client.id.into(), + client_name: client.name, + client_icon: client.icon_url, + flow_id, + requested_scopes, + }; + Ok(HttpResponse::Ok().json(access_request)) + } + } + } else { + Err(OAuthError::error(OAuthErrorType::InvalidClientId( + client_id, + ))) + } +} + +#[derive(Serialize, Deserialize)] +pub struct RespondToOAuthClientScopes { + pub flow: String, +} + +#[post("accept")] +pub async fn accept_client_scopes( + req: HttpRequest, + accept_body: web::Json, + pool: Data, + redis: Data, + session_queue: Data, +) -> Result { + accept_or_reject_client_scopes( + true, + req, + accept_body, + pool, + redis, + session_queue, + ) + .await +} + +#[post("reject")] +pub async fn reject_client_scopes( + req: HttpRequest, + body: web::Json, + pool: Data, + redis: Data, + session_queue: Data, +) -> Result { + accept_or_reject_client_scopes(false, req, body, pool, redis, session_queue) + .await +} + +#[derive(Serialize, Deserialize)] +pub struct TokenRequest { + pub grant_type: String, + pub code: String, + pub redirect_uri: Option, + pub client_id: models::ids::OAuthClientId, +} + +#[derive(Serialize, Deserialize)] +pub struct TokenResponse { + pub access_token: String, + pub token_type: String, + pub expires_in: i64, +} + +#[post("token")] +/// Params should be in the urlencoded request body +/// And client secret should be in the HTTP basic authorization header +/// Per IETF RFC6749 Section 4.1.3 (https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3) +pub async fn request_token( + req: HttpRequest, + req_params: web::Form, + pool: Data, + redis: Data, +) -> Result { + let req_client_id = req_params.client_id; + let client = DBOAuthClient::get(req_client_id.into(), &**pool).await?; + if let Some(client) = client { + authenticate_client_token_request(&req, &client)?; + + // Ensure auth code is single use + // per IETF RFC6749 Section 10.5 (https://datatracker.ietf.org/doc/html/rfc6749#section-10.5) + let flow = Flow::take_if( + &req_params.code, + |f| matches!(f, Flow::OAuthAuthorizationCodeSupplied { .. }), + &redis, + ) + .await?; + if let Some(Flow::OAuthAuthorizationCodeSupplied { + user_id, + client_id, + authorization_id, + scopes, + original_redirect_uri, + }) = flow + { + // https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 + if req_client_id != client_id.into() { + return Err(OAuthError::error( + OAuthErrorType::UnauthorizedClient, + )); + } + + if original_redirect_uri != req_params.redirect_uri { + return Err(OAuthError::error( + OAuthErrorType::RedirectUriChanged( + req_params.redirect_uri.clone(), + ), + )); + } + + if req_params.grant_type != "authorization_code" { + return Err(OAuthError::error( + OAuthErrorType::OnlySupportsAuthorizationCodeGrant( + req_params.grant_type.clone(), + ), + )); + } + + let scopes = scopes - Scopes::restricted(); + + let mut transaction = pool.begin().await?; + let token_id = + generate_oauth_access_token_id(&mut transaction).await?; + let token = generate_access_token(); + let token_hash = OAuthAccessToken::hash_token(&token); + let time_until_expiration = OAuthAccessToken { + id: token_id, + authorization_id, + token_hash, + scopes, + created: Default::default(), + expires: Default::default(), + last_used: None, + client_id, + user_id, + } + .insert(&mut *transaction) + .await?; + + transaction.commit().await?; + + // IETF RFC6749 Section 5.1 (https://datatracker.ietf.org/doc/html/rfc6749#section-5.1) + Ok(HttpResponse::Ok() + .append_header((CACHE_CONTROL, "no-store")) + .append_header((PRAGMA, "no-cache")) + .json(TokenResponse { + access_token: token, + token_type: "Bearer".to_string(), + expires_in: time_until_expiration.num_seconds(), + })) + } else { + Err(OAuthError::error(OAuthErrorType::InvalidAuthCode)) + } + } else { + Err(OAuthError::error(OAuthErrorType::InvalidClientId( + req_client_id.into(), + ))) + } +} + +pub async fn accept_or_reject_client_scopes( + accept: bool, + req: HttpRequest, + body: web::Json, + pool: Data, + redis: Data, + session_queue: Data, +) -> Result { + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::SESSION_ACCESS]), + ) + .await? + .1; + + let flow = Flow::take_if( + &body.flow, + |f| matches!(f, Flow::InitOAuthAppApproval { .. }), + &redis, + ) + .await?; + if let Some(Flow::InitOAuthAppApproval { + user_id, + client_id, + existing_authorization_id, + scopes, + redirect_uris, + state, + }) = flow + { + if current_user.id != user_id.into() { + return Err(OAuthError::error( + AuthenticationError::InvalidCredentials, + )); + } + + if accept { + let mut transaction = pool.begin().await?; + + let auth_id = match existing_authorization_id { + Some(id) => id, + None => { + generate_oauth_client_authorization_id(&mut transaction) + .await? + } + }; + OAuthClientAuthorization::upsert( + auth_id, + client_id, + user_id, + scopes, + &mut transaction, + ) + .await?; + + transaction.commit().await?; + + init_oauth_code_flow( + user_id, + client_id.into(), + auth_id, + scopes, + redirect_uris, + state, + &redis, + ) + .await + } else { + Err(OAuthError::redirect( + OAuthErrorType::AccessDenied, + &state, + &redirect_uris.validated, + )) + } + } else { + Err(OAuthError::error(OAuthErrorType::InvalidAcceptFlowId)) + } +} + +fn authenticate_client_token_request( + req: &HttpRequest, + client: &DBOAuthClient, +) -> Result<(), OAuthError> { + let client_secret = extract_authorization_header(req)?; + let hashed_client_secret = DBOAuthClient::hash_secret(client_secret); + if client.secret_hash != hashed_client_secret { + Err(OAuthError::error( + OAuthErrorType::ClientAuthenticationFailed, + )) + } else { + Ok(()) + } +} + +fn generate_access_token() -> String { + let random = ChaCha20Rng::from_entropy() + .sample_iter(&Alphanumeric) + .take(60) + .map(char::from) + .collect::(); + format!("mro_{}", random) +} + +async fn init_oauth_code_flow( + user_id: crate::database::models::UserId, + client_id: OAuthClientId, + authorization_id: OAuthClientAuthorizationId, + scopes: Scopes, + redirect_uris: OAuthRedirectUris, + state: Option, + redis: &RedisPool, +) -> Result { + let code = Flow::OAuthAuthorizationCodeSupplied { + user_id, + client_id: client_id.into(), + authorization_id, + scopes, + original_redirect_uri: redirect_uris.original.clone(), + } + .insert(Duration::minutes(10), redis) + .await + .map_err(|e| { + OAuthError::redirect(e, &state, &redirect_uris.validated.clone()) + })?; + + let mut redirect_params = vec![format!("code={code}")]; + if let Some(state) = state { + redirect_params.push(format!("state={state}")); + } + + let redirect_uri = + append_params_to_uri(&redirect_uris.validated.0, &redirect_params); + + // IETF RFC 6749 Section 4.1.2 (https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.2) + Ok(HttpResponse::Ok() + .append_header((LOCATION, redirect_uri.clone())) + .body(redirect_uri)) +} + +fn append_params_to_uri(uri: &str, params: &[impl AsRef]) -> String { + let mut uri = uri.to_string(); + let mut connector = if uri.contains('?') { "&" } else { "?" }; + for param in params { + uri.push_str(&format!("{}{}", connector, param.as_ref())); + connector = "&"; + } + + uri +} diff --git a/apps/labrinth/src/auth/oauth/uris.rs b/apps/labrinth/src/auth/oauth/uris.rs new file mode 100644 index 000000000..edef0c9d4 --- /dev/null +++ b/apps/labrinth/src/auth/oauth/uris.rs @@ -0,0 +1,108 @@ +use super::errors::OAuthError; +use crate::auth::oauth::OAuthErrorType; +use crate::database::models::OAuthClientId; +use serde::{Deserialize, Serialize}; + +#[derive(derive_new::new, Serialize, Deserialize)] +pub struct OAuthRedirectUris { + pub original: Option, + pub validated: ValidatedRedirectUri, +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct ValidatedRedirectUri(pub String); + +impl ValidatedRedirectUri { + pub fn validate<'a>( + to_validate: &Option, + validate_against: impl IntoIterator + Clone, + client_id: OAuthClientId, + ) -> Result { + if let Some(first_client_redirect_uri) = + validate_against.clone().into_iter().next() + { + if let Some(to_validate) = to_validate { + if validate_against.into_iter().any(|uri| { + same_uri_except_query_components(uri, to_validate) + }) { + Ok(ValidatedRedirectUri(to_validate.clone())) + } else { + Err(OAuthError::error( + OAuthErrorType::RedirectUriNotConfigured( + to_validate.clone(), + ), + )) + } + } else { + Ok(ValidatedRedirectUri(first_client_redirect_uri.to_string())) + } + } else { + Err(OAuthError::error( + OAuthErrorType::ClientMissingRedirectURI { client_id }, + )) + } + } +} + +fn same_uri_except_query_components(a: &str, b: &str) -> bool { + let mut a_components = a.split('?'); + let mut b_components = b.split('?'); + a_components.next() == b_components.next() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_for_none_returns_first_valid_uri() { + let validate_against = vec!["https://modrinth.com/a"]; + + let validated = ValidatedRedirectUri::validate( + &None, + validate_against.clone(), + OAuthClientId(0), + ) + .unwrap(); + + assert_eq!(validate_against[0], validated.0); + } + + #[test] + fn validate_for_valid_uri_returns_first_matching_uri_ignoring_query_params() + { + let validate_against = vec![ + "https://modrinth.com/a?q3=p3&q4=p4", + "https://modrinth.com/a/b/c?q1=p1&q2=p2", + ]; + let to_validate = + "https://modrinth.com/a/b/c?query0=param0&query1=param1" + .to_string(); + + let validated = ValidatedRedirectUri::validate( + &Some(to_validate.clone()), + validate_against, + OAuthClientId(0), + ) + .unwrap(); + + assert_eq!(to_validate, validated.0); + } + + #[test] + fn validate_for_invalid_uri_returns_err() { + let validate_against = vec!["https://modrinth.com/a"]; + let to_validate = "https://modrinth.com/a/b".to_string(); + + let validated = ValidatedRedirectUri::validate( + &Some(to_validate), + validate_against, + OAuthClientId(0), + ); + + assert!(validated.is_err_and(|e| matches!( + e.error_type, + OAuthErrorType::RedirectUriNotConfigured(_) + ))); + } +} diff --git a/apps/labrinth/src/auth/templates/error.html b/apps/labrinth/src/auth/templates/error.html new file mode 100644 index 000000000..82f353142 --- /dev/null +++ b/apps/labrinth/src/auth/templates/error.html @@ -0,0 +1,21 @@ + + + + + + Error - Modrinth + + + +
+ +

{{ code }}

+

An error has occurred during the authentication process.

+

+ Try closing this window and signing in again. + Join our Discord server to get help if this error persists after three attempts. +

+

Debug information: {{ message }}

+
+ + diff --git a/apps/labrinth/src/auth/templates/mod.rs b/apps/labrinth/src/auth/templates/mod.rs new file mode 100644 index 000000000..6cb20174c --- /dev/null +++ b/apps/labrinth/src/auth/templates/mod.rs @@ -0,0 +1,66 @@ +use crate::auth::AuthenticationError; +use actix_web::http::StatusCode; +use actix_web::{HttpResponse, ResponseError}; +use std::fmt::{Debug, Display, Formatter}; + +pub struct Success<'a> { + pub icon: &'a str, + pub name: &'a str, +} + +impl<'a> Success<'a> { + pub fn render(self) -> HttpResponse { + let html = include_str!("success.html"); + + HttpResponse::Ok() + .append_header(("Content-Type", "text/html; charset=utf-8")) + .body( + html.replace("{{ icon }}", self.icon) + .replace("{{ name }}", self.name), + ) + } +} + +#[derive(Debug)] +pub struct ErrorPage { + pub code: StatusCode, + pub message: String, +} + +impl Display for ErrorPage { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let html = include_str!("error.html") + .replace("{{ code }}", &self.code.to_string()) + .replace("{{ message }}", &self.message); + write!(f, "{}", html)?; + + Ok(()) + } +} + +impl ErrorPage { + pub fn render(&self) -> HttpResponse { + HttpResponse::Ok() + .append_header(("Content-Type", "text/html; charset=utf-8")) + .body(self.to_string()) + } +} + +impl actix_web::ResponseError for ErrorPage { + fn status_code(&self) -> StatusCode { + self.code + } + + fn error_response(&self) -> HttpResponse { + self.render() + } +} + +impl From for ErrorPage { + fn from(item: AuthenticationError) -> Self { + ErrorPage { + code: item.status_code(), + message: item.to_string(), + } + } +} diff --git a/apps/labrinth/src/auth/templates/success.html b/apps/labrinth/src/auth/templates/success.html new file mode 100644 index 000000000..471811867 --- /dev/null +++ b/apps/labrinth/src/auth/templates/success.html @@ -0,0 +1,16 @@ + + + + + + Login - Modrinth + + + +
+ +

Login Successful

+

Hey, {{ name }}! You can now safely close this tab.

+
+ + \ No newline at end of file diff --git a/apps/labrinth/src/auth/validate.rs b/apps/labrinth/src/auth/validate.rs new file mode 100644 index 000000000..e69d19431 --- /dev/null +++ b/apps/labrinth/src/auth/validate.rs @@ -0,0 +1,188 @@ +use super::AuthProvider; +use crate::auth::AuthenticationError; +use crate::database::models::user_item; +use crate::database::redis::RedisPool; +use crate::models::pats::Scopes; +use crate::models::users::User; +use crate::queue::session::AuthQueue; +use crate::routes::internal::session::get_session_metadata; +use actix_web::http::header::{HeaderValue, AUTHORIZATION}; +use actix_web::HttpRequest; +use chrono::Utc; + +pub async fn get_user_from_headers<'a, E>( + req: &HttpRequest, + executor: E, + redis: &RedisPool, + session_queue: &AuthQueue, + required_scopes: Option<&[Scopes]>, +) -> Result<(Scopes, User), AuthenticationError> +where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, +{ + // Fetch DB user record and minos user from headers + let (scopes, db_user) = get_user_record_from_bearer_token( + req, + None, + executor, + redis, + session_queue, + ) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + + let user = User::from_full(db_user); + + if let Some(required_scopes) = required_scopes { + for scope in required_scopes { + if !scopes.contains(*scope) { + return Err(AuthenticationError::InvalidCredentials); + } + } + } + + Ok((scopes, user)) +} + +pub async fn get_user_record_from_bearer_token<'a, 'b, E>( + req: &HttpRequest, + token: Option<&str>, + executor: E, + redis: &RedisPool, + session_queue: &AuthQueue, +) -> Result, AuthenticationError> +where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, +{ + let token = if let Some(token) = token { + token + } else { + extract_authorization_header(req)? + }; + + let possible_user = match token.split_once('_') { + Some(("mrp", _)) => { + let pat = + crate::database::models::pat_item::PersonalAccessToken::get( + token, executor, redis, + ) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + + if pat.expires < Utc::now() { + return Err(AuthenticationError::InvalidCredentials); + } + + let user = + user_item::User::get_id(pat.user_id, executor, redis).await?; + + session_queue.add_pat(pat.id).await; + + user.map(|x| (pat.scopes, x)) + } + Some(("mra", _)) => { + let session = crate::database::models::session_item::Session::get( + token, executor, redis, + ) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + + if session.expires < Utc::now() { + return Err(AuthenticationError::InvalidCredentials); + } + + let user = + user_item::User::get_id(session.user_id, executor, redis) + .await?; + + let rate_limit_ignore = dotenvy::var("RATE_LIMIT_IGNORE_KEY")?; + if !req + .headers() + .get("x-ratelimit-key") + .and_then(|x| x.to_str().ok()) + .map(|x| x == rate_limit_ignore) + .unwrap_or(false) + { + let metadata = get_session_metadata(req).await?; + session_queue.add_session(session.id, metadata).await; + } + + user.map(|x| (Scopes::all(), x)) + } + Some(("mro", _)) => { + use crate::database::models::oauth_token_item::OAuthAccessToken; + + let hash = OAuthAccessToken::hash_token(token); + let access_token = + crate::database::models::oauth_token_item::OAuthAccessToken::get(hash, executor) + .await? + .ok_or(AuthenticationError::InvalidCredentials)?; + + if access_token.expires < Utc::now() { + return Err(AuthenticationError::InvalidCredentials); + } + + let user = + user_item::User::get_id(access_token.user_id, executor, redis) + .await?; + + session_queue.add_oauth_access_token(access_token.id).await; + + user.map(|u| (access_token.scopes, u)) + } + Some(("github", _)) | Some(("gho", _)) | Some(("ghp", _)) => { + let user = AuthProvider::GitHub.get_user(token).await?; + let id = + AuthProvider::GitHub.get_user_id(&user.id, executor).await?; + + let user = user_item::User::get_id( + id.ok_or_else(|| AuthenticationError::InvalidCredentials)?, + executor, + redis, + ) + .await?; + + user.map(|x| ((Scopes::all() ^ Scopes::restricted()), x)) + } + _ => return Err(AuthenticationError::InvalidAuthMethod), + }; + Ok(possible_user) +} + +pub fn extract_authorization_header( + req: &HttpRequest, +) -> Result<&str, AuthenticationError> { + let headers = req.headers(); + let token_val: Option<&HeaderValue> = headers.get(AUTHORIZATION); + token_val + .ok_or_else(|| AuthenticationError::InvalidAuthMethod)? + .to_str() + .map_err(|_| AuthenticationError::InvalidCredentials) +} + +pub async fn check_is_moderator_from_headers<'a, 'b, E>( + req: &HttpRequest, + executor: E, + redis: &RedisPool, + session_queue: &AuthQueue, + required_scopes: Option<&[Scopes]>, +) -> Result +where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, +{ + let user = get_user_from_headers( + req, + executor, + redis, + session_queue, + required_scopes, + ) + .await? + .1; + + if user.role.is_mod() { + Ok(user) + } else { + Err(AuthenticationError::InvalidCredentials) + } +} diff --git a/apps/labrinth/src/clickhouse/fetch.rs b/apps/labrinth/src/clickhouse/fetch.rs new file mode 100644 index 000000000..b0245075b --- /dev/null +++ b/apps/labrinth/src/clickhouse/fetch.rs @@ -0,0 +1,164 @@ +use std::sync::Arc; + +use crate::{models::ids::ProjectId, routes::ApiError}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(clickhouse::Row, Serialize, Deserialize, Clone, Debug)] +pub struct ReturnIntervals { + pub time: u32, + pub id: u64, + pub total: u64, +} + +#[derive(clickhouse::Row, Serialize, Deserialize, Clone, Debug)] +pub struct ReturnCountry { + pub country: String, + pub id: u64, + pub total: u64, +} + +// Only one of project_id or version_id should be used +// Fetches playtimes as a Vec of ReturnPlaytimes +pub async fn fetch_playtimes( + projects: Vec, + start_date: DateTime, + end_date: DateTime, + resolution_minute: u32, + client: Arc, +) -> Result, ApiError> { + let query = client + .query( + " + SELECT + toUnixTimestamp(toStartOfInterval(recorded, toIntervalMinute(?))) AS time, + project_id AS id, + SUM(seconds) AS total + FROM playtime + WHERE recorded BETWEEN ? AND ? + AND project_id IN ? + GROUP BY + time, + project_id + ", + ) + .bind(resolution_minute) + .bind(start_date.timestamp()) + .bind(end_date.timestamp()) + .bind(projects.iter().map(|x| x.0).collect::>()); + + Ok(query.fetch_all().await?) +} + +// Fetches views as a Vec of ReturnViews +pub async fn fetch_views( + projects: Vec, + start_date: DateTime, + end_date: DateTime, + resolution_minutes: u32, + client: Arc, +) -> Result, ApiError> { + let query = client + .query( + " + SELECT + toUnixTimestamp(toStartOfInterval(recorded, toIntervalMinute(?))) AS time, + project_id AS id, + count(1) AS total + FROM views + WHERE recorded BETWEEN ? AND ? + AND project_id IN ? + GROUP BY + time, project_id + ", + ) + .bind(resolution_minutes) + .bind(start_date.timestamp()) + .bind(end_date.timestamp()) + .bind(projects.iter().map(|x| x.0).collect::>()); + + Ok(query.fetch_all().await?) +} + +// Fetches downloads as a Vec of ReturnDownloads +pub async fn fetch_downloads( + projects: Vec, + start_date: DateTime, + end_date: DateTime, + resolution_minutes: u32, + client: Arc, +) -> Result, ApiError> { + let query = client + .query( + " + SELECT + toUnixTimestamp(toStartOfInterval(recorded, toIntervalMinute(?))) AS time, + project_id as id, + count(1) AS total + FROM downloads + WHERE recorded BETWEEN ? AND ? + AND project_id IN ? + GROUP BY time, project_id + ", + ) + .bind(resolution_minutes) + .bind(start_date.timestamp()) + .bind(end_date.timestamp()) + .bind(projects.iter().map(|x| x.0).collect::>()); + + Ok(query.fetch_all().await?) +} + +pub async fn fetch_countries_downloads( + projects: Vec, + start_date: DateTime, + end_date: DateTime, + client: Arc, +) -> Result, ApiError> { + let query = client + .query( + " + SELECT + country, + project_id, + count(1) AS total + FROM downloads + WHERE recorded BETWEEN ? AND ? AND project_id IN ? + GROUP BY + country, + project_id + ", + ) + .bind(start_date.timestamp()) + .bind(end_date.timestamp()) + .bind(projects.iter().map(|x| x.0).collect::>()); + + Ok(query.fetch_all().await?) +} + +pub async fn fetch_countries_views( + projects: Vec, + start_date: DateTime, + end_date: DateTime, + client: Arc, +) -> Result, ApiError> { + let query = client + .query( + " + SELECT + country, + project_id, + count(1) AS total + FROM views + WHERE recorded BETWEEN ? AND ? AND project_id IN ? + GROUP BY + country, + project_id + ", + ) + .bind(start_date.timestamp()) + .bind(end_date.timestamp()) + .bind(projects.iter().map(|x| x.0).collect::>()); + + Ok(query.fetch_all().await?) +} diff --git a/apps/labrinth/src/clickhouse/mod.rs b/apps/labrinth/src/clickhouse/mod.rs new file mode 100644 index 000000000..f74daa6a0 --- /dev/null +++ b/apps/labrinth/src/clickhouse/mod.rs @@ -0,0 +1,112 @@ +use hyper::client::HttpConnector; +use hyper_tls::{native_tls, HttpsConnector}; + +mod fetch; + +pub use fetch::*; + +pub async fn init_client() -> clickhouse::error::Result { + init_client_with_database(&dotenvy::var("CLICKHOUSE_DATABASE").unwrap()) + .await +} + +pub async fn init_client_with_database( + database: &str, +) -> clickhouse::error::Result { + let client = { + let mut http_connector = HttpConnector::new(); + http_connector.enforce_http(false); // allow https URLs + + let tls_connector = + native_tls::TlsConnector::builder().build().unwrap().into(); + let https_connector = + HttpsConnector::from((http_connector, tls_connector)); + let hyper_client = + hyper::client::Client::builder().build(https_connector); + + clickhouse::Client::with_http_client(hyper_client) + .with_url(dotenvy::var("CLICKHOUSE_URL").unwrap()) + .with_user(dotenvy::var("CLICKHOUSE_USER").unwrap()) + .with_password(dotenvy::var("CLICKHOUSE_PASSWORD").unwrap()) + }; + + client + .query(&format!("CREATE DATABASE IF NOT EXISTS {database}")) + .execute() + .await?; + + client + .query(&format!( + " + CREATE TABLE IF NOT EXISTS {database}.views + ( + recorded DateTime64(4), + domain String, + site_path String, + + user_id UInt64, + project_id UInt64, + monetized Bool DEFAULT True, + + ip IPv6, + country String, + user_agent String, + headers Array(Tuple(String, String)) + ) + ENGINE = MergeTree() + PRIMARY KEY (project_id, recorded, ip) + " + )) + .execute() + .await?; + + client + .query(&format!( + " + CREATE TABLE IF NOT EXISTS {database}.downloads + ( + recorded DateTime64(4), + domain String, + site_path String, + + user_id UInt64, + project_id UInt64, + version_id UInt64, + + ip IPv6, + country String, + user_agent String, + headers Array(Tuple(String, String)) + ) + ENGINE = MergeTree() + PRIMARY KEY (project_id, recorded, ip) + " + )) + .execute() + .await?; + + client + .query(&format!( + " + CREATE TABLE IF NOT EXISTS {database}.playtime + ( + recorded DateTime64(4), + seconds UInt64, + + user_id UInt64, + project_id UInt64, + version_id UInt64, + + loader String, + game_version String, + parent UInt64 + ) + ENGINE = MergeTree() + PRIMARY KEY (project_id, recorded, user_id) + " + )) + .execute() + .await?; + + Ok(client.with_database(database)) +} diff --git a/apps/labrinth/src/database/mod.rs b/apps/labrinth/src/database/mod.rs new file mode 100644 index 000000000..2bba7dca6 --- /dev/null +++ b/apps/labrinth/src/database/mod.rs @@ -0,0 +1,8 @@ +pub mod models; +mod postgres_database; +pub mod redis; +pub use models::Image; +pub use models::Project; +pub use models::Version; +pub use postgres_database::check_for_migrations; +pub use postgres_database::connect; diff --git a/apps/labrinth/src/database/models/categories.rs b/apps/labrinth/src/database/models/categories.rs new file mode 100644 index 000000000..90abf1adb --- /dev/null +++ b/apps/labrinth/src/database/models/categories.rs @@ -0,0 +1,318 @@ +use std::collections::HashMap; + +use crate::database::redis::RedisPool; + +use super::ids::*; +use super::DatabaseError; +use futures::TryStreamExt; +use serde::{Deserialize, Serialize}; + +const TAGS_NAMESPACE: &str = "tags"; + +pub struct ProjectType { + pub id: ProjectTypeId, + pub name: String, +} + +#[derive(Serialize, Deserialize)] +pub struct Category { + pub id: CategoryId, + pub category: String, + pub project_type: String, + pub icon: String, + pub header: String, +} + +pub struct ReportType { + pub id: ReportTypeId, + pub report_type: String, +} + +#[derive(Serialize, Deserialize)] +pub struct LinkPlatform { + pub id: LinkPlatformId, + pub name: String, + pub donation: bool, +} + +impl Category { + // Gets hashmap of category ids matching a name + // Multiple categories can have the same name, but different project types, so we need to return a hashmap + // ProjectTypeId -> CategoryId + pub async fn get_ids<'a, E>( + name: &str, + exec: E, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT id, project_type FROM categories + WHERE category = $1 + ", + name, + ) + .fetch_all(exec) + .await?; + + let mut map = HashMap::new(); + for r in result { + map.insert(ProjectTypeId(r.project_type), CategoryId(r.id)); + } + + Ok(map) + } + + pub async fn get_id_project<'a, E>( + name: &str, + project_type: ProjectTypeId, + exec: E, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT id FROM categories + WHERE category = $1 AND project_type = $2 + ", + name, + project_type as ProjectTypeId + ) + .fetch_optional(exec) + .await?; + + Ok(result.map(|r| CategoryId(r.id))) + } + + pub async fn list<'a, E>( + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let mut redis = redis.connect().await?; + + let res: Option> = redis + .get_deserialized_from_json(TAGS_NAMESPACE, "category") + .await?; + + if let Some(res) = res { + return Ok(res); + } + + let result = sqlx::query!( + " + SELECT c.id id, c.category category, c.icon icon, c.header category_header, pt.name project_type + FROM categories c + INNER JOIN project_types pt ON c.project_type = pt.id + ORDER BY c.ordering, c.category + " + ) + .fetch(exec) + .map_ok(|c| Category { + id: CategoryId(c.id), + category: c.category, + project_type: c.project_type, + icon: c.icon, + header: c.category_header + }) + .try_collect::>() + .await?; + + redis + .set_serialized_to_json(TAGS_NAMESPACE, "category", &result, None) + .await?; + + Ok(result) + } +} + +impl LinkPlatform { + pub async fn get_id<'a, E>( + id: &str, + exec: E, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT id FROM link_platforms + WHERE name = $1 + ", + id + ) + .fetch_optional(exec) + .await?; + + Ok(result.map(|r| LinkPlatformId(r.id))) + } + + pub async fn list<'a, E>( + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let mut redis = redis.connect().await?; + + let res: Option> = redis + .get_deserialized_from_json(TAGS_NAMESPACE, "link_platform") + .await?; + + if let Some(res) = res { + return Ok(res); + } + + let result = sqlx::query!( + " + SELECT id, name, donation FROM link_platforms + " + ) + .fetch(exec) + .map_ok(|c| LinkPlatform { + id: LinkPlatformId(c.id), + name: c.name, + donation: c.donation, + }) + .try_collect::>() + .await?; + + redis + .set_serialized_to_json( + TAGS_NAMESPACE, + "link_platform", + &result, + None, + ) + .await?; + + Ok(result) + } +} + +impl ReportType { + pub async fn get_id<'a, E>( + name: &str, + exec: E, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT id FROM report_types + WHERE name = $1 + ", + name + ) + .fetch_optional(exec) + .await?; + + Ok(result.map(|r| ReportTypeId(r.id))) + } + + pub async fn list<'a, E>( + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let mut redis = redis.connect().await?; + + let res: Option> = redis + .get_deserialized_from_json(TAGS_NAMESPACE, "report_type") + .await?; + + if let Some(res) = res { + return Ok(res); + } + + let result = sqlx::query!( + " + SELECT name FROM report_types + " + ) + .fetch(exec) + .map_ok(|c| c.name) + .try_collect::>() + .await?; + + redis + .set_serialized_to_json( + TAGS_NAMESPACE, + "report_type", + &result, + None, + ) + .await?; + + Ok(result) + } +} + +impl ProjectType { + pub async fn get_id<'a, E>( + name: &str, + exec: E, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT id FROM project_types + WHERE name = $1 + ", + name + ) + .fetch_optional(exec) + .await?; + + Ok(result.map(|r| ProjectTypeId(r.id))) + } + + pub async fn list<'a, E>( + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let mut redis = redis.connect().await?; + + let res: Option> = redis + .get_deserialized_from_json(TAGS_NAMESPACE, "project_type") + .await?; + + if let Some(res) = res { + return Ok(res); + } + + let result = sqlx::query!( + " + SELECT name FROM project_types + " + ) + .fetch(exec) + .map_ok(|c| c.name) + .try_collect::>() + .await?; + + redis + .set_serialized_to_json( + TAGS_NAMESPACE, + "project_type", + &result, + None, + ) + .await?; + + Ok(result) + } +} diff --git a/apps/labrinth/src/database/models/charge_item.rs b/apps/labrinth/src/database/models/charge_item.rs new file mode 100644 index 000000000..80d75de58 --- /dev/null +++ b/apps/labrinth/src/database/models/charge_item.rs @@ -0,0 +1,200 @@ +use crate::database::models::{ + ChargeId, DatabaseError, ProductPriceId, UserId, UserSubscriptionId, +}; +use crate::models::billing::{ChargeStatus, ChargeType, PriceDuration}; +use chrono::{DateTime, Utc}; +use std::convert::{TryFrom, TryInto}; + +pub struct ChargeItem { + pub id: ChargeId, + pub user_id: UserId, + pub price_id: ProductPriceId, + pub amount: i64, + pub currency_code: String, + pub status: ChargeStatus, + pub due: DateTime, + pub last_attempt: Option>, + + pub type_: ChargeType, + pub subscription_id: Option, + pub subscription_interval: Option, +} + +struct ChargeResult { + id: i64, + user_id: i64, + price_id: i64, + amount: i64, + currency_code: String, + status: String, + due: DateTime, + last_attempt: Option>, + charge_type: String, + subscription_id: Option, + subscription_interval: Option, +} + +impl TryFrom for ChargeItem { + type Error = serde_json::Error; + + fn try_from(r: ChargeResult) -> Result { + Ok(ChargeItem { + id: ChargeId(r.id), + user_id: UserId(r.user_id), + price_id: ProductPriceId(r.price_id), + amount: r.amount, + currency_code: r.currency_code, + status: ChargeStatus::from_string(&r.status), + due: r.due, + last_attempt: r.last_attempt, + type_: ChargeType::from_string(&r.charge_type), + subscription_id: r.subscription_id.map(UserSubscriptionId), + subscription_interval: r + .subscription_interval + .map(|x| PriceDuration::from_string(&x)), + }) + } +} + +macro_rules! select_charges_with_predicate { + ($predicate:tt, $param:ident) => { + sqlx::query_as!( + ChargeResult, + r#" + SELECT id, user_id, price_id, amount, currency_code, status, due, last_attempt, charge_type, subscription_id, subscription_interval + FROM charges + "# + + $predicate, + $param + ) + }; +} + +impl ChargeItem { + pub async fn upsert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result { + sqlx::query!( + r#" + INSERT INTO charges (id, user_id, price_id, amount, currency_code, charge_type, status, due, last_attempt, subscription_id, subscription_interval) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + ON CONFLICT (id) + DO UPDATE + SET status = EXCLUDED.status, + last_attempt = EXCLUDED.last_attempt, + due = EXCLUDED.due, + subscription_id = EXCLUDED.subscription_id, + subscription_interval = EXCLUDED.subscription_interval + "#, + self.id.0, + self.user_id.0, + self.price_id.0, + self.amount, + self.currency_code, + self.type_.as_str(), + self.status.as_str(), + self.due, + self.last_attempt, + self.subscription_id.map(|x| x.0), + self.subscription_interval.map(|x| x.as_str()), + ) + .execute(&mut **transaction) + .await?; + + Ok(self.id) + } + + pub async fn get( + id: ChargeId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let id = id.0; + let res = select_charges_with_predicate!("WHERE id = $1", id) + .fetch_optional(exec) + .await?; + + Ok(res.and_then(|r| r.try_into().ok())) + } + + pub async fn get_from_user( + user_id: UserId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let user_id = user_id.0; + let res = select_charges_with_predicate!( + "WHERE user_id = $1 ORDER BY due DESC", + user_id + ) + .fetch_all(exec) + .await?; + + Ok(res + .into_iter() + .map(|r| r.try_into()) + .collect::, serde_json::Error>>()?) + } + + pub async fn get_open_subscription( + user_subscription_id: UserSubscriptionId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let user_subscription_id = user_subscription_id.0; + let res = select_charges_with_predicate!( + "WHERE subscription_id = $1 AND (status = 'open' OR status = 'cancelled')", + user_subscription_id + ) + .fetch_optional(exec) + .await?; + + Ok(res.and_then(|r| r.try_into().ok())) + } + + pub async fn get_chargeable( + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let now = Utc::now(); + + let res = select_charges_with_predicate!("WHERE (status = 'open' AND due < $1) OR (status = 'failed' AND last_attempt < $1 - INTERVAL '2 days')", now) + .fetch_all(exec) + .await?; + + Ok(res + .into_iter() + .map(|r| r.try_into()) + .collect::, serde_json::Error>>()?) + } + + pub async fn get_unprovision( + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let now = Utc::now(); + + let res = + select_charges_with_predicate!("WHERE (status = 'cancelled' AND due < $1) OR (status = 'failed' AND last_attempt < $1 - INTERVAL '2 days')", now) + .fetch_all(exec) + .await?; + + Ok(res + .into_iter() + .map(|r| r.try_into()) + .collect::, serde_json::Error>>()?) + } + + pub async fn remove( + id: ChargeId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + sqlx::query!( + " + DELETE FROM charges + WHERE id = $1 + ", + id.0 as i64 + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } +} diff --git a/apps/labrinth/src/database/models/collection_item.rs b/apps/labrinth/src/database/models/collection_item.rs new file mode 100644 index 000000000..0a4c440b7 --- /dev/null +++ b/apps/labrinth/src/database/models/collection_item.rs @@ -0,0 +1,224 @@ +use super::ids::*; +use crate::database::models; +use crate::database::models::DatabaseError; +use crate::database::redis::RedisPool; +use crate::models::collections::CollectionStatus; +use chrono::{DateTime, Utc}; +use dashmap::DashMap; +use futures::TryStreamExt; +use serde::{Deserialize, Serialize}; + +const COLLECTIONS_NAMESPACE: &str = "collections"; + +#[derive(Clone)] +pub struct CollectionBuilder { + pub collection_id: CollectionId, + pub user_id: UserId, + pub name: String, + pub description: Option, + pub status: CollectionStatus, + pub projects: Vec, +} + +impl CollectionBuilder { + pub async fn insert( + self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result { + let collection_struct = Collection { + id: self.collection_id, + name: self.name, + user_id: self.user_id, + description: self.description, + created: Utc::now(), + updated: Utc::now(), + icon_url: None, + raw_icon_url: None, + color: None, + status: self.status, + projects: self.projects, + }; + collection_struct.insert(transaction).await?; + + Ok(self.collection_id) + } +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Collection { + pub id: CollectionId, + pub user_id: UserId, + pub name: String, + pub description: Option, + pub created: DateTime, + pub updated: DateTime, + pub icon_url: Option, + pub raw_icon_url: Option, + pub color: Option, + pub status: CollectionStatus, + pub projects: Vec, +} + +impl Collection { + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + sqlx::query!( + " + INSERT INTO collections ( + id, user_id, name, description, + created, icon_url, raw_icon_url, status + ) + VALUES ( + $1, $2, $3, $4, + $5, $6, $7, $8 + ) + ", + self.id as CollectionId, + self.user_id as UserId, + &self.name, + self.description.as_ref(), + self.created, + self.icon_url.as_ref(), + self.raw_icon_url.as_ref(), + self.status.to_string(), + ) + .execute(&mut **transaction) + .await?; + + let (collection_ids, project_ids): (Vec<_>, Vec<_>) = + self.projects.iter().map(|p| (self.id.0, p.0)).unzip(); + sqlx::query!( + " + INSERT INTO collections_mods (collection_id, mod_id) + SELECT * FROM UNNEST($1::bigint[], $2::bigint[]) + ON CONFLICT DO NOTHING + ", + &collection_ids[..], + &project_ids[..], + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } + + pub async fn remove( + id: CollectionId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &RedisPool, + ) -> Result, DatabaseError> { + let collection = Self::get(id, &mut **transaction, redis).await?; + + if let Some(collection) = collection { + sqlx::query!( + " + DELETE FROM collections_mods + WHERE collection_id = $1 + ", + id as CollectionId, + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + DELETE FROM collections + WHERE id = $1 + ", + id as CollectionId, + ) + .execute(&mut **transaction) + .await?; + + models::Collection::clear_cache(collection.id, redis).await?; + + Ok(Some(())) + } else { + Ok(None) + } + } + + pub async fn get<'a, 'b, E>( + id: CollectionId, + executor: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + Collection::get_many(&[id], executor, redis) + .await + .map(|x| x.into_iter().next()) + } + + pub async fn get_many<'a, E>( + collection_ids: &[CollectionId], + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let val = redis + .get_cached_keys( + COLLECTIONS_NAMESPACE, + &collection_ids.iter().map(|x| x.0).collect::>(), + |collection_ids| async move { + let collections = sqlx::query!( + " + SELECT c.id id, c.name name, c.description description, + c.icon_url icon_url, c.raw_icon_url raw_icon_url, c.color color, c.created created, c.user_id user_id, + c.updated updated, c.status status, + ARRAY_AGG(DISTINCT cm.mod_id) filter (where cm.mod_id is not null) mods + FROM collections c + LEFT JOIN collections_mods cm ON cm.collection_id = c.id + WHERE c.id = ANY($1) + GROUP BY c.id; + ", + &collection_ids, + ) + .fetch(exec) + .try_fold(DashMap::new(), |acc, m| { + let collection = Collection { + id: CollectionId(m.id), + user_id: UserId(m.user_id), + name: m.name.clone(), + description: m.description.clone(), + icon_url: m.icon_url.clone(), + raw_icon_url: m.raw_icon_url.clone(), + color: m.color.map(|x| x as u32), + created: m.created, + updated: m.updated, + status: CollectionStatus::from_string(&m.status), + projects: m + .mods + .unwrap_or_default() + .into_iter() + .map(ProjectId) + .collect(), + }; + + acc.insert(m.id, collection); + async move { Ok(acc) } + }) + .await?; + + Ok(collections) + }, + ) + .await?; + + Ok(val) + } + + pub async fn clear_cache( + id: CollectionId, + redis: &RedisPool, + ) -> Result<(), DatabaseError> { + let mut redis = redis.connect().await?; + + redis.delete(COLLECTIONS_NAMESPACE, id.0).await?; + Ok(()) + } +} diff --git a/apps/labrinth/src/database/models/flow_item.rs b/apps/labrinth/src/database/models/flow_item.rs new file mode 100644 index 000000000..95e66b6c0 --- /dev/null +++ b/apps/labrinth/src/database/models/flow_item.rs @@ -0,0 +1,115 @@ +use super::ids::*; +use crate::auth::oauth::uris::OAuthRedirectUris; +use crate::auth::AuthProvider; +use crate::database::models::DatabaseError; +use crate::database::redis::RedisPool; +use crate::models::pats::Scopes; +use chrono::Duration; +use rand::distributions::Alphanumeric; +use rand::Rng; +use rand_chacha::rand_core::SeedableRng; +use rand_chacha::ChaCha20Rng; +use serde::{Deserialize, Serialize}; + +const FLOWS_NAMESPACE: &str = "flows"; + +#[derive(Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Flow { + OAuth { + user_id: Option, + url: Option, + provider: AuthProvider, + }, + Login2FA { + user_id: UserId, + }, + Initialize2FA { + user_id: UserId, + secret: String, + }, + ForgotPassword { + user_id: UserId, + }, + ConfirmEmail { + user_id: UserId, + confirm_email: String, + }, + MinecraftAuth, + InitOAuthAppApproval { + user_id: UserId, + client_id: OAuthClientId, + existing_authorization_id: Option, + scopes: Scopes, + redirect_uris: OAuthRedirectUris, + state: Option, + }, + OAuthAuthorizationCodeSupplied { + user_id: UserId, + client_id: OAuthClientId, + authorization_id: OAuthClientAuthorizationId, + scopes: Scopes, + original_redirect_uri: Option, // Needed for https://datatracker.ietf.org/doc/html/rfc6749#section-4.1.3 + }, +} + +impl Flow { + pub async fn insert( + &self, + expires: Duration, + redis: &RedisPool, + ) -> Result { + let mut redis = redis.connect().await?; + + let flow = ChaCha20Rng::from_entropy() + .sample_iter(&Alphanumeric) + .take(32) + .map(char::from) + .collect::(); + + redis + .set_serialized_to_json( + FLOWS_NAMESPACE, + &flow, + &self, + Some(expires.num_seconds()), + ) + .await?; + Ok(flow) + } + + pub async fn get( + id: &str, + redis: &RedisPool, + ) -> Result, DatabaseError> { + let mut redis = redis.connect().await?; + + redis.get_deserialized_from_json(FLOWS_NAMESPACE, id).await + } + + /// Gets the flow and removes it from the cache, but only removes if the flow was present and the predicate returned true + /// The predicate should validate that the flow being removed is the correct one, as a security measure + pub async fn take_if( + id: &str, + predicate: impl FnOnce(&Flow) -> bool, + redis: &RedisPool, + ) -> Result, DatabaseError> { + let flow = Self::get(id, redis).await?; + if let Some(flow) = flow.as_ref() { + if predicate(flow) { + Self::remove(id, redis).await?; + } + } + Ok(flow) + } + + pub async fn remove( + id: &str, + redis: &RedisPool, + ) -> Result, DatabaseError> { + let mut redis = redis.connect().await?; + + redis.delete(FLOWS_NAMESPACE, id).await?; + Ok(Some(())) + } +} diff --git a/apps/labrinth/src/database/models/ids.rs b/apps/labrinth/src/database/models/ids.rs new file mode 100644 index 000000000..aa1b99895 --- /dev/null +++ b/apps/labrinth/src/database/models/ids.rs @@ -0,0 +1,662 @@ +use super::DatabaseError; +use crate::models::ids::base62_impl::to_base62; +use crate::models::ids::{random_base62_rng, random_base62_rng_range}; +use censor::Censor; +use rand::SeedableRng; +use rand_chacha::ChaCha20Rng; +use serde::{Deserialize, Serialize}; +use sqlx::sqlx_macros::Type; + +const ID_RETRY_COUNT: usize = 20; + +macro_rules! generate_ids { + ($vis:vis $function_name:ident, $return_type:ty, $id_length:expr, $select_stmnt:literal, $id_function:expr) => { + $vis async fn $function_name( + con: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<$return_type, DatabaseError> { + let mut rng = ChaCha20Rng::from_entropy(); + let length = $id_length; + let mut id = random_base62_rng(&mut rng, length); + let mut retry_count = 0; + let censor = Censor::Standard + Censor::Sex; + + // Check if ID is unique + loop { + let results = sqlx::query!($select_stmnt, id as i64) + .fetch_one(&mut **con) + .await?; + + if results.exists.unwrap_or(true) || censor.check(&*to_base62(id)) { + id = random_base62_rng(&mut rng, length); + } else { + break; + } + + retry_count += 1; + if retry_count > ID_RETRY_COUNT { + return Err(DatabaseError::RandomId); + } + } + + Ok($id_function(id as i64)) + } + }; +} + +macro_rules! generate_bulk_ids { + ($vis:vis $function_name:ident, $return_type:ty, $select_stmnt:literal, $id_function:expr) => { + $vis async fn $function_name( + count: usize, + con: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result, DatabaseError> { + let mut rng = rand::thread_rng(); + let mut retry_count = 0; + + // Check if ID is unique + loop { + let base = random_base62_rng_range(&mut rng, 1, 10) as i64; + let ids = (0..count).map(|x| base + x as i64).collect::>(); + + let results = sqlx::query!($select_stmnt, &ids) + .fetch_one(&mut **con) + .await?; + + if !results.exists.unwrap_or(true) { + return Ok(ids.into_iter().map(|x| $id_function(x)).collect()); + } + + retry_count += 1; + if retry_count > ID_RETRY_COUNT { + return Err(DatabaseError::RandomId); + } + } + } + }; +} + +generate_ids!( + pub generate_project_id, + ProjectId, + 8, + "SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1)", + ProjectId +); +generate_ids!( + pub generate_version_id, + VersionId, + 8, + "SELECT EXISTS(SELECT 1 FROM versions WHERE id=$1)", + VersionId +); +generate_ids!( + pub generate_team_id, + TeamId, + 8, + "SELECT EXISTS(SELECT 1 FROM teams WHERE id=$1)", + TeamId +); +generate_ids!( + pub generate_organization_id, + OrganizationId, + 8, + "SELECT EXISTS(SELECT 1 FROM organizations WHERE id=$1)", + OrganizationId +); +generate_ids!( + pub generate_collection_id, + CollectionId, + 8, + "SELECT EXISTS(SELECT 1 FROM collections WHERE id=$1)", + CollectionId +); +generate_ids!( + pub generate_file_id, + FileId, + 8, + "SELECT EXISTS(SELECT 1 FROM files WHERE id=$1)", + FileId +); +generate_ids!( + pub generate_team_member_id, + TeamMemberId, + 8, + "SELECT EXISTS(SELECT 1 FROM team_members WHERE id=$1)", + TeamMemberId +); +generate_ids!( + pub generate_pat_id, + PatId, + 8, + "SELECT EXISTS(SELECT 1 FROM pats WHERE id=$1)", + PatId +); + +generate_ids!( + pub generate_user_id, + UserId, + 8, + "SELECT EXISTS(SELECT 1 FROM users WHERE id=$1)", + UserId +); +generate_ids!( + pub generate_report_id, + ReportId, + 8, + "SELECT EXISTS(SELECT 1 FROM reports WHERE id=$1)", + ReportId +); + +generate_ids!( + pub generate_notification_id, + NotificationId, + 8, + "SELECT EXISTS(SELECT 1 FROM notifications WHERE id=$1)", + NotificationId +); + +generate_bulk_ids!( + pub generate_many_notification_ids, + NotificationId, + "SELECT EXISTS(SELECT 1 FROM notifications WHERE id = ANY($1))", + NotificationId +); + +generate_ids!( + pub generate_thread_id, + ThreadId, + 8, + "SELECT EXISTS(SELECT 1 FROM threads WHERE id=$1)", + ThreadId +); +generate_ids!( + pub generate_thread_message_id, + ThreadMessageId, + 8, + "SELECT EXISTS(SELECT 1 FROM threads_messages WHERE id=$1)", + ThreadMessageId +); + +generate_ids!( + pub generate_session_id, + SessionId, + 8, + "SELECT EXISTS(SELECT 1 FROM sessions WHERE id=$1)", + SessionId +); + +generate_ids!( + pub generate_image_id, + ImageId, + 8, + "SELECT EXISTS(SELECT 1 FROM uploaded_images WHERE id=$1)", + ImageId +); + +generate_ids!( + pub generate_oauth_client_authorization_id, + OAuthClientAuthorizationId, + 8, + "SELECT EXISTS(SELECT 1 FROM oauth_client_authorizations WHERE id=$1)", + OAuthClientAuthorizationId +); + +generate_ids!( + pub generate_oauth_client_id, + OAuthClientId, + 8, + "SELECT EXISTS(SELECT 1 FROM oauth_clients WHERE id=$1)", + OAuthClientId +); + +generate_ids!( + pub generate_oauth_redirect_id, + OAuthRedirectUriId, + 8, + "SELECT EXISTS(SELECT 1 FROM oauth_client_redirect_uris WHERE id=$1)", + OAuthRedirectUriId +); + +generate_ids!( + pub generate_oauth_access_token_id, + OAuthAccessTokenId, + 8, + "SELECT EXISTS(SELECT 1 FROM oauth_access_tokens WHERE id=$1)", + OAuthAccessTokenId +); + +generate_ids!( + pub generate_payout_id, + PayoutId, + 8, + "SELECT EXISTS(SELECT 1 FROM oauth_access_tokens WHERE id=$1)", + PayoutId +); + +generate_ids!( + pub generate_product_id, + ProductId, + 8, + "SELECT EXISTS(SELECT 1 FROM products WHERE id=$1)", + ProductId +); + +generate_ids!( + pub generate_product_price_id, + ProductPriceId, + 8, + "SELECT EXISTS(SELECT 1 FROM products_prices WHERE id=$1)", + ProductPriceId +); + +generate_ids!( + pub generate_user_subscription_id, + UserSubscriptionId, + 8, + "SELECT EXISTS(SELECT 1 FROM users_subscriptions WHERE id=$1)", + UserSubscriptionId +); + +generate_ids!( + pub generate_charge_id, + ChargeId, + 8, + "SELECT EXISTS(SELECT 1 FROM charges WHERE id=$1)", + ChargeId +); + +#[derive( + Copy, Clone, Debug, PartialEq, Eq, Type, Hash, Serialize, Deserialize, +)] +#[sqlx(transparent)] +pub struct UserId(pub i64); + +#[derive( + Copy, Clone, Debug, Type, Eq, Hash, PartialEq, Serialize, Deserialize, +)] +#[sqlx(transparent)] +pub struct TeamId(pub i64); +#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize)] +#[sqlx(transparent)] +pub struct TeamMemberId(pub i64); + +#[derive( + Copy, Clone, Debug, Type, PartialEq, Eq, Hash, Serialize, Deserialize, +)] +#[sqlx(transparent)] +pub struct OrganizationId(pub i64); + +#[derive( + Copy, Clone, Debug, Type, PartialEq, Eq, Hash, Serialize, Deserialize, +)] +#[sqlx(transparent)] +pub struct ProjectId(pub i64); +#[derive( + Copy, Clone, Debug, Type, Serialize, Deserialize, PartialEq, Eq, Hash, +)] +#[sqlx(transparent)] +pub struct ProjectTypeId(pub i32); + +#[derive(Copy, Clone, Debug, Type)] +#[sqlx(transparent)] +pub struct StatusId(pub i32); +#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize)] +#[sqlx(transparent)] +pub struct GameId(pub i32); +#[derive( + Copy, Clone, Debug, Type, Serialize, Deserialize, PartialEq, Eq, Hash, +)] +#[sqlx(transparent)] +pub struct LinkPlatformId(pub i32); + +#[derive( + Copy, + Clone, + Debug, + Type, + PartialEq, + Eq, + Hash, + Serialize, + Deserialize, + PartialOrd, + Ord, +)] +#[sqlx(transparent)] +pub struct VersionId(pub i64); +#[derive( + Copy, Clone, Debug, Type, Serialize, Deserialize, PartialEq, Eq, Hash, +)] +#[sqlx(transparent)] +pub struct LoaderId(pub i32); +#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize)] +#[sqlx(transparent)] +pub struct CategoryId(pub i32); + +#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize)] +#[sqlx(transparent)] +pub struct CollectionId(pub i64); + +#[derive(Copy, Clone, Debug, Type, Deserialize, Serialize)] +#[sqlx(transparent)] +pub struct ReportId(pub i64); +#[derive(Copy, Clone, Debug, Type)] +#[sqlx(transparent)] +pub struct ReportTypeId(pub i32); + +#[derive( + Copy, Clone, Debug, Type, Hash, Eq, PartialEq, Deserialize, Serialize, +)] +#[sqlx(transparent)] +pub struct FileId(pub i64); + +#[derive( + Copy, Clone, Debug, Type, Deserialize, Serialize, Eq, PartialEq, Hash, +)] +#[sqlx(transparent)] +pub struct PatId(pub i64); + +#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize)] +#[sqlx(transparent)] +pub struct NotificationId(pub i64); +#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize)] +#[sqlx(transparent)] +pub struct NotificationActionId(pub i32); + +#[derive(Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq)] +#[sqlx(transparent)] +pub struct ThreadId(pub i64); +#[derive( + Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash, +)] +#[sqlx(transparent)] +pub struct ThreadMessageId(pub i64); + +#[derive( + Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash, +)] +#[sqlx(transparent)] +pub struct SessionId(pub i64); + +#[derive( + Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash, +)] +#[sqlx(transparent)] +pub struct ImageId(pub i64); + +#[derive( + Copy, + Clone, + Debug, + Type, + Serialize, + Deserialize, + Eq, + PartialEq, + Hash, + PartialOrd, + Ord, +)] +#[sqlx(transparent)] +pub struct LoaderFieldId(pub i32); + +#[derive( + Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash, +)] +#[sqlx(transparent)] +pub struct LoaderFieldEnumId(pub i32); + +#[derive( + Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash, +)] +#[sqlx(transparent)] +pub struct LoaderFieldEnumValueId(pub i32); + +#[derive( + Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash, +)] +#[sqlx(transparent)] +pub struct OAuthClientId(pub i64); + +#[derive( + Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash, +)] +#[sqlx(transparent)] +pub struct OAuthClientAuthorizationId(pub i64); + +#[derive( + Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash, +)] +#[sqlx(transparent)] +pub struct OAuthRedirectUriId(pub i64); + +#[derive( + Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash, +)] +#[sqlx(transparent)] +pub struct OAuthAccessTokenId(pub i64); + +#[derive( + Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash, +)] +#[sqlx(transparent)] +pub struct PayoutId(pub i64); + +#[derive( + Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash, +)] +#[sqlx(transparent)] +pub struct ProductId(pub i64); +#[derive( + Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash, +)] +#[sqlx(transparent)] +pub struct ProductPriceId(pub i64); + +#[derive( + Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash, +)] +#[sqlx(transparent)] +pub struct UserSubscriptionId(pub i64); + +#[derive( + Copy, Clone, Debug, Type, Serialize, Deserialize, Eq, PartialEq, Hash, +)] +#[sqlx(transparent)] +pub struct ChargeId(pub i64); + +use crate::models::ids; + +impl From for ProjectId { + fn from(id: ids::ProjectId) -> Self { + ProjectId(id.0 as i64) + } +} +impl From for ids::ProjectId { + fn from(id: ProjectId) -> Self { + ids::ProjectId(id.0 as u64) + } +} +impl From for UserId { + fn from(id: ids::UserId) -> Self { + UserId(id.0 as i64) + } +} +impl From for ids::UserId { + fn from(id: UserId) -> Self { + ids::UserId(id.0 as u64) + } +} +impl From for TeamId { + fn from(id: ids::TeamId) -> Self { + TeamId(id.0 as i64) + } +} +impl From for ids::TeamId { + fn from(id: TeamId) -> Self { + ids::TeamId(id.0 as u64) + } +} +impl From for OrganizationId { + fn from(id: ids::OrganizationId) -> Self { + OrganizationId(id.0 as i64) + } +} +impl From for ids::OrganizationId { + fn from(id: OrganizationId) -> Self { + ids::OrganizationId(id.0 as u64) + } +} +impl From for VersionId { + fn from(id: ids::VersionId) -> Self { + VersionId(id.0 as i64) + } +} +impl From for ids::VersionId { + fn from(id: VersionId) -> Self { + ids::VersionId(id.0 as u64) + } +} +impl From for CollectionId { + fn from(id: ids::CollectionId) -> Self { + CollectionId(id.0 as i64) + } +} +impl From for ids::CollectionId { + fn from(id: CollectionId) -> Self { + ids::CollectionId(id.0 as u64) + } +} +impl From for ReportId { + fn from(id: ids::ReportId) -> Self { + ReportId(id.0 as i64) + } +} +impl From for ids::ReportId { + fn from(id: ReportId) -> Self { + ids::ReportId(id.0 as u64) + } +} +impl From for ids::ImageId { + fn from(id: ImageId) -> Self { + ids::ImageId(id.0 as u64) + } +} +impl From for ImageId { + fn from(id: ids::ImageId) -> Self { + ImageId(id.0 as i64) + } +} +impl From for NotificationId { + fn from(id: ids::NotificationId) -> Self { + NotificationId(id.0 as i64) + } +} +impl From for ids::NotificationId { + fn from(id: NotificationId) -> Self { + ids::NotificationId(id.0 as u64) + } +} +impl From for ThreadId { + fn from(id: ids::ThreadId) -> Self { + ThreadId(id.0 as i64) + } +} +impl From for ids::ThreadId { + fn from(id: ThreadId) -> Self { + ids::ThreadId(id.0 as u64) + } +} +impl From for ThreadMessageId { + fn from(id: ids::ThreadMessageId) -> Self { + ThreadMessageId(id.0 as i64) + } +} +impl From for ids::ThreadMessageId { + fn from(id: ThreadMessageId) -> Self { + ids::ThreadMessageId(id.0 as u64) + } +} +impl From for ids::SessionId { + fn from(id: SessionId) -> Self { + ids::SessionId(id.0 as u64) + } +} +impl From for ids::PatId { + fn from(id: PatId) -> Self { + ids::PatId(id.0 as u64) + } +} +impl From for ids::OAuthClientId { + fn from(id: OAuthClientId) -> Self { + ids::OAuthClientId(id.0 as u64) + } +} +impl From for OAuthClientId { + fn from(id: ids::OAuthClientId) -> Self { + Self(id.0 as i64) + } +} +impl From for ids::OAuthRedirectUriId { + fn from(id: OAuthRedirectUriId) -> Self { + ids::OAuthRedirectUriId(id.0 as u64) + } +} +impl From for ids::OAuthClientAuthorizationId { + fn from(id: OAuthClientAuthorizationId) -> Self { + ids::OAuthClientAuthorizationId(id.0 as u64) + } +} + +impl From for PayoutId { + fn from(id: ids::PayoutId) -> Self { + PayoutId(id.0 as i64) + } +} +impl From for ids::PayoutId { + fn from(id: PayoutId) -> Self { + ids::PayoutId(id.0 as u64) + } +} + +impl From for ProductId { + fn from(id: ids::ProductId) -> Self { + ProductId(id.0 as i64) + } +} +impl From for ids::ProductId { + fn from(id: ProductId) -> Self { + ids::ProductId(id.0 as u64) + } +} +impl From for ProductPriceId { + fn from(id: ids::ProductPriceId) -> Self { + ProductPriceId(id.0 as i64) + } +} +impl From for ids::ProductPriceId { + fn from(id: ProductPriceId) -> Self { + ids::ProductPriceId(id.0 as u64) + } +} + +impl From for UserSubscriptionId { + fn from(id: ids::UserSubscriptionId) -> Self { + UserSubscriptionId(id.0 as i64) + } +} +impl From for ids::UserSubscriptionId { + fn from(id: UserSubscriptionId) -> Self { + ids::UserSubscriptionId(id.0 as u64) + } +} + +impl From for ChargeId { + fn from(id: ids::ChargeId) -> Self { + ChargeId(id.0 as i64) + } +} +impl From for ids::ChargeId { + fn from(id: ChargeId) -> Self { + ids::ChargeId(id.0 as u64) + } +} diff --git a/apps/labrinth/src/database/models/image_item.rs b/apps/labrinth/src/database/models/image_item.rs new file mode 100644 index 000000000..d0ef66ab3 --- /dev/null +++ b/apps/labrinth/src/database/models/image_item.rs @@ -0,0 +1,235 @@ +use super::ids::*; +use crate::database::redis::RedisPool; +use crate::{database::models::DatabaseError, models::images::ImageContext}; +use chrono::{DateTime, Utc}; +use dashmap::DashMap; +use serde::{Deserialize, Serialize}; + +const IMAGES_NAMESPACE: &str = "images"; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Image { + pub id: ImageId, + pub url: String, + pub raw_url: String, + pub size: u64, + pub created: DateTime, + pub owner_id: UserId, + + // context it is associated with + pub context: String, + + pub project_id: Option, + pub version_id: Option, + pub thread_message_id: Option, + pub report_id: Option, +} + +impl Image { + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + sqlx::query!( + " + INSERT INTO uploaded_images ( + id, url, raw_url, size, created, owner_id, context, mod_id, version_id, thread_message_id, report_id + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11 + ); + ", + self.id as ImageId, + self.url, + self.raw_url, + self.size as i64, + self.created, + self.owner_id as UserId, + self.context, + self.project_id.map(|x| x.0), + self.version_id.map(|x| x.0), + self.thread_message_id.map(|x| x.0), + self.report_id.map(|x| x.0), + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } + + pub async fn remove( + id: ImageId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &RedisPool, + ) -> Result, DatabaseError> { + let image = Self::get(id, &mut **transaction, redis).await?; + + if let Some(image) = image { + sqlx::query!( + " + DELETE FROM uploaded_images + WHERE id = $1 + ", + id as ImageId, + ) + .execute(&mut **transaction) + .await?; + + Image::clear_cache(image.id, redis).await?; + + Ok(Some(())) + } else { + Ok(None) + } + } + + pub async fn get_many_contexted( + context: ImageContext, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result, sqlx::Error> { + // Set all of project_id, version_id, thread_message_id, report_id to None + // Then set the one that is relevant to Some + + let mut project_id = None; + let mut version_id = None; + let mut thread_message_id = None; + let mut report_id = None; + match context { + ImageContext::Project { + project_id: Some(id), + } => { + project_id = Some(ProjectId::from(id)); + } + ImageContext::Version { + version_id: Some(id), + } => { + version_id = Some(VersionId::from(id)); + } + ImageContext::ThreadMessage { + thread_message_id: Some(id), + } => { + thread_message_id = Some(ThreadMessageId::from(id)); + } + ImageContext::Report { + report_id: Some(id), + } => { + report_id = Some(ReportId::from(id)); + } + _ => {} + } + + use futures::stream::TryStreamExt; + sqlx::query!( + " + SELECT id, url, raw_url, size, created, owner_id, context, mod_id, version_id, thread_message_id, report_id + FROM uploaded_images + WHERE context = $1 + AND (mod_id = $2 OR ($2 IS NULL AND mod_id IS NULL)) + AND (version_id = $3 OR ($3 IS NULL AND version_id IS NULL)) + AND (thread_message_id = $4 OR ($4 IS NULL AND thread_message_id IS NULL)) + AND (report_id = $5 OR ($5 IS NULL AND report_id IS NULL)) + GROUP BY id + ", + context.context_as_str(), + project_id.map(|x| x.0), + version_id.map(|x| x.0), + thread_message_id.map(|x| x.0), + report_id.map(|x| x.0), + + ) + .fetch(&mut **transaction) + .map_ok(|row| { + let id = ImageId(row.id); + + Image { + id, + url: row.url, + raw_url: row.raw_url, + size: row.size as u64, + created: row.created, + owner_id: UserId(row.owner_id), + context: row.context, + project_id: row.mod_id.map(ProjectId), + version_id: row.version_id.map(VersionId), + thread_message_id: row.thread_message_id.map(ThreadMessageId), + report_id: row.report_id.map(ReportId), + } + }) + .try_collect::>() + .await + } + + pub async fn get<'a, 'b, E>( + id: ImageId, + executor: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + Image::get_many(&[id], executor, redis) + .await + .map(|x| x.into_iter().next()) + } + + pub async fn get_many<'a, E>( + image_ids: &[ImageId], + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + use futures::TryStreamExt; + + let val = redis.get_cached_keys( + IMAGES_NAMESPACE, + &image_ids.iter().map(|x| x.0).collect::>(), + |image_ids| async move { + let images = sqlx::query!( + " + SELECT id, url, raw_url, size, created, owner_id, context, mod_id, version_id, thread_message_id, report_id + FROM uploaded_images + WHERE id = ANY($1) + GROUP BY id; + ", + &image_ids, + ) + .fetch(exec) + .try_fold(DashMap::new(), |acc, i| { + let img = Image { + id: ImageId(i.id), + url: i.url, + raw_url: i.raw_url, + size: i.size as u64, + created: i.created, + owner_id: UserId(i.owner_id), + context: i.context, + project_id: i.mod_id.map(ProjectId), + version_id: i.version_id.map(VersionId), + thread_message_id: i.thread_message_id.map(ThreadMessageId), + report_id: i.report_id.map(ReportId), + }; + + acc.insert(i.id, img); + async move { Ok(acc) } + }) + .await?; + + Ok(images) + }, + ).await?; + + Ok(val) + } + + pub async fn clear_cache( + id: ImageId, + redis: &RedisPool, + ) -> Result<(), DatabaseError> { + let mut redis = redis.connect().await?; + + redis.delete(IMAGES_NAMESPACE, id.0).await?; + Ok(()) + } +} diff --git a/apps/labrinth/src/database/models/legacy_loader_fields.rs b/apps/labrinth/src/database/models/legacy_loader_fields.rs new file mode 100644 index 000000000..e7fa76140 --- /dev/null +++ b/apps/labrinth/src/database/models/legacy_loader_fields.rs @@ -0,0 +1,232 @@ +// In V3, we switched to dynamic loader fields for a better support for more loaders, games, and potential metadata. +// This file contains the legacy loader fields, which are still used by V2 projects. +// They are still useful to have in several places where minecraft-java functionality is hardcoded- for example, +// for fetching data from forge, maven, etc. +// These fields only apply to minecraft-java, and are hardcoded to the minecraft-java game. + +use chrono::{DateTime, Utc}; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use serde_json::json; + +use crate::database::redis::RedisPool; + +use super::{ + loader_fields::{ + LoaderFieldEnum, LoaderFieldEnumValue, VersionField, VersionFieldValue, + }, + DatabaseError, LoaderFieldEnumValueId, +}; + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct MinecraftGameVersion { + pub id: LoaderFieldEnumValueId, + pub version: String, + #[serde(rename = "type")] + pub type_: String, + pub created: DateTime, + pub major: bool, +} + +impl MinecraftGameVersion { + // The name under which this legacy field is stored as a LoaderField + pub const FIELD_NAME: &'static str = "game_versions"; + + pub fn builder() -> MinecraftGameVersionBuilder<'static> { + MinecraftGameVersionBuilder::default() + } + + pub async fn list<'a, E>( + version_type_option: Option<&str>, + major_option: Option, + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Acquire<'a, Database = sqlx::Postgres>, + { + let mut exec = exec.acquire().await?; + let game_version_enum = + LoaderFieldEnum::get(Self::FIELD_NAME, &mut *exec, redis) + .await? + .ok_or_else(|| { + DatabaseError::SchemaError( + "Could not find game version enum.".to_string(), + ) + })?; + let game_version_enum_values = + LoaderFieldEnumValue::list(game_version_enum.id, &mut *exec, redis) + .await?; + + let game_versions = game_version_enum_values + .into_iter() + .map(MinecraftGameVersion::from_enum_value) + .filter(|x| { + let mut bool = true; + + if let Some(version_type) = version_type_option { + bool &= &*x.type_ == version_type; + } + if let Some(major) = major_option { + bool &= x.major == major; + } + + bool + }) + .collect_vec(); + + Ok(game_versions) + } + + // Tries to create a MinecraftGameVersion from a VersionField + // Clones on success + pub fn try_from_version_field( + version_field: &VersionField, + ) -> Result, DatabaseError> { + if version_field.field_name != Self::FIELD_NAME { + return Err(DatabaseError::SchemaError(format!( + "Field name {} is not {}", + version_field.field_name, + Self::FIELD_NAME + ))); + } + let game_versions = match version_field.clone() { + VersionField { + value: VersionFieldValue::ArrayEnum(_, values), + .. + } => values.into_iter().map(Self::from_enum_value).collect(), + VersionField { + value: VersionFieldValue::Enum(_, value), + .. + } => { + vec![Self::from_enum_value(value)] + } + _ => { + return Err(DatabaseError::SchemaError(format!( + "Game version requires field value to be an enum: {:?}", + version_field + ))); + } + }; + Ok(game_versions) + } + + pub fn from_enum_value( + loader_field_enum_value: LoaderFieldEnumValue, + ) -> MinecraftGameVersion { + MinecraftGameVersion { + id: loader_field_enum_value.id, + version: loader_field_enum_value.value, + created: loader_field_enum_value.created, + type_: loader_field_enum_value + .metadata + .get("type") + .and_then(|x| x.as_str()) + .map(|x| x.to_string()) + .unwrap_or_default(), + major: loader_field_enum_value + .metadata + .get("major") + .and_then(|x| x.as_bool()) + .unwrap_or_default(), + } + } +} + +#[derive(Default)] +pub struct MinecraftGameVersionBuilder<'a> { + pub version: Option<&'a str>, + pub version_type: Option<&'a str>, + pub date: Option<&'a DateTime>, +} + +impl<'a> MinecraftGameVersionBuilder<'a> { + pub fn new() -> Self { + Self::default() + } + /// The game version. Spaces must be replaced with '_' for it to be valid + pub fn version( + self, + version: &'a str, + ) -> Result, DatabaseError> { + Ok(Self { + version: Some(version), + ..self + }) + } + + pub fn version_type( + self, + version_type: &'a str, + ) -> Result, DatabaseError> { + Ok(Self { + version_type: Some(version_type), + ..self + }) + } + + pub fn created( + self, + created: &'a DateTime, + ) -> MinecraftGameVersionBuilder<'a> { + Self { + date: Some(created), + ..self + } + } + + pub async fn insert<'b, E>( + self, + exec: E, + redis: &RedisPool, + ) -> Result + where + E: sqlx::Executor<'b, Database = sqlx::Postgres> + Copy, + { + let game_versions_enum = + LoaderFieldEnum::get("game_versions", exec, redis) + .await? + .ok_or(DatabaseError::SchemaError( + "Missing loaders field: 'game_versions'".to_string(), + ))?; + + // Get enum id for game versions + let metadata = json!({ + "type": self.version_type, + "major": false + }); + + // This looks like a mess, but it *should* work + // This allows game versions to be partially updated without + // replacing the unspecified fields with defaults. + let result = sqlx::query!( + " + INSERT INTO loader_field_enum_values (enum_id, value, created, metadata) + VALUES ($1, $2, COALESCE($3, timezone('utc', now())), $4) + ON CONFLICT (enum_id, value) DO UPDATE + SET metadata = jsonb_set( + COALESCE(loader_field_enum_values.metadata, $4), + '{type}', + COALESCE($4->'type', loader_field_enum_values.metadata->'type') + ), + created = COALESCE($3, loader_field_enum_values.created) + RETURNING id + ", + game_versions_enum.id.0, + self.version, + self.date.map(chrono::DateTime::naive_utc), + metadata + ) + .fetch_one(exec) + .await?; + + let mut conn = redis.connect().await?; + conn.delete( + crate::database::models::loader_fields::LOADER_FIELD_ENUM_VALUES_NAMESPACE, + game_versions_enum.id.0, + ) + .await?; + + Ok(LoaderFieldEnumValueId(result.id)) + } +} diff --git a/apps/labrinth/src/database/models/loader_fields.rs b/apps/labrinth/src/database/models/loader_fields.rs new file mode 100644 index 000000000..70c74150e --- /dev/null +++ b/apps/labrinth/src/database/models/loader_fields.rs @@ -0,0 +1,1367 @@ +use std::collections::HashMap; +use std::hash::Hasher; + +use super::ids::*; +use super::DatabaseError; +use crate::database::redis::RedisPool; +use chrono::DateTime; +use chrono::Utc; +use dashmap::DashMap; +use futures::TryStreamExt; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; + +const GAMES_LIST_NAMESPACE: &str = "games"; +const LOADER_ID: &str = "loader_id"; +const LOADERS_LIST_NAMESPACE: &str = "loaders"; +const LOADER_FIELDS_NAMESPACE: &str = "loader_fields"; +const LOADER_FIELDS_NAMESPACE_ALL: &str = "loader_fields_all"; +const LOADER_FIELD_ENUMS_ID_NAMESPACE: &str = "loader_field_enums"; +pub const LOADER_FIELD_ENUM_VALUES_NAMESPACE: &str = "loader_field_enum_values"; + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct Game { + pub id: GameId, + pub slug: String, + pub name: String, + pub icon_url: Option, + pub banner_url: Option, +} + +impl Game { + pub async fn get_slug<'a, E>( + slug: &str, + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + Ok(Self::list(exec, redis) + .await? + .into_iter() + .find(|x| x.slug == slug)) + } + + pub async fn list<'a, E>( + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let mut redis = redis.connect().await?; + let cached_games: Option> = redis + .get_deserialized_from_json(GAMES_LIST_NAMESPACE, "games") + .await?; + if let Some(cached_games) = cached_games { + return Ok(cached_games); + } + + let result = sqlx::query!( + " + SELECT id, slug, name, icon_url, banner_url FROM games + ", + ) + .fetch(exec) + .map_ok(|x| Game { + id: GameId(x.id), + slug: x.slug, + name: x.name, + icon_url: x.icon_url, + banner_url: x.banner_url, + }) + .try_collect::>() + .await?; + + redis + .set_serialized_to_json( + GAMES_LIST_NAMESPACE, + "games", + &result, + None, + ) + .await?; + + Ok(result) + } +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct Loader { + pub id: LoaderId, + pub loader: String, + pub icon: String, + pub supported_project_types: Vec, + pub supported_games: Vec, // slugs + pub metadata: serde_json::Value, +} + +impl Loader { + pub async fn get_id<'a, E>( + name: &str, + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let mut redis = redis.connect().await?; + let cached_id: Option = + redis.get_deserialized_from_json(LOADER_ID, name).await?; + if let Some(cached_id) = cached_id { + return Ok(Some(LoaderId(cached_id))); + } + + let result = sqlx::query!( + " + SELECT id FROM loaders + WHERE loader = $1 + ", + name + ) + .fetch_optional(exec) + .await? + .map(|r| LoaderId(r.id)); + + if let Some(result) = result { + redis + .set_serialized_to_json(LOADER_ID, name, &result.0, None) + .await?; + } + + Ok(result) + } + + pub async fn list<'a, E>( + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let mut redis = redis.connect().await?; + let cached_loaders: Option> = redis + .get_deserialized_from_json(LOADERS_LIST_NAMESPACE, "all") + .await?; + if let Some(cached_loaders) = cached_loaders { + return Ok(cached_loaders); + } + + let result = sqlx::query!( + " + SELECT l.id id, l.loader loader, l.icon icon, l.metadata metadata, + ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types, + ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games + FROM loaders l + LEFT OUTER JOIN loaders_project_types lpt ON joining_loader_id = l.id + LEFT OUTER JOIN project_types pt ON lpt.joining_project_type_id = pt.id + LEFT OUTER JOIN loaders_project_types_games lptg ON lptg.loader_id = lpt.joining_loader_id AND lptg.project_type_id = lpt.joining_project_type_id + LEFT OUTER JOIN games g ON lptg.game_id = g.id + GROUP BY l.id; + ", + ) + .fetch(exec) + .map_ok(|x| Loader { + id: LoaderId(x.id), + loader: x.loader, + icon: x.icon, + supported_project_types: x + .project_types + .unwrap_or_default() + .iter() + .map(|x| x.to_string()) + .collect(), + supported_games: x + .games + .unwrap_or_default(), + metadata: x.metadata + }) + .try_collect::>() + .await?; + + redis + .set_serialized_to_json( + LOADERS_LIST_NAMESPACE, + "all", + &result, + None, + ) + .await?; + + Ok(result) + } +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct LoaderField { + pub id: LoaderFieldId, + pub field: String, + pub field_type: LoaderFieldType, + pub optional: bool, + pub min_val: Option, + pub max_val: Option, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub enum LoaderFieldType { + Integer, + Text, + Enum(LoaderFieldEnumId), + Boolean, + ArrayInteger, + ArrayText, + ArrayEnum(LoaderFieldEnumId), + ArrayBoolean, +} +impl LoaderFieldType { + pub fn build( + field_type_name: &str, + loader_field_enum: Option, + ) -> Option { + Some(match (field_type_name, loader_field_enum) { + ("integer", _) => LoaderFieldType::Integer, + ("text", _) => LoaderFieldType::Text, + ("boolean", _) => LoaderFieldType::Boolean, + ("array_integer", _) => LoaderFieldType::ArrayInteger, + ("array_text", _) => LoaderFieldType::ArrayText, + ("array_boolean", _) => LoaderFieldType::ArrayBoolean, + ("enum", Some(id)) => LoaderFieldType::Enum(LoaderFieldEnumId(id)), + ("array_enum", Some(id)) => { + LoaderFieldType::ArrayEnum(LoaderFieldEnumId(id)) + } + _ => return None, + }) + } + + pub fn to_str(&self) -> &'static str { + match self { + LoaderFieldType::Integer => "integer", + LoaderFieldType::Text => "text", + LoaderFieldType::Boolean => "boolean", + LoaderFieldType::ArrayInteger => "array_integer", + LoaderFieldType::ArrayText => "array_text", + LoaderFieldType::ArrayBoolean => "array_boolean", + LoaderFieldType::Enum(_) => "enum", + LoaderFieldType::ArrayEnum(_) => "array_enum", + } + } + + pub fn is_array(&self) -> bool { + match self { + LoaderFieldType::ArrayInteger => true, + LoaderFieldType::ArrayText => true, + LoaderFieldType::ArrayBoolean => true, + LoaderFieldType::ArrayEnum(_) => true, + + LoaderFieldType::Integer => false, + LoaderFieldType::Text => false, + LoaderFieldType::Boolean => false, + LoaderFieldType::Enum(_) => false, + } + } +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct LoaderFieldEnum { + pub id: LoaderFieldEnumId, + pub enum_name: String, + pub ordering: Option, + pub hidable: bool, +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq)] +pub struct LoaderFieldEnumValue { + pub id: LoaderFieldEnumValueId, + pub enum_id: LoaderFieldEnumId, + pub value: String, + pub ordering: Option, + pub created: DateTime, + #[serde(flatten)] + pub metadata: serde_json::Value, +} + +impl std::hash::Hash for LoaderFieldEnumValue { + fn hash(&self, state: &mut H) { + self.id.hash(state); + self.enum_id.hash(state); + self.value.hash(state); + self.ordering.hash(state); + self.created.hash(state); + } +} + +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Hash)] +pub struct VersionField { + pub version_id: VersionId, + pub field_id: LoaderFieldId, + pub field_name: String, + pub value: VersionFieldValue, +} +#[derive(Clone, Serialize, Deserialize, Debug, PartialEq, Eq, Hash)] +pub enum VersionFieldValue { + Integer(i32), + Text(String), + Enum(LoaderFieldEnumId, LoaderFieldEnumValue), + Boolean(bool), + ArrayInteger(Vec), + ArrayText(Vec), + ArrayEnum(LoaderFieldEnumId, Vec), + ArrayBoolean(Vec), +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct QueryVersionField { + pub version_id: VersionId, + pub field_id: LoaderFieldId, + pub int_value: Option, + pub enum_value: Option, + pub string_value: Option, +} + +impl QueryVersionField { + pub fn with_int_value(mut self, int_value: i32) -> Self { + self.int_value = Some(int_value); + self + } + + pub fn with_enum_value( + mut self, + enum_value: LoaderFieldEnumValueId, + ) -> Self { + self.enum_value = Some(enum_value); + self + } + + pub fn with_string_value(mut self, string_value: String) -> Self { + self.string_value = Some(string_value); + self + } +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct QueryLoaderField { + pub id: LoaderFieldId, + pub field: String, + pub field_type: String, + pub enum_type: Option, + pub min_val: Option, + pub max_val: Option, + pub optional: bool, +} + +#[derive(Clone, Serialize, Deserialize, Debug)] +pub struct QueryLoaderFieldEnumValue { + pub id: LoaderFieldEnumValueId, + pub enum_id: LoaderFieldEnumId, + pub value: String, + pub ordering: Option, + pub created: DateTime, + pub metadata: Option, +} + +impl LoaderField { + pub async fn get_field<'a, E>( + field: &str, + loader_ids: &[LoaderId], + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let fields = Self::get_fields(loader_ids, exec, redis).await?; + Ok(fields.into_iter().find(|f| f.field == field)) + } + + // Gets all fields for a given loader(s) + // Returns all as this there are probably relatively few fields per loader + pub async fn get_fields<'a, E>( + loader_ids: &[LoaderId], + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let found_loader_fields = + Self::get_fields_per_loader(loader_ids, exec, redis).await?; + let result = found_loader_fields + .into_values() + .flatten() + .unique_by(|x| x.id) + .collect(); + Ok(result) + } + + pub async fn get_fields_per_loader<'a, E>( + loader_ids: &[LoaderId], + exec: E, + redis: &RedisPool, + ) -> Result>, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let val = redis.get_cached_keys_raw( + LOADER_FIELDS_NAMESPACE, + &loader_ids.iter().map(|x| x.0).collect::>(), + |loader_ids| async move { + let result = sqlx::query!( + " + SELECT DISTINCT lf.id, lf.field, lf.field_type, lf.optional, lf.min_val, lf.max_val, lf.enum_type, lfl.loader_id + FROM loader_fields lf + LEFT JOIN loader_fields_loaders lfl ON lfl.loader_field_id = lf.id + WHERE lfl.loader_id = ANY($1) + ", + &loader_ids, + ) + .fetch(exec) + .try_fold(DashMap::new(), |acc: DashMap>, r| { + if let Some(field_type) = LoaderFieldType::build(&r.field_type, r.enum_type) { + let loader_field = LoaderField { + id: LoaderFieldId(r.id), + field_type, + field: r.field, + optional: r.optional, + min_val: r.min_val, + max_val: r.max_val, + }; + + acc.entry(r.loader_id) + .or_default() + .push(loader_field); + } + + async move { + Ok(acc) + } + }) + .await?; + + Ok(result) + }, + ).await?; + + Ok(val.into_iter().map(|x| (LoaderId(x.0), x.1)).collect()) + } + + // Gets all fields for a given loader(s) + // This is for tags, which need all fields for all loaders + // We want to return them even in testing situations where we dont have loaders or loader_fields_loaders set up + pub async fn get_fields_all<'a, E>( + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let mut redis = redis.connect().await?; + + let cached_fields: Option> = redis + .get(LOADER_FIELDS_NAMESPACE_ALL, "") + .await? + .and_then(|x| serde_json::from_str::>(&x).ok()); + + if let Some(cached_fields) = cached_fields { + return Ok(cached_fields); + } + + let result = sqlx::query!( + " + SELECT DISTINCT lf.id, lf.field, lf.field_type, lf.optional, lf.min_val, lf.max_val, lf.enum_type + FROM loader_fields lf + ", + ) + .fetch(exec) + .map_ok(|r| { + Some(LoaderField { + id: LoaderFieldId(r.id), + field_type: LoaderFieldType::build(&r.field_type, r.enum_type)?, + field: r.field, + optional: r.optional, + min_val: r.min_val, + max_val: r.max_val, + }) + }) + .try_collect::>>() + .await? + .into_iter() + .flatten() + .collect(); + + redis + .set_serialized_to_json( + LOADER_FIELDS_NAMESPACE_ALL, + "", + &result, + None, + ) + .await?; + + Ok(result) + } +} +impl LoaderFieldEnum { + pub async fn get<'a, E>( + enum_name: &str, // Note: NOT loader field name + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let mut redis = redis.connect().await?; + + let cached_enum = redis + .get_deserialized_from_json( + LOADER_FIELD_ENUMS_ID_NAMESPACE, + enum_name, + ) + .await?; + if let Some(cached_enum) = cached_enum { + return Ok(cached_enum); + } + + let result = sqlx::query!( + " + SELECT lfe.id, lfe.enum_name, lfe.ordering, lfe.hidable + FROM loader_field_enums lfe + WHERE lfe.enum_name = $1 + ORDER BY lfe.ordering ASC + ", + enum_name + ) + .fetch_optional(exec) + .await? + .map(|l| LoaderFieldEnum { + id: LoaderFieldEnumId(l.id), + enum_name: l.enum_name, + ordering: l.ordering, + hidable: l.hidable, + }); + + redis + .set_serialized_to_json( + LOADER_FIELD_ENUMS_ID_NAMESPACE, + enum_name, + &result, + None, + ) + .await?; + + Ok(result) + } +} + +impl LoaderFieldEnumValue { + pub async fn list<'a, E>( + loader_field_enum_id: LoaderFieldEnumId, + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + Ok(Self::list_many(&[loader_field_enum_id], exec, redis) + .await? + .into_iter() + .next() + .map(|x| x.1) + .unwrap_or_default()) + } + + pub async fn list_many_loader_fields<'a, E>( + loader_fields: &[LoaderField], + exec: E, + redis: &RedisPool, + ) -> Result>, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let get_enum_id = |x: &LoaderField| match x.field_type { + LoaderFieldType::Enum(id) | LoaderFieldType::ArrayEnum(id) => { + Some(id) + } + _ => None, + }; + + let enum_ids = loader_fields + .iter() + .filter_map(get_enum_id) + .collect::>(); + let values = Self::list_many(&enum_ids, exec, redis) + .await? + .into_iter() + .collect::>(); + + let mut res = HashMap::new(); + for lf in loader_fields { + if let Some(id) = get_enum_id(lf) { + res.insert( + lf.id, + values.get(&id).unwrap_or(&Vec::new()).to_vec(), + ); + } + } + Ok(res) + } + + pub async fn list_many<'a, E>( + loader_field_enum_ids: &[LoaderFieldEnumId], + exec: E, + redis: &RedisPool, + ) -> Result< + HashMap>, + DatabaseError, + > + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let val = redis.get_cached_keys_raw( + LOADER_FIELD_ENUM_VALUES_NAMESPACE, + &loader_field_enum_ids.iter().map(|x| x.0).collect::>(), + |loader_field_enum_ids| async move { + let values = sqlx::query!( + " + SELECT id, enum_id, value, ordering, metadata, created FROM loader_field_enum_values + WHERE enum_id = ANY($1) + ORDER BY enum_id, ordering, created DESC + ", + &loader_field_enum_ids + ) + .fetch(exec) + .try_fold(DashMap::new(), |acc: DashMap>, c| { + let value = LoaderFieldEnumValue { + id: LoaderFieldEnumValueId(c.id), + enum_id: LoaderFieldEnumId(c.enum_id), + value: c.value, + ordering: c.ordering, + created: c.created, + metadata: c.metadata.unwrap_or_default(), + }; + + acc.entry(c.enum_id) + .or_default() + .push(value); + + async move { + Ok(acc) + } + }) + .await?; + + Ok(values) + }, + ).await?; + + Ok(val + .into_iter() + .map(|x| (LoaderFieldEnumId(x.0), x.1)) + .collect()) + } + + // Matches filter against metadata of enum values + pub async fn list_filter<'a, E>( + loader_field_enum_id: LoaderFieldEnumId, + filter: HashMap, + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = Self::list(loader_field_enum_id, exec, redis) + .await? + .into_iter() + .filter(|x| { + let mut bool = true; + for (key, value) in filter.iter() { + if let Some(metadata_value) = x.metadata.get(key) { + bool &= metadata_value == value; + } else { + bool = false; + } + } + bool + }) + .collect(); + + Ok(result) + } +} + +impl VersionField { + pub async fn insert_many( + items: Vec, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + let mut query_version_fields = vec![]; + for item in items { + let base = QueryVersionField { + version_id: item.version_id, + field_id: item.field_id, + int_value: None, + enum_value: None, + string_value: None, + }; + + match item.value { + VersionFieldValue::Integer(i) => { + query_version_fields.push(base.clone().with_int_value(i)) + } + VersionFieldValue::Text(s) => { + query_version_fields.push(base.clone().with_string_value(s)) + } + VersionFieldValue::Boolean(b) => query_version_fields + .push(base.clone().with_int_value(if b { 1 } else { 0 })), + VersionFieldValue::ArrayInteger(v) => { + for i in v { + query_version_fields + .push(base.clone().with_int_value(i)); + } + } + VersionFieldValue::ArrayText(v) => { + for s in v { + query_version_fields + .push(base.clone().with_string_value(s)); + } + } + VersionFieldValue::ArrayBoolean(v) => { + for b in v { + query_version_fields.push( + base.clone().with_int_value(if b { 1 } else { 0 }), + ); + } + } + VersionFieldValue::Enum(_, v) => query_version_fields + .push(base.clone().with_enum_value(v.id)), + VersionFieldValue::ArrayEnum(_, v) => { + for ev in v { + query_version_fields + .push(base.clone().with_enum_value(ev.id)); + } + } + }; + } + + let (field_ids, version_ids, int_values, enum_values, string_values): ( + Vec<_>, + Vec<_>, + Vec<_>, + Vec<_>, + Vec<_>, + ) = query_version_fields + .iter() + .map(|l| { + ( + l.field_id.0, + l.version_id.0, + l.int_value, + l.enum_value.as_ref().map(|e| e.0), + l.string_value.clone(), + ) + }) + .multiunzip(); + + sqlx::query!( + " + INSERT INTO version_fields (field_id, version_id, int_value, string_value, enum_value) + SELECT * FROM UNNEST($1::integer[], $2::bigint[], $3::integer[], $4::text[], $5::integer[]) + ", + &field_ids[..], + &version_ids[..], + &int_values[..] as &[Option], + &string_values[..] as &[Option], + &enum_values[..] as &[Option] + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } + + pub fn check_parse( + version_id: VersionId, + loader_field: LoaderField, + value: serde_json::Value, + enum_variants: Vec, + ) -> Result { + let value = + VersionFieldValue::parse(&loader_field, value, enum_variants)?; + + // Ensure, if applicable, that the value is within the min/max bounds + let countable = match &value { + VersionFieldValue::Integer(i) => Some(*i), + VersionFieldValue::ArrayInteger(v) => Some(v.len() as i32), + VersionFieldValue::Text(_) => None, + VersionFieldValue::ArrayText(v) => Some(v.len() as i32), + VersionFieldValue::Boolean(_) => None, + VersionFieldValue::ArrayBoolean(v) => Some(v.len() as i32), + VersionFieldValue::Enum(_, _) => None, + VersionFieldValue::ArrayEnum(_, v) => Some(v.len() as i32), + }; + + if let Some(count) = countable { + if let Some(min) = loader_field.min_val { + if count < min { + return Err(format!( + "Provided value '{v}' for {field_name} is less than the minimum of {min}", + v = serde_json::to_string(&value).unwrap_or_default(), + field_name = loader_field.field, + )); + } + } + + if let Some(max) = loader_field.max_val { + if count > max { + return Err(format!( + "Provided value '{v}' for {field_name} is greater than the maximum of {max}", + v = serde_json::to_string(&value).unwrap_or_default(), + field_name = loader_field.field, + )); + } + } + } + + Ok(VersionField { + version_id, + field_id: loader_field.id, + field_name: loader_field.field, + value, + }) + } + + pub fn from_query_json( + // A list of all version fields to extract data from + query_version_field_combined: Vec, + // A list of all loader fields to reference when extracting data + // Note: any loader field in here that is not in query_version_field_combined will be still considered + // (For example, game_versions in query_loader_fields but not in query_version_field_combined would produce game_versions: []) + query_loader_fields: &[&QueryLoaderField], + // enum values to reference when parsing enum values + query_loader_field_enum_values: &[QueryLoaderFieldEnumValue], + // If true, will allow multiple values for a single singleton field, returning them as separate VersionFields + // allow_many = true, multiple Bools => two VersionFields of Bool + // allow_many = false, multiple Bools => error + // multiple Arraybools => 1 VersionField of ArrayBool + allow_many: bool, + ) -> Vec { + query_loader_fields + .iter() + .flat_map(|q| { + let loader_field_type = match LoaderFieldType::build( + &q.field_type, + q.enum_type.map(|l| l.0), + ) { + Some(lft) => lft, + None => return vec![], + }; + let loader_field = LoaderField { + id: q.id, + field: q.field.clone(), + field_type: loader_field_type, + optional: q.optional, + min_val: q.min_val, + max_val: q.max_val, + }; + + // todo: avoid clone here? + let version_fields = query_version_field_combined + .iter() + .filter(|qvf| qvf.field_id == q.id) + .cloned() + .collect::>(); + if allow_many { + VersionField::build_many( + loader_field, + version_fields, + query_loader_field_enum_values, + ) + .unwrap_or_default() + .into_iter() + .unique() + .collect_vec() + } else { + match VersionField::build( + loader_field, + version_fields, + query_loader_field_enum_values, + ) { + Ok(vf) => vec![vf], + Err(_) => vec![], + } + } + }) + .collect() + } + + pub fn build( + loader_field: LoaderField, + query_version_fields: Vec, + query_loader_field_enum_values: &[QueryLoaderFieldEnumValue], + ) -> Result { + let (version_id, value) = VersionFieldValue::build( + &loader_field.field_type, + query_version_fields, + query_loader_field_enum_values, + )?; + Ok(VersionField { + version_id, + field_id: loader_field.id, + field_name: loader_field.field, + value, + }) + } + + pub fn build_many( + loader_field: LoaderField, + query_version_fields: Vec, + query_loader_field_enum_values: &[QueryLoaderFieldEnumValue], + ) -> Result, DatabaseError> { + let values = VersionFieldValue::build_many( + &loader_field.field_type, + query_version_fields, + query_loader_field_enum_values, + )?; + Ok(values + .into_iter() + .map(|(version_id, value)| VersionField { + version_id, + field_id: loader_field.id, + field_name: loader_field.field.clone(), + value, + }) + .collect()) + } +} + +impl VersionFieldValue { + // Build from user-submitted JSON data + // value is the attempted value of the field, which will be tried to parse to the correct type + // enum_array is the list of valid enum variants for the field, if it is an enum (see LoaderFieldEnumValue::list_many_loader_fields) + pub fn parse( + loader_field: &LoaderField, + value: serde_json::Value, + enum_array: Vec, + ) -> Result { + let field_name = &loader_field.field; + let field_type = &loader_field.field_type; + + let error_value = value.clone(); + let incorrect_type_error = |field_type: &str| { + format!( + "Provided value '{v}' for {field_name} could not be parsed to {field_type} ", + v = serde_json::to_string(&error_value).unwrap_or_default() + ) + }; + + Ok(match field_type { + LoaderFieldType::Integer => VersionFieldValue::Integer( + serde_json::from_value(value) + .map_err(|_| incorrect_type_error("integer"))?, + ), + LoaderFieldType::Text => VersionFieldValue::Text( + value + .as_str() + .ok_or_else(|| incorrect_type_error("string"))? + .to_string(), + ), + LoaderFieldType::Boolean => VersionFieldValue::Boolean( + value + .as_bool() + .ok_or_else(|| incorrect_type_error("boolean"))?, + ), + LoaderFieldType::ArrayInteger => VersionFieldValue::ArrayInteger({ + let array_values: Vec = serde_json::from_value(value) + .map_err(|_| incorrect_type_error("array of integers"))?; + array_values.into_iter().collect() + }), + LoaderFieldType::ArrayText => VersionFieldValue::ArrayText({ + let array_values: Vec = serde_json::from_value(value) + .map_err(|_| { + incorrect_type_error("array of strings") + })?; + array_values.into_iter().collect() + }), + LoaderFieldType::ArrayBoolean => VersionFieldValue::ArrayBoolean({ + let array_values: Vec = serde_json::from_value(value) + .map_err(|_| incorrect_type_error("array of booleans"))?; + array_values.into_iter().map(|v| v != 0).collect() + }), + LoaderFieldType::Enum(id) => VersionFieldValue::Enum(*id, { + let enum_value = value + .as_str() + .ok_or_else(|| incorrect_type_error("enum"))?; + if let Some(ev) = + enum_array.into_iter().find(|v| v.value == enum_value) + { + ev + } else { + return Err(format!( + "Provided value '{enum_value}' is not a valid variant for {field_name}" + )); + } + }), + LoaderFieldType::ArrayEnum(id) => { + VersionFieldValue::ArrayEnum(*id, { + let array_values: Vec = + serde_json::from_value(value).map_err(|_| { + incorrect_type_error("array of enums") + })?; + let mut enum_values = vec![]; + for av in array_values { + if let Some(ev) = + enum_array.iter().find(|v| v.value == av) + { + enum_values.push(ev.clone()); + } else { + return Err(format!( + "Provided value '{av}' is not a valid variant for {field_name}" + )); + } + } + enum_values + }) + } + }) + } + + // This will ensure that if multiple QueryVersionFields are provided, they can be combined into a single VersionFieldValue + // of the appropriate type (ie: false, false, true -> ArrayBoolean([false, false, true])) (and not just Boolean) + pub fn build( + field_type: &LoaderFieldType, + qvfs: Vec, + qlfev: &[QueryLoaderFieldEnumValue], + ) -> Result<(VersionId, VersionFieldValue), DatabaseError> { + match field_type { + LoaderFieldType::Integer + | LoaderFieldType::Text + | LoaderFieldType::Boolean + | LoaderFieldType::Enum(_) => { + let mut fields = Self::build_many(field_type, qvfs, qlfev)?; + if fields.len() > 1 { + return Err(DatabaseError::SchemaError(format!( + "Multiple fields for field {}", + field_type.to_str() + ))); + } + fields.pop().ok_or_else(|| { + DatabaseError::SchemaError(format!( + "No version fields for field {}", + field_type.to_str() + )) + }) + } + LoaderFieldType::ArrayInteger + | LoaderFieldType::ArrayText + | LoaderFieldType::ArrayBoolean + | LoaderFieldType::ArrayEnum(_) => { + let fields = Self::build_many(field_type, qvfs, qlfev)?; + Ok(fields.into_iter().next().ok_or_else(|| { + DatabaseError::SchemaError(format!( + "No version fields for field {}", + field_type.to_str() + )) + })?) + } + } + } + + // Build from internal query data + // This encapsulates redundant behavior in db query -> object conversions + // This allows for multiple fields to be built at once. If there are multiple fields, + // but the type only allows for a single field, then multiple VersionFieldValues will be returned + // If there are multiple fields, and the type allows for multiple fields, then a single VersionFieldValue will be returned (array.len == 1) + pub fn build_many( + field_type: &LoaderFieldType, + qvfs: Vec, + qlfev: &[QueryLoaderFieldEnumValue], + ) -> Result, DatabaseError> { + let field_name = field_type.to_str(); + let did_not_exist_error = |field_name: &str, desired_field: &str| { + DatabaseError::SchemaError(format!( + "Field name {} for field {} in does not exist", + desired_field, field_name + )) + }; + + // Check errors- version_id must all be the same + let version_id = qvfs + .iter() + .map(|qvf| qvf.version_id) + .unique() + .collect::>(); + // If the field type is a non-array, then the reason for multiple version ids is that there are multiple versions being aggregated, and those version ids are contained within. + // If the field type is an array, then the reason for multiple version ids is that there are multiple values for a single version + // (or a greater aggregation between multiple arrays, in which case the per-field version is lost, so we just take the first one and use it for that) + let version_id = version_id.into_iter().next().unwrap_or(VersionId(0)); + + let field_id = qvfs + .iter() + .map(|qvf| qvf.field_id) + .unique() + .collect::>(); + if field_id.len() > 1 { + return Err(DatabaseError::SchemaError(format!( + "Multiple field ids for field {}", + field_name + ))); + } + + let mut value = + match field_type { + // Singleton fields + // If there are multiple, we assume multiple versions are being concatenated + LoaderFieldType::Integer => qvfs + .into_iter() + .map(|qvf| { + Ok(( + qvf.version_id, + VersionFieldValue::Integer(qvf.int_value.ok_or( + did_not_exist_error(field_name, "int_value"), + )?), + )) + }) + .collect::, + DatabaseError, + >>()?, + LoaderFieldType::Text => qvfs + .into_iter() + .map(|qvf| { + Ok(( + qvf.version_id, + VersionFieldValue::Text(qvf.string_value.ok_or( + did_not_exist_error(field_name, "string_value"), + )?), + )) + }) + .collect::, + DatabaseError, + >>()?, + LoaderFieldType::Boolean => qvfs + .into_iter() + .map(|qvf| { + Ok(( + qvf.version_id, + VersionFieldValue::Boolean( + qvf.int_value.ok_or(did_not_exist_error( + field_name, + "int_value", + ))? != 0, + ), + )) + }) + .collect::, + DatabaseError, + >>()?, + LoaderFieldType::Enum(id) => qvfs + .into_iter() + .map(|qvf| { + Ok(( + qvf.version_id, + VersionFieldValue::Enum(*id, { + let enum_id = qvf.enum_value.ok_or( + did_not_exist_error( + field_name, + "enum_value", + ), + )?; + let lfev = qlfev + .iter() + .find(|x| x.id == enum_id) + .ok_or(did_not_exist_error( + field_name, + "enum_value", + ))?; + LoaderFieldEnumValue { + id: lfev.id, + enum_id: lfev.enum_id, + value: lfev.value.clone(), + ordering: lfev.ordering, + created: lfev.created, + metadata: lfev + .metadata + .clone() + .unwrap_or_default(), + } + }), + )) + }) + .collect::, + DatabaseError, + >>()?, + + // Array fields + // We concatenate into one array + LoaderFieldType::ArrayInteger => vec![( + version_id, + VersionFieldValue::ArrayInteger( + qvfs.into_iter() + .map(|qvf| { + qvf.int_value.ok_or(did_not_exist_error( + field_name, + "int_value", + )) + }) + .collect::>()?, + ), + )], + LoaderFieldType::ArrayText => vec![( + version_id, + VersionFieldValue::ArrayText( + qvfs.into_iter() + .map(|qvf| { + qvf.string_value.ok_or(did_not_exist_error( + field_name, + "string_value", + )) + }) + .collect::>()?, + ), + )], + LoaderFieldType::ArrayBoolean => vec![( + version_id, + VersionFieldValue::ArrayBoolean( + qvfs.into_iter() + .map(|qvf| { + Ok::( + qvf.int_value.ok_or( + did_not_exist_error( + field_name, + "int_value", + ), + )? != 0, + ) + }) + .collect::>()?, + ), + )], + LoaderFieldType::ArrayEnum(id) => vec![( + version_id, + VersionFieldValue::ArrayEnum( + *id, + qvfs.into_iter() + .map(|qvf| { + let enum_id = qvf.enum_value.ok_or( + did_not_exist_error( + field_name, + "enum_value", + ), + )?; + let lfev = qlfev + .iter() + .find(|x| x.id == enum_id) + .ok_or(did_not_exist_error( + field_name, + "enum_value", + ))?; + Ok::<_, DatabaseError>(LoaderFieldEnumValue { + id: lfev.id, + enum_id: lfev.enum_id, + value: lfev.value.clone(), + ordering: lfev.ordering, + created: lfev.created, + metadata: lfev + .metadata + .clone() + .unwrap_or_default(), + }) + }) + .collect::>()?, + ), + )], + }; + + // Sort arrayenums by ordering, then by created + for (_, v) in value.iter_mut() { + if let VersionFieldValue::ArrayEnum(_, v) = v { + v.sort_by(|a, b| { + a.ordering.cmp(&b.ordering).then(a.created.cmp(&b.created)) + }); + } + } + + Ok(value) + } + + // Serialize to internal value, such as for converting to user-facing JSON + pub fn serialize_internal(&self) -> serde_json::Value { + match self { + VersionFieldValue::Integer(i) => { + serde_json::Value::Number((*i).into()) + } + VersionFieldValue::Text(s) => serde_json::Value::String(s.clone()), + VersionFieldValue::Boolean(b) => serde_json::Value::Bool(*b), + VersionFieldValue::ArrayInteger(v) => serde_json::Value::Array( + v.iter() + .map(|i| serde_json::Value::Number((*i).into())) + .collect(), + ), + VersionFieldValue::ArrayText(v) => serde_json::Value::Array( + v.iter() + .map(|s| serde_json::Value::String(s.clone())) + .collect(), + ), + VersionFieldValue::ArrayBoolean(v) => serde_json::Value::Array( + v.iter().map(|b| serde_json::Value::Bool(*b)).collect(), + ), + VersionFieldValue::Enum(_, v) => { + serde_json::Value::String(v.value.clone()) + } + VersionFieldValue::ArrayEnum(_, v) => serde_json::Value::Array( + v.iter() + .map(|v| serde_json::Value::String(v.value.clone())) + .collect(), + ), + } + } + + // For conversion to an interanl string(s), such as for search facets, filtering, or direct hardcoding + // No matter the type, it will be converted to a Vec, whre the non-array types will have a single element + pub fn as_strings(&self) -> Vec { + match self { + VersionFieldValue::Integer(i) => vec![i.to_string()], + VersionFieldValue::Text(s) => vec![s.clone()], + VersionFieldValue::Boolean(b) => vec![b.to_string()], + VersionFieldValue::ArrayInteger(v) => { + v.iter().map(|i| i.to_string()).collect() + } + VersionFieldValue::ArrayText(v) => v.clone(), + VersionFieldValue::ArrayBoolean(v) => { + v.iter().map(|b| b.to_string()).collect() + } + VersionFieldValue::Enum(_, v) => vec![v.value.clone()], + VersionFieldValue::ArrayEnum(_, v) => { + v.iter().map(|v| v.value.clone()).collect() + } + } + } + + pub fn contains_json_value(&self, value: &serde_json::Value) -> bool { + match self { + VersionFieldValue::Integer(i) => value.as_i64() == Some(*i as i64), + VersionFieldValue::Text(s) => value.as_str() == Some(s), + VersionFieldValue::Boolean(b) => value.as_bool() == Some(*b), + VersionFieldValue::ArrayInteger(v) => value + .as_i64() + .map(|i| v.contains(&(i as i32))) + .unwrap_or(false), + VersionFieldValue::ArrayText(v) => value + .as_str() + .map(|s| v.contains(&s.to_string())) + .unwrap_or(false), + VersionFieldValue::ArrayBoolean(v) => { + value.as_bool().map(|b| v.contains(&b)).unwrap_or(false) + } + VersionFieldValue::Enum(_, v) => value.as_str() == Some(&v.value), + VersionFieldValue::ArrayEnum(_, v) => value + .as_str() + .map(|s| v.iter().any(|v| v.value == s)) + .unwrap_or(false), + } + } +} diff --git a/apps/labrinth/src/database/models/mod.rs b/apps/labrinth/src/database/models/mod.rs new file mode 100644 index 000000000..dabcfdda6 --- /dev/null +++ b/apps/labrinth/src/database/models/mod.rs @@ -0,0 +1,56 @@ +use thiserror::Error; + +pub mod categories; +pub mod charge_item; +pub mod collection_item; +pub mod flow_item; +pub mod ids; +pub mod image_item; +pub mod legacy_loader_fields; +pub mod loader_fields; +pub mod notification_item; +pub mod oauth_client_authorization_item; +pub mod oauth_client_item; +pub mod oauth_token_item; +pub mod organization_item; +pub mod pat_item; +pub mod payout_item; +pub mod product_item; +pub mod project_item; +pub mod report_item; +pub mod session_item; +pub mod team_item; +pub mod thread_item; +pub mod user_item; +pub mod user_subscription_item; +pub mod version_item; + +pub use collection_item::Collection; +pub use ids::*; +pub use image_item::Image; +pub use oauth_client_item::OAuthClient; +pub use organization_item::Organization; +pub use project_item::Project; +pub use team_item::Team; +pub use team_item::TeamMember; +pub use thread_item::{Thread, ThreadMessage}; +pub use user_item::User; +pub use version_item::Version; + +#[derive(Error, Debug)] +pub enum DatabaseError { + #[error("Error while interacting with the database: {0}")] + Database(#[from] sqlx::Error), + #[error("Error while trying to generate random ID")] + RandomId, + #[error("Error while interacting with the cache: {0}")] + CacheError(#[from] redis::RedisError), + #[error("Redis Pool Error: {0}")] + RedisPool(#[from] deadpool_redis::PoolError), + #[error("Error while serializing with the cache: {0}")] + SerdeCacheError(#[from] serde_json::Error), + #[error("Schema error: {0}")] + SchemaError(String), + #[error("Timeout when waiting for cache subscriber")] + CacheTimeout, +} diff --git a/apps/labrinth/src/database/models/notification_item.rs b/apps/labrinth/src/database/models/notification_item.rs new file mode 100644 index 000000000..5fda4a80e --- /dev/null +++ b/apps/labrinth/src/database/models/notification_item.rs @@ -0,0 +1,323 @@ +use super::ids::*; +use crate::database::{models::DatabaseError, redis::RedisPool}; +use crate::models::notifications::NotificationBody; +use chrono::{DateTime, Utc}; +use futures::TryStreamExt; +use serde::{Deserialize, Serialize}; + +const USER_NOTIFICATIONS_NAMESPACE: &str = "user_notifications"; + +pub struct NotificationBuilder { + pub body: NotificationBody, +} + +#[derive(Serialize, Deserialize)] +pub struct Notification { + pub id: NotificationId, + pub user_id: UserId, + pub body: NotificationBody, + pub read: bool, + pub created: DateTime, +} + +#[derive(Serialize, Deserialize)] +pub struct NotificationAction { + pub id: NotificationActionId, + pub notification_id: NotificationId, + pub name: String, + pub action_route_method: String, + pub action_route: String, +} + +impl NotificationBuilder { + pub async fn insert( + &self, + user: UserId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &RedisPool, + ) -> Result<(), DatabaseError> { + self.insert_many(vec![user], transaction, redis).await + } + + pub async fn insert_many( + &self, + users: Vec, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &RedisPool, + ) -> Result<(), DatabaseError> { + let notification_ids = + generate_many_notification_ids(users.len(), &mut *transaction) + .await?; + + let body = serde_json::value::to_value(&self.body)?; + let bodies = notification_ids + .iter() + .map(|_| body.clone()) + .collect::>(); + + sqlx::query!( + " + INSERT INTO notifications ( + id, user_id, body + ) + SELECT * FROM UNNEST($1::bigint[], $2::bigint[], $3::jsonb[]) + ", + ¬ification_ids + .into_iter() + .map(|x| x.0) + .collect::>()[..], + &users.iter().map(|x| x.0).collect::>()[..], + &bodies[..], + ) + .execute(&mut **transaction) + .await?; + + Notification::clear_user_notifications_cache(&users, redis).await?; + + Ok(()) + } +} + +impl Notification { + pub async fn get<'a, 'b, E>( + id: NotificationId, + executor: E, + ) -> Result, sqlx::error::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + Self::get_many(&[id], executor) + .await + .map(|x| x.into_iter().next()) + } + + pub async fn get_many<'a, E>( + notification_ids: &[NotificationId], + exec: E, + ) -> Result, sqlx::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + let notification_ids_parsed: Vec = + notification_ids.iter().map(|x| x.0).collect(); + sqlx::query!( + " + SELECT n.id, n.user_id, n.name, n.text, n.link, n.created, n.read, n.type notification_type, n.body, + JSONB_AGG(DISTINCT jsonb_build_object('id', na.id, 'notification_id', na.notification_id, 'name', na.name, 'action_route_method', na.action_route_method, 'action_route', na.action_route)) filter (where na.id is not null) actions + FROM notifications n + LEFT OUTER JOIN notifications_actions na on n.id = na.notification_id + WHERE n.id = ANY($1) + GROUP BY n.id, n.user_id + ORDER BY n.created DESC; + ", + ¬ification_ids_parsed + ) + .fetch(exec) + .map_ok(|row| { + let id = NotificationId(row.id); + + Notification { + id, + user_id: UserId(row.user_id), + read: row.read, + created: row.created, + body: row.body.clone().and_then(|x| serde_json::from_value(x).ok()).unwrap_or_else(|| { + if let Some(name) = row.name { + NotificationBody::LegacyMarkdown { + notification_type: row.notification_type, + name, + text: row.text.unwrap_or_default(), + link: row.link.unwrap_or_default(), + actions: serde_json::from_value( + row.actions.unwrap_or_default(), + ) + .ok() + .unwrap_or_default(), + } + } else { + NotificationBody::Unknown + } + }), + } + }) + .try_collect::>() + .await + } + + pub async fn get_many_user<'a, E>( + user_id: UserId, + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + let mut redis = redis.connect().await?; + + let cached_notifications: Option> = redis + .get_deserialized_from_json( + USER_NOTIFICATIONS_NAMESPACE, + &user_id.0.to_string(), + ) + .await?; + + if let Some(notifications) = cached_notifications { + return Ok(notifications); + } + + let db_notifications = sqlx::query!( + " + SELECT n.id, n.user_id, n.name, n.text, n.link, n.created, n.read, n.type notification_type, n.body, + JSONB_AGG(DISTINCT jsonb_build_object('id', na.id, 'notification_id', na.notification_id, 'name', na.name, 'action_route_method', na.action_route_method, 'action_route', na.action_route)) filter (where na.id is not null) actions + FROM notifications n + LEFT OUTER JOIN notifications_actions na on n.id = na.notification_id + WHERE n.user_id = $1 + GROUP BY n.id, n.user_id; + ", + user_id as UserId + ) + .fetch(exec) + .map_ok(|row| { + let id = NotificationId(row.id); + + Notification { + id, + user_id: UserId(row.user_id), + read: row.read, + created: row.created, + body: row.body.clone().and_then(|x| serde_json::from_value(x).ok()).unwrap_or_else(|| { + if let Some(name) = row.name { + NotificationBody::LegacyMarkdown { + notification_type: row.notification_type, + name, + text: row.text.unwrap_or_default(), + link: row.link.unwrap_or_default(), + actions: serde_json::from_value( + row.actions.unwrap_or_default(), + ) + .ok() + .unwrap_or_default(), + } + } else { + NotificationBody::Unknown + } + }), + } + }) + .try_collect::>() + .await?; + + redis + .set_serialized_to_json( + USER_NOTIFICATIONS_NAMESPACE, + user_id.0, + &db_notifications, + None, + ) + .await?; + + Ok(db_notifications) + } + + pub async fn read( + id: NotificationId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &RedisPool, + ) -> Result, DatabaseError> { + Self::read_many(&[id], transaction, redis).await + } + + pub async fn read_many( + notification_ids: &[NotificationId], + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &RedisPool, + ) -> Result, DatabaseError> { + let notification_ids_parsed: Vec = + notification_ids.iter().map(|x| x.0).collect(); + + let affected_users = sqlx::query!( + " + UPDATE notifications + SET read = TRUE + WHERE id = ANY($1) + RETURNING user_id + ", + ¬ification_ids_parsed + ) + .fetch(&mut **transaction) + .map_ok(|x| UserId(x.user_id)) + .try_collect::>() + .await?; + + Notification::clear_user_notifications_cache( + affected_users.iter(), + redis, + ) + .await?; + + Ok(Some(())) + } + + pub async fn remove( + id: NotificationId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &RedisPool, + ) -> Result, DatabaseError> { + Self::remove_many(&[id], transaction, redis).await + } + + pub async fn remove_many( + notification_ids: &[NotificationId], + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &RedisPool, + ) -> Result, DatabaseError> { + let notification_ids_parsed: Vec = + notification_ids.iter().map(|x| x.0).collect(); + + sqlx::query!( + " + DELETE FROM notifications_actions + WHERE notification_id = ANY($1) + ", + ¬ification_ids_parsed + ) + .execute(&mut **transaction) + .await?; + + let affected_users = sqlx::query!( + " + DELETE FROM notifications + WHERE id = ANY($1) + RETURNING user_id + ", + ¬ification_ids_parsed + ) + .fetch(&mut **transaction) + .map_ok(|x| UserId(x.user_id)) + .try_collect::>() + .await?; + + Notification::clear_user_notifications_cache( + affected_users.iter(), + redis, + ) + .await?; + + Ok(Some(())) + } + + pub async fn clear_user_notifications_cache( + user_ids: impl IntoIterator, + redis: &RedisPool, + ) -> Result<(), DatabaseError> { + let mut redis = redis.connect().await?; + + redis + .delete_many(user_ids.into_iter().map(|id| { + (USER_NOTIFICATIONS_NAMESPACE, Some(id.0.to_string())) + })) + .await?; + + Ok(()) + } +} diff --git a/apps/labrinth/src/database/models/oauth_client_authorization_item.rs b/apps/labrinth/src/database/models/oauth_client_authorization_item.rs new file mode 100644 index 000000000..617e6fcd8 --- /dev/null +++ b/apps/labrinth/src/database/models/oauth_client_authorization_item.rs @@ -0,0 +1,126 @@ +use chrono::{DateTime, Utc}; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; + +use crate::models::pats::Scopes; + +use super::{DatabaseError, OAuthClientAuthorizationId, OAuthClientId, UserId}; + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct OAuthClientAuthorization { + pub id: OAuthClientAuthorizationId, + pub client_id: OAuthClientId, + pub user_id: UserId, + pub scopes: Scopes, + pub created: DateTime, +} + +struct AuthorizationQueryResult { + id: i64, + client_id: i64, + user_id: i64, + scopes: i64, + created: DateTime, +} + +impl From for OAuthClientAuthorization { + fn from(value: AuthorizationQueryResult) -> Self { + OAuthClientAuthorization { + id: OAuthClientAuthorizationId(value.id), + client_id: OAuthClientId(value.client_id), + user_id: UserId(value.user_id), + scopes: Scopes::from_postgres(value.scopes), + created: value.created, + } + } +} + +impl OAuthClientAuthorization { + pub async fn get( + client_id: OAuthClientId, + user_id: UserId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let value = sqlx::query_as!( + AuthorizationQueryResult, + " + SELECT id, client_id, user_id, scopes, created + FROM oauth_client_authorizations + WHERE client_id=$1 AND user_id=$2 + ", + client_id.0, + user_id.0, + ) + .fetch_optional(exec) + .await?; + + Ok(value.map(|r| r.into())) + } + + pub async fn get_all_for_user( + user_id: UserId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let results = sqlx::query_as!( + AuthorizationQueryResult, + " + SELECT id, client_id, user_id, scopes, created + FROM oauth_client_authorizations + WHERE user_id=$1 + ", + user_id.0 + ) + .fetch_all(exec) + .await?; + + Ok(results.into_iter().map(|r| r.into()).collect_vec()) + } + + pub async fn upsert( + id: OAuthClientAuthorizationId, + client_id: OAuthClientId, + user_id: UserId, + scopes: Scopes, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + sqlx::query!( + " + INSERT INTO oauth_client_authorizations ( + id, client_id, user_id, scopes + ) + VALUES ( + $1, $2, $3, $4 + ) + ON CONFLICT (id) + DO UPDATE SET scopes = EXCLUDED.scopes + ", + id.0, + client_id.0, + user_id.0, + scopes.bits() as i64, + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } + + pub async fn remove( + client_id: OAuthClientId, + user_id: UserId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + sqlx::query!( + " + DELETE FROM oauth_client_authorizations + WHERE client_id=$1 AND user_id=$2 + ", + client_id.0, + user_id.0 + ) + .execute(exec) + .await?; + + Ok(()) + } +} diff --git a/apps/labrinth/src/database/models/oauth_client_item.rs b/apps/labrinth/src/database/models/oauth_client_item.rs new file mode 100644 index 000000000..820f28ce2 --- /dev/null +++ b/apps/labrinth/src/database/models/oauth_client_item.rs @@ -0,0 +1,269 @@ +use chrono::{DateTime, Utc}; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use sha2::Digest; + +use super::{DatabaseError, OAuthClientId, OAuthRedirectUriId, UserId}; +use crate::models::pats::Scopes; + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct OAuthRedirectUri { + pub id: OAuthRedirectUriId, + pub client_id: OAuthClientId, + pub uri: String, +} + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct OAuthClient { + pub id: OAuthClientId, + pub name: String, + pub icon_url: Option, + pub raw_icon_url: Option, + pub max_scopes: Scopes, + pub secret_hash: String, + pub redirect_uris: Vec, + pub created: DateTime, + pub created_by: UserId, + pub url: Option, + pub description: Option, +} + +struct ClientQueryResult { + id: i64, + name: String, + icon_url: Option, + raw_icon_url: Option, + max_scopes: i64, + secret_hash: String, + created: DateTime, + created_by: i64, + url: Option, + description: Option, + uri_ids: Option>, + uri_vals: Option>, +} + +macro_rules! select_clients_with_predicate { + ($predicate:tt, $param:ident) => { + // The columns in this query have nullability type hints, because for some reason + // the combination of the JOIN and filter using ANY makes sqlx think all columns are nullable + // https://docs.rs/sqlx/latest/sqlx/macro.query.html#force-nullable + sqlx::query_as!( + ClientQueryResult, + r#" + SELECT + clients.id as "id!", + clients.name as "name!", + clients.icon_url as "icon_url?", + clients.raw_icon_url as "raw_icon_url?", + clients.max_scopes as "max_scopes!", + clients.secret_hash as "secret_hash!", + clients.created as "created!", + clients.created_by as "created_by!", + clients.url as "url?", + clients.description as "description?", + uris.uri_ids as "uri_ids?", + uris.uri_vals as "uri_vals?" + FROM oauth_clients clients + LEFT JOIN ( + SELECT client_id, array_agg(id) as uri_ids, array_agg(uri) as uri_vals + FROM oauth_client_redirect_uris + GROUP BY client_id + ) uris ON clients.id = uris.client_id + "# + + $predicate, + $param + ) + }; +} + +impl OAuthClient { + pub async fn get( + id: OAuthClientId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + Ok(Self::get_many(&[id], exec).await?.into_iter().next()) + } + + pub async fn get_many( + ids: &[OAuthClientId], + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let ids = ids.iter().map(|id| id.0).collect_vec(); + let ids_ref: &[i64] = &ids; + let results = select_clients_with_predicate!( + "WHERE clients.id = ANY($1::bigint[])", + ids_ref + ) + .fetch_all(exec) + .await?; + + Ok(results.into_iter().map(|r| r.into()).collect_vec()) + } + + pub async fn get_all_user_clients( + user_id: UserId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let user_id_param = user_id.0; + let clients = select_clients_with_predicate!( + "WHERE created_by = $1", + user_id_param + ) + .fetch_all(exec) + .await?; + + Ok(clients.into_iter().map(|r| r.into()).collect()) + } + + pub async fn remove( + id: OAuthClientId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + // Cascades to oauth_client_redirect_uris, oauth_client_authorizations + sqlx::query!( + " + DELETE FROM oauth_clients + WHERE id = $1 + ", + id.0 + ) + .execute(exec) + .await?; + + Ok(()) + } + + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + sqlx::query!( + " + INSERT INTO oauth_clients ( + id, name, icon_url, raw_icon_url, max_scopes, secret_hash, created_by + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7 + ) + ", + self.id.0, + self.name, + self.icon_url, + self.raw_icon_url, + self.max_scopes.to_postgres(), + self.secret_hash, + self.created_by.0 + ) + .execute(&mut **transaction) + .await?; + + Self::insert_redirect_uris(&self.redirect_uris, &mut **transaction) + .await?; + + Ok(()) + } + + pub async fn update_editable_fields( + &self, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + sqlx::query!( + " + UPDATE oauth_clients + SET name = $1, icon_url = $2, raw_icon_url = $3, max_scopes = $4, url = $5, description = $6 + WHERE (id = $7) + ", + self.name, + self.icon_url, + self.raw_icon_url, + self.max_scopes.to_postgres(), + self.url, + self.description, + self.id.0, + ) + .execute(exec) + .await?; + + Ok(()) + } + + pub async fn remove_redirect_uris( + ids: impl IntoIterator, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + let ids = ids.into_iter().map(|id| id.0).collect_vec(); + sqlx::query!( + " + DELETE FROM oauth_client_redirect_uris + WHERE id IN + (SELECT * FROM UNNEST($1::bigint[])) + ", + &ids[..] + ) + .execute(exec) + .await?; + + Ok(()) + } + + pub async fn insert_redirect_uris( + uris: &[OAuthRedirectUri], + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + let (ids, client_ids, uris): (Vec<_>, Vec<_>, Vec<_>) = uris + .iter() + .map(|r| (r.id.0, r.client_id.0, r.uri.clone())) + .multiunzip(); + sqlx::query!( + " + INSERT INTO oauth_client_redirect_uris (id, client_id, uri) + SELECT * FROM UNNEST($1::bigint[], $2::bigint[], $3::varchar[]) + ", + &ids[..], + &client_ids[..], + &uris[..], + ) + .execute(exec) + .await?; + + Ok(()) + } + + pub fn hash_secret(secret: &str) -> String { + format!("{:x}", sha2::Sha512::digest(secret.as_bytes())) + } +} + +impl From for OAuthClient { + fn from(r: ClientQueryResult) -> Self { + let redirects = if let (Some(ids), Some(uris)) = + (r.uri_ids.as_ref(), r.uri_vals.as_ref()) + { + ids.iter() + .zip(uris.iter()) + .map(|(id, uri)| OAuthRedirectUri { + id: OAuthRedirectUriId(*id), + client_id: OAuthClientId(r.id), + uri: uri.to_string(), + }) + .collect() + } else { + vec![] + }; + + OAuthClient { + id: OAuthClientId(r.id), + name: r.name, + icon_url: r.icon_url, + raw_icon_url: r.raw_icon_url, + max_scopes: Scopes::from_postgres(r.max_scopes), + secret_hash: r.secret_hash, + redirect_uris: redirects, + created: r.created, + created_by: UserId(r.created_by), + url: r.url, + description: r.description, + } + } +} diff --git a/apps/labrinth/src/database/models/oauth_token_item.rs b/apps/labrinth/src/database/models/oauth_token_item.rs new file mode 100644 index 000000000..9c35a5904 --- /dev/null +++ b/apps/labrinth/src/database/models/oauth_token_item.rs @@ -0,0 +1,98 @@ +use super::{ + DatabaseError, OAuthAccessTokenId, OAuthClientAuthorizationId, + OAuthClientId, UserId, +}; +use crate::models::pats::Scopes; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::Digest; + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct OAuthAccessToken { + pub id: OAuthAccessTokenId, + pub authorization_id: OAuthClientAuthorizationId, + pub token_hash: String, + pub scopes: Scopes, + pub created: DateTime, + pub expires: DateTime, + pub last_used: Option>, + + // Stored separately inside oauth_client_authorizations table + pub client_id: OAuthClientId, + pub user_id: UserId, +} + +impl OAuthAccessToken { + pub async fn get( + token_hash: String, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let value = sqlx::query!( + " + SELECT + tokens.id, + tokens.authorization_id, + tokens.token_hash, + tokens.scopes, + tokens.created, + tokens.expires, + tokens.last_used, + auths.client_id, + auths.user_id + FROM oauth_access_tokens tokens + JOIN oauth_client_authorizations auths + ON tokens.authorization_id = auths.id + WHERE tokens.token_hash = $1 + ", + token_hash + ) + .fetch_optional(exec) + .await?; + + Ok(value.map(|r| OAuthAccessToken { + id: OAuthAccessTokenId(r.id), + authorization_id: OAuthClientAuthorizationId(r.authorization_id), + token_hash: r.token_hash, + scopes: Scopes::from_postgres(r.scopes), + created: r.created, + expires: r.expires, + last_used: r.last_used, + client_id: OAuthClientId(r.client_id), + user_id: UserId(r.user_id), + })) + } + + /// Inserts and returns the time until the token expires + pub async fn insert( + &self, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result { + let r = sqlx::query!( + " + INSERT INTO oauth_access_tokens ( + id, authorization_id, token_hash, scopes, last_used + ) + VALUES ( + $1, $2, $3, $4, $5 + ) + RETURNING created, expires + ", + self.id.0, + self.authorization_id.0, + self.token_hash, + self.scopes.to_postgres(), + Option::>::None + ) + .fetch_one(exec) + .await?; + + let (created, expires) = (r.created, r.expires); + let time_until_expiration = expires - created; + + Ok(time_until_expiration) + } + + pub fn hash_token(token: &str) -> String { + format!("{:x}", sha2::Sha512::digest(token.as_bytes())) + } +} diff --git a/apps/labrinth/src/database/models/organization_item.rs b/apps/labrinth/src/database/models/organization_item.rs new file mode 100644 index 000000000..b01052776 --- /dev/null +++ b/apps/labrinth/src/database/models/organization_item.rs @@ -0,0 +1,271 @@ +use crate::{ + database::redis::RedisPool, models::ids::base62_impl::parse_base62, +}; +use dashmap::DashMap; +use futures::TryStreamExt; +use std::fmt::{Debug, Display}; +use std::hash::Hash; + +use super::{ids::*, TeamMember}; +use serde::{Deserialize, Serialize}; + +const ORGANIZATIONS_NAMESPACE: &str = "organizations"; +const ORGANIZATIONS_TITLES_NAMESPACE: &str = "organizations_titles"; + +#[derive(Deserialize, Serialize, Clone, Debug)] +/// An organization of users who together control one or more projects and organizations. +pub struct Organization { + /// The id of the organization + pub id: OrganizationId, + + /// The slug of the organization + pub slug: String, + + /// The title of the organization + pub name: String, + + /// The associated team of the organization + pub team_id: TeamId, + + /// The description of the organization + pub description: String, + + /// The display icon for the organization + pub icon_url: Option, + pub raw_icon_url: Option, + pub color: Option, +} + +impl Organization { + pub async fn insert( + self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), super::DatabaseError> { + sqlx::query!( + " + INSERT INTO organizations (id, slug, name, team_id, description, icon_url, raw_icon_url, color) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ", + self.id.0, + self.slug, + self.name, + self.team_id as TeamId, + self.description, + self.icon_url, + self.raw_icon_url, + self.color.map(|x| x as i32), + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } + + pub async fn get<'a, E>( + string: &str, + exec: E, + redis: &RedisPool, + ) -> Result, super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + Self::get_many(&[string], exec, redis) + .await + .map(|x| x.into_iter().next()) + } + + pub async fn get_id<'a, 'b, E>( + id: OrganizationId, + exec: E, + redis: &RedisPool, + ) -> Result, super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + Self::get_many_ids(&[id], exec, redis) + .await + .map(|x| x.into_iter().next()) + } + + pub async fn get_many_ids<'a, 'b, E>( + organization_ids: &[OrganizationId], + exec: E, + redis: &RedisPool, + ) -> Result, super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let ids = organization_ids + .iter() + .map(|x| crate::models::ids::OrganizationId::from(*x)) + .collect::>(); + Self::get_many(&ids, exec, redis).await + } + + pub async fn get_many< + 'a, + E, + T: Display + Hash + Eq + PartialEq + Clone + Debug, + >( + organization_strings: &[T], + exec: E, + redis: &RedisPool, + ) -> Result, super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let val = redis + .get_cached_keys_with_slug( + ORGANIZATIONS_NAMESPACE, + ORGANIZATIONS_TITLES_NAMESPACE, + false, + organization_strings, + |ids| async move { + let org_ids: Vec = ids + .iter() + .flat_map(|x| parse_base62(&x.to_string()).ok()) + .map(|x| x as i64) + .collect(); + let slugs = ids + .into_iter() + .map(|x| x.to_string().to_lowercase()) + .collect::>(); + + let organizations = sqlx::query!( + " + SELECT o.id, o.slug, o.name, o.team_id, o.description, o.icon_url, o.raw_icon_url, o.color + FROM organizations o + WHERE o.id = ANY($1) OR LOWER(o.slug) = ANY($2) + GROUP BY o.id; + ", + &org_ids, + &slugs, + ) + .fetch(exec) + .try_fold(DashMap::new(), |acc, m| { + let org = Organization { + id: OrganizationId(m.id), + slug: m.slug.clone(), + name: m.name, + team_id: TeamId(m.team_id), + description: m.description, + icon_url: m.icon_url, + raw_icon_url: m.raw_icon_url, + color: m.color.map(|x| x as u32), + }; + + acc.insert(m.id, (Some(m.slug), org)); + async move { Ok(acc) } + }) + .await?; + + Ok(organizations) + }, + ) + .await?; + + Ok(val) + } + + // Gets organization associated with a project ID, if it exists and there is one + pub async fn get_associated_organization_project_id<'a, 'b, E>( + project_id: ProjectId, + exec: E, + ) -> Result, super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT o.id, o.slug, o.name, o.team_id, o.description, o.icon_url, o.raw_icon_url, o.color + FROM organizations o + LEFT JOIN mods m ON m.organization_id = o.id + WHERE m.id = $1 + GROUP BY o.id; + ", + project_id as ProjectId, + ) + .fetch_optional(exec) + .await?; + + if let Some(result) = result { + Ok(Some(Organization { + id: OrganizationId(result.id), + slug: result.slug, + name: result.name, + team_id: TeamId(result.team_id), + description: result.description, + icon_url: result.icon_url, + raw_icon_url: result.raw_icon_url, + color: result.color.map(|x| x as u32), + })) + } else { + Ok(None) + } + } + + pub async fn remove( + id: OrganizationId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &RedisPool, + ) -> Result, super::DatabaseError> { + let organization = Self::get_id(id, &mut **transaction, redis).await?; + + if let Some(organization) = organization { + sqlx::query!( + " + DELETE FROM organizations + WHERE id = $1 + ", + id as OrganizationId, + ) + .execute(&mut **transaction) + .await?; + + TeamMember::clear_cache(organization.team_id, redis).await?; + + sqlx::query!( + " + DELETE FROM team_members + WHERE team_id = $1 + ", + organization.team_id as TeamId, + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + DELETE FROM teams + WHERE id = $1 + ", + organization.team_id as TeamId, + ) + .execute(&mut **transaction) + .await?; + + Ok(Some(())) + } else { + Ok(None) + } + } + + pub async fn clear_cache( + id: OrganizationId, + slug: Option, + redis: &RedisPool, + ) -> Result<(), super::DatabaseError> { + let mut redis = redis.connect().await?; + + redis + .delete_many([ + (ORGANIZATIONS_NAMESPACE, Some(id.0.to_string())), + ( + ORGANIZATIONS_TITLES_NAMESPACE, + slug.map(|x| x.to_lowercase()), + ), + ]) + .await?; + Ok(()) + } +} diff --git a/apps/labrinth/src/database/models/pat_item.rs b/apps/labrinth/src/database/models/pat_item.rs new file mode 100644 index 000000000..205a70e4b --- /dev/null +++ b/apps/labrinth/src/database/models/pat_item.rs @@ -0,0 +1,240 @@ +use super::ids::*; +use crate::database::models::DatabaseError; +use crate::database::redis::RedisPool; +use crate::models::ids::base62_impl::parse_base62; +use crate::models::pats::Scopes; +use chrono::{DateTime, Utc}; +use dashmap::DashMap; +use futures::TryStreamExt; +use serde::{Deserialize, Serialize}; +use std::fmt::{Debug, Display}; +use std::hash::Hash; + +const PATS_NAMESPACE: &str = "pats"; +const PATS_TOKENS_NAMESPACE: &str = "pats_tokens"; +const PATS_USERS_NAMESPACE: &str = "pats_users"; + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct PersonalAccessToken { + pub id: PatId, + pub name: String, + pub access_token: String, + pub scopes: Scopes, + pub user_id: UserId, + pub created: DateTime, + pub expires: DateTime, + pub last_used: Option>, +} + +impl PersonalAccessToken { + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + sqlx::query!( + " + INSERT INTO pats ( + id, name, access_token, scopes, user_id, + expires + ) + VALUES ( + $1, $2, $3, $4, $5, + $6 + ) + ", + self.id as PatId, + self.name, + self.access_token, + self.scopes.bits() as i64, + self.user_id as UserId, + self.expires + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } + + pub async fn get< + 'a, + E, + T: Display + Hash + Eq + PartialEq + Clone + Debug, + >( + id: T, + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + Self::get_many(&[id], exec, redis) + .await + .map(|x| x.into_iter().next()) + } + + pub async fn get_many_ids<'a, E>( + pat_ids: &[PatId], + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let ids = pat_ids + .iter() + .map(|x| crate::models::ids::PatId::from(*x)) + .collect::>(); + PersonalAccessToken::get_many(&ids, exec, redis).await + } + + pub async fn get_many< + 'a, + E, + T: Display + Hash + Eq + PartialEq + Clone + Debug, + >( + pat_strings: &[T], + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let val = redis + .get_cached_keys_with_slug( + PATS_NAMESPACE, + PATS_TOKENS_NAMESPACE, + true, + pat_strings, + |ids| async move { + let pat_ids: Vec = ids + .iter() + .flat_map(|x| parse_base62(&x.to_string()).ok()) + .map(|x| x as i64) + .collect(); + let slugs = ids.into_iter().map(|x| x.to_string()).collect::>(); + + let pats = sqlx::query!( + " + SELECT id, name, access_token, scopes, user_id, created, expires, last_used + FROM pats + WHERE id = ANY($1) OR access_token = ANY($2) + ORDER BY created DESC + ", + &pat_ids, + &slugs, + ) + .fetch(exec) + .try_fold(DashMap::new(), |acc, x| { + let pat = PersonalAccessToken { + id: PatId(x.id), + name: x.name, + access_token: x.access_token.clone(), + scopes: Scopes::from_bits(x.scopes as u64).unwrap_or(Scopes::NONE), + user_id: UserId(x.user_id), + created: x.created, + expires: x.expires, + last_used: x.last_used, + }; + + acc.insert(x.id, (Some(x.access_token), pat)); + async move { Ok(acc) } + }) + .await?; + Ok(pats) + }, + ) + .await?; + + Ok(val) + } + + pub async fn get_user_pats<'a, E>( + user_id: UserId, + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let mut redis = redis.connect().await?; + + let res = redis + .get_deserialized_from_json::>( + PATS_USERS_NAMESPACE, + &user_id.0.to_string(), + ) + .await?; + + if let Some(res) = res { + return Ok(res.into_iter().map(PatId).collect()); + } + + let db_pats: Vec = sqlx::query!( + " + SELECT id + FROM pats + WHERE user_id = $1 + ORDER BY created DESC + ", + user_id.0, + ) + .fetch(exec) + .map_ok(|x| PatId(x.id)) + .try_collect::>() + .await?; + + redis + .set( + PATS_USERS_NAMESPACE, + &user_id.0.to_string(), + &serde_json::to_string(&db_pats)?, + None, + ) + .await?; + Ok(db_pats) + } + + pub async fn clear_cache( + clear_pats: Vec<(Option, Option, Option)>, + redis: &RedisPool, + ) -> Result<(), DatabaseError> { + let mut redis = redis.connect().await?; + + if clear_pats.is_empty() { + return Ok(()); + } + + redis + .delete_many(clear_pats.into_iter().flat_map( + |(id, token, user_id)| { + [ + (PATS_NAMESPACE, id.map(|i| i.0.to_string())), + (PATS_TOKENS_NAMESPACE, token), + ( + PATS_USERS_NAMESPACE, + user_id.map(|i| i.0.to_string()), + ), + ] + }, + )) + .await?; + + Ok(()) + } + + pub async fn remove( + id: PatId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result, sqlx::error::Error> { + sqlx::query!( + " + DELETE FROM pats WHERE id = $1 + ", + id as PatId, + ) + .execute(&mut **transaction) + .await?; + + Ok(Some(())) + } +} diff --git a/apps/labrinth/src/database/models/payout_item.rs b/apps/labrinth/src/database/models/payout_item.rs new file mode 100644 index 000000000..51ed85abb --- /dev/null +++ b/apps/labrinth/src/database/models/payout_item.rs @@ -0,0 +1,118 @@ +use crate::models::payouts::{PayoutMethodType, PayoutStatus}; +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; + +use super::{DatabaseError, PayoutId, UserId}; + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct Payout { + pub id: PayoutId, + pub user_id: UserId, + pub created: DateTime, + pub status: PayoutStatus, + pub amount: Decimal, + + pub fee: Option, + pub method: Option, + pub method_address: Option, + pub platform_id: Option, +} + +impl Payout { + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + sqlx::query!( + " + INSERT INTO payouts ( + id, amount, fee, user_id, status, method, method_address, platform_id + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8 + ) + ", + self.id.0, + self.amount, + self.fee, + self.user_id.0, + self.status.as_str(), + self.method.map(|x| x.as_str()), + self.method_address, + self.platform_id, + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } + + pub async fn get<'a, 'b, E>( + id: PayoutId, + executor: E, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + Payout::get_many(&[id], executor) + .await + .map(|x| x.into_iter().next()) + } + + pub async fn get_many<'a, E>( + payout_ids: &[PayoutId], + exec: E, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + use futures::TryStreamExt; + + let results = sqlx::query!( + " + SELECT id, user_id, created, amount, status, method, method_address, platform_id, fee + FROM payouts + WHERE id = ANY($1) + ", + &payout_ids.into_iter().map(|x| x.0).collect::>() + ) + .fetch(exec) + .map_ok(|r| Payout { + id: PayoutId(r.id), + user_id: UserId(r.user_id), + created: r.created, + status: PayoutStatus::from_string(&r.status), + amount: r.amount, + method: r.method.map(|x| PayoutMethodType::from_string(&x)), + method_address: r.method_address, + platform_id: r.platform_id, + fee: r.fee, + }) + .try_collect::>() + .await?; + + Ok(results) + } + + pub async fn get_all_for_user( + user_id: UserId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let results = sqlx::query!( + " + SELECT id + FROM payouts + WHERE user_id = $1 + ", + user_id.0 + ) + .fetch_all(exec) + .await?; + + Ok(results + .into_iter() + .map(|r| PayoutId(r.id)) + .collect::>()) + } +} diff --git a/apps/labrinth/src/database/models/product_item.rs b/apps/labrinth/src/database/models/product_item.rs new file mode 100644 index 000000000..eaca8b7df --- /dev/null +++ b/apps/labrinth/src/database/models/product_item.rs @@ -0,0 +1,264 @@ +use crate::database::models::{ + product_item, DatabaseError, ProductId, ProductPriceId, +}; +use crate::database::redis::RedisPool; +use crate::models::billing::{Price, ProductMetadata}; +use dashmap::DashMap; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use std::convert::TryFrom; +use std::convert::TryInto; + +const PRODUCTS_NAMESPACE: &str = "products"; + +pub struct ProductItem { + pub id: ProductId, + pub metadata: ProductMetadata, + pub unitary: bool, +} + +struct ProductResult { + id: i64, + metadata: serde_json::Value, + unitary: bool, +} + +macro_rules! select_products_with_predicate { + ($predicate:tt, $param:ident) => { + sqlx::query_as!( + ProductResult, + r#" + SELECT id, metadata, unitary + FROM products + "# + + $predicate, + $param + ) + }; +} + +impl TryFrom for ProductItem { + type Error = serde_json::Error; + + fn try_from(r: ProductResult) -> Result { + Ok(ProductItem { + id: ProductId(r.id), + metadata: serde_json::from_value(r.metadata)?, + unitary: r.unitary, + }) + } +} + +impl ProductItem { + pub async fn get( + id: ProductId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + Ok(Self::get_many(&[id], exec).await?.into_iter().next()) + } + + pub async fn get_many( + ids: &[ProductId], + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let ids = ids.iter().map(|id| id.0).collect_vec(); + let ids_ref: &[i64] = &ids; + let results = select_products_with_predicate!( + "WHERE id = ANY($1::bigint[])", + ids_ref + ) + .fetch_all(exec) + .await?; + + Ok(results + .into_iter() + .map(|r| r.try_into()) + .collect::, serde_json::Error>>()?) + } + + pub async fn get_all( + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let one = 1; + let results = select_products_with_predicate!("WHERE 1 = $1", one) + .fetch_all(exec) + .await?; + + Ok(results + .into_iter() + .map(|r| r.try_into()) + .collect::, serde_json::Error>>()?) + } +} + +#[derive(Deserialize, Serialize)] +pub struct QueryProduct { + pub id: ProductId, + pub metadata: ProductMetadata, + pub unitary: bool, + pub prices: Vec, +} + +impl QueryProduct { + pub async fn list<'a, E>( + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + let mut redis = redis.connect().await?; + + let res: Option> = redis + .get_deserialized_from_json(PRODUCTS_NAMESPACE, "all") + .await?; + + if let Some(res) = res { + return Ok(res); + } + + let all_products = product_item::ProductItem::get_all(exec).await?; + let prices = product_item::ProductPriceItem::get_all_products_prices( + &all_products.iter().map(|x| x.id).collect::>(), + exec, + ) + .await?; + + let products = all_products + .into_iter() + .map(|x| QueryProduct { + id: x.id, + metadata: x.metadata, + prices: prices + .remove(&x.id) + .map(|x| x.1) + .unwrap_or_default() + .into_iter() + .map(|x| ProductPriceItem { + id: x.id, + product_id: x.product_id, + prices: x.prices, + currency_code: x.currency_code, + }) + .collect(), + unitary: x.unitary, + }) + .collect::>(); + + redis + .set_serialized_to_json(PRODUCTS_NAMESPACE, "all", &products, None) + .await?; + + Ok(products) + } +} + +#[derive(Deserialize, Serialize)] +pub struct ProductPriceItem { + pub id: ProductPriceId, + pub product_id: ProductId, + pub prices: Price, + pub currency_code: String, +} + +struct ProductPriceResult { + id: i64, + product_id: i64, + prices: serde_json::Value, + currency_code: String, +} + +macro_rules! select_prices_with_predicate { + ($predicate:tt, $param:ident) => { + sqlx::query_as!( + ProductPriceResult, + r#" + SELECT id, product_id, prices, currency_code + FROM products_prices + "# + + $predicate, + $param + ) + }; +} + +impl TryFrom for ProductPriceItem { + type Error = serde_json::Error; + + fn try_from(r: ProductPriceResult) -> Result { + Ok(ProductPriceItem { + id: ProductPriceId(r.id), + product_id: ProductId(r.product_id), + prices: serde_json::from_value(r.prices)?, + currency_code: r.currency_code, + }) + } +} + +impl ProductPriceItem { + pub async fn get( + id: ProductPriceId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + Ok(Self::get_many(&[id], exec).await?.into_iter().next()) + } + + pub async fn get_many( + ids: &[ProductPriceId], + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let ids = ids.iter().map(|id| id.0).collect_vec(); + let ids_ref: &[i64] = &ids; + let results = select_prices_with_predicate!( + "WHERE id = ANY($1::bigint[])", + ids_ref + ) + .fetch_all(exec) + .await?; + + Ok(results + .into_iter() + .map(|r| r.try_into()) + .collect::, serde_json::Error>>()?) + } + + pub async fn get_all_product_prices( + product_id: ProductId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let res = Self::get_all_products_prices(&[product_id], exec).await?; + + Ok(res.remove(&product_id).map(|x| x.1).unwrap_or_default()) + } + + pub async fn get_all_products_prices( + product_ids: &[ProductId], + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result>, DatabaseError> { + let ids = product_ids.iter().map(|id| id.0).collect_vec(); + let ids_ref: &[i64] = &ids; + + use futures_util::TryStreamExt; + let prices = select_prices_with_predicate!( + "WHERE product_id = ANY($1::bigint[])", + ids_ref + ) + .fetch(exec) + .try_fold( + DashMap::new(), + |acc: DashMap>, x| { + if let Ok(item) = >::try_into(x) + { + acc.entry(item.product_id).or_default().push(item); + } + + async move { Ok(acc) } + }, + ) + .await?; + + Ok(prices) + } +} diff --git a/apps/labrinth/src/database/models/project_item.rs b/apps/labrinth/src/database/models/project_item.rs new file mode 100644 index 000000000..1bd07d224 --- /dev/null +++ b/apps/labrinth/src/database/models/project_item.rs @@ -0,0 +1,962 @@ +use super::loader_fields::{ + QueryLoaderField, QueryLoaderFieldEnumValue, QueryVersionField, + VersionField, +}; +use super::{ids::*, User}; +use crate::database::models; +use crate::database::models::DatabaseError; +use crate::database::redis::RedisPool; +use crate::models::ids::base62_impl::parse_base62; +use crate::models::projects::{MonetizationStatus, ProjectStatus}; +use chrono::{DateTime, Utc}; +use dashmap::{DashMap, DashSet}; +use futures::TryStreamExt; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use std::fmt::{Debug, Display}; +use std::hash::Hash; + +pub const PROJECTS_NAMESPACE: &str = "projects"; +pub const PROJECTS_SLUGS_NAMESPACE: &str = "projects_slugs"; +const PROJECTS_DEPENDENCIES_NAMESPACE: &str = "projects_dependencies"; + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct LinkUrl { + pub platform_id: LinkPlatformId, + pub platform_name: String, + pub url: String, + pub donation: bool, // Is this a donation link +} + +impl LinkUrl { + pub async fn insert_many_projects( + links: Vec, + project_id: ProjectId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), sqlx::error::Error> { + let (project_ids, platform_ids, urls): (Vec<_>, Vec<_>, Vec<_>) = links + .into_iter() + .map(|url| (project_id.0, url.platform_id.0, url.url)) + .multiunzip(); + sqlx::query!( + " + INSERT INTO mods_links ( + joining_mod_id, joining_platform_id, url + ) + SELECT * FROM UNNEST($1::bigint[], $2::int[], $3::varchar[]) + ", + &project_ids[..], + &platform_ids[..], + &urls[..], + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct GalleryItem { + pub image_url: String, + pub raw_image_url: String, + pub featured: bool, + pub name: Option, + pub description: Option, + pub created: DateTime, + pub ordering: i64, +} + +impl GalleryItem { + pub async fn insert_many( + items: Vec, + project_id: ProjectId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), sqlx::error::Error> { + let ( + project_ids, + image_urls, + raw_image_urls, + featureds, + names, + descriptions, + orderings, + ): (Vec<_>, Vec<_>, Vec<_>, Vec<_>, Vec<_>, Vec<_>, Vec<_>) = items + .into_iter() + .map(|gi| { + ( + project_id.0, + gi.image_url, + gi.raw_image_url, + gi.featured, + gi.name, + gi.description, + gi.ordering, + ) + }) + .multiunzip(); + sqlx::query!( + " + INSERT INTO mods_gallery ( + mod_id, image_url, raw_image_url, featured, name, description, ordering + ) + SELECT * FROM UNNEST ($1::bigint[], $2::varchar[], $3::varchar[], $4::bool[], $5::varchar[], $6::varchar[], $7::bigint[]) + ", + &project_ids[..], + &image_urls[..], + &raw_image_urls[..], + &featureds[..], + &names[..] as &[Option], + &descriptions[..] as &[Option], + &orderings[..] + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } +} + +#[derive(derive_new::new)] +pub struct ModCategory { + project_id: ProjectId, + category_id: CategoryId, + is_additional: bool, +} + +impl ModCategory { + pub async fn insert_many( + items: Vec, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + let (project_ids, category_ids, is_additionals): ( + Vec<_>, + Vec<_>, + Vec<_>, + ) = items + .into_iter() + .map(|mc| (mc.project_id.0, mc.category_id.0, mc.is_additional)) + .multiunzip(); + sqlx::query!( + " + INSERT INTO mods_categories (joining_mod_id, joining_category_id, is_additional) + SELECT * FROM UNNEST ($1::bigint[], $2::int[], $3::bool[]) + ", + &project_ids[..], + &category_ids[..], + &is_additionals[..] + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } +} + +#[derive(Clone)] +pub struct ProjectBuilder { + pub project_id: ProjectId, + pub team_id: TeamId, + pub organization_id: Option, + pub name: String, + pub summary: String, + pub description: String, + pub icon_url: Option, + pub raw_icon_url: Option, + pub license_url: Option, + pub categories: Vec, + pub additional_categories: Vec, + pub initial_versions: Vec, + pub status: ProjectStatus, + pub requested_status: Option, + pub license: String, + pub slug: Option, + pub link_urls: Vec, + pub gallery_items: Vec, + pub color: Option, + pub monetization_status: MonetizationStatus, +} + +impl ProjectBuilder { + pub async fn insert( + self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result { + let project_struct = Project { + id: self.project_id, + team_id: self.team_id, + organization_id: self.organization_id, + name: self.name, + summary: self.summary, + description: self.description, + published: Utc::now(), + updated: Utc::now(), + approved: None, + queued: if self.status == ProjectStatus::Processing { + Some(Utc::now()) + } else { + None + }, + status: self.status, + requested_status: self.requested_status, + downloads: 0, + follows: 0, + icon_url: self.icon_url, + raw_icon_url: self.raw_icon_url, + license_url: self.license_url, + license: self.license, + slug: self.slug, + moderation_message: None, + moderation_message_body: None, + webhook_sent: false, + color: self.color, + monetization_status: self.monetization_status, + loaders: vec![], + }; + project_struct.insert(&mut *transaction).await?; + + let ProjectBuilder { + link_urls, + gallery_items, + categories, + additional_categories, + .. + } = self; + + for mut version in self.initial_versions { + version.project_id = self.project_id; + version.insert(&mut *transaction).await?; + } + + LinkUrl::insert_many_projects( + link_urls, + self.project_id, + &mut *transaction, + ) + .await?; + + GalleryItem::insert_many( + gallery_items, + self.project_id, + &mut *transaction, + ) + .await?; + + let project_id = self.project_id; + let mod_categories = categories + .into_iter() + .map(|c| ModCategory::new(project_id, c, false)) + .chain( + additional_categories + .into_iter() + .map(|c| ModCategory::new(project_id, c, true)), + ) + .collect_vec(); + ModCategory::insert_many(mod_categories, &mut *transaction).await?; + + Ok(self.project_id) + } +} +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct Project { + pub id: ProjectId, + pub team_id: TeamId, + pub organization_id: Option, + pub name: String, + pub summary: String, + pub description: String, + pub published: DateTime, + pub updated: DateTime, + pub approved: Option>, + pub queued: Option>, + pub status: ProjectStatus, + pub requested_status: Option, + pub downloads: i32, + pub follows: i32, + pub icon_url: Option, + pub raw_icon_url: Option, + pub license_url: Option, + pub license: String, + pub slug: Option, + pub moderation_message: Option, + pub moderation_message_body: Option, + pub webhook_sent: bool, + pub color: Option, + pub monetization_status: MonetizationStatus, + pub loaders: Vec, +} + +impl Project { + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + sqlx::query!( + " + INSERT INTO mods ( + id, team_id, name, summary, description, + published, downloads, icon_url, raw_icon_url, status, requested_status, + license_url, license, + slug, color, monetization_status, organization_id + ) + VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, $11, + $12, $13, + LOWER($14), $15, $16, $17 + ) + ", + self.id as ProjectId, + self.team_id as TeamId, + &self.name, + &self.summary, + &self.description, + self.published, + self.downloads, + self.icon_url.as_ref(), + self.raw_icon_url.as_ref(), + self.status.as_str(), + self.requested_status.map(|x| x.as_str()), + self.license_url.as_ref(), + &self.license, + self.slug.as_ref(), + self.color.map(|x| x as i32), + self.monetization_status.as_str(), + self.organization_id.map(|x| x.0 as i64), + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } + + pub async fn remove( + id: ProjectId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &RedisPool, + ) -> Result, DatabaseError> { + let project = Self::get_id(id, &mut **transaction, redis).await?; + + if let Some(project) = project { + Project::clear_cache(id, project.inner.slug, Some(true), redis) + .await?; + + sqlx::query!( + " + DELETE FROM mod_follows + WHERE mod_id = $1 + ", + id as ProjectId + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + DELETE FROM mods_gallery + WHERE mod_id = $1 + ", + id as ProjectId + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + DELETE FROM mod_follows + WHERE mod_id = $1 + ", + id as ProjectId, + ) + .execute(&mut **transaction) + .await?; + + models::Thread::remove_full(project.thread_id, transaction).await?; + + sqlx::query!( + " + UPDATE reports + SET mod_id = NULL + WHERE mod_id = $1 + ", + id as ProjectId, + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + DELETE FROM mods_categories + WHERE joining_mod_id = $1 + ", + id as ProjectId, + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + DELETE FROM mods_links + WHERE joining_mod_id = $1 + ", + id as ProjectId, + ) + .execute(&mut **transaction) + .await?; + + for version in project.versions { + super::Version::remove_full(version, redis, transaction) + .await?; + } + + sqlx::query!( + " + DELETE FROM dependencies WHERE mod_dependency_id = $1 + ", + id as ProjectId, + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + UPDATE payouts_values + SET mod_id = NULL + WHERE (mod_id = $1) + ", + id as ProjectId, + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + DELETE FROM mods + WHERE id = $1 + ", + id as ProjectId, + ) + .execute(&mut **transaction) + .await?; + + models::TeamMember::clear_cache(project.inner.team_id, redis) + .await?; + + let affected_user_ids = sqlx::query!( + " + DELETE FROM team_members + WHERE team_id = $1 + RETURNING user_id + ", + project.inner.team_id as TeamId, + ) + .fetch(&mut **transaction) + .map_ok(|x| UserId(x.user_id)) + .try_collect::>() + .await?; + + User::clear_project_cache(&affected_user_ids, redis).await?; + + sqlx::query!( + " + DELETE FROM teams + WHERE id = $1 + ", + project.inner.team_id as TeamId, + ) + .execute(&mut **transaction) + .await?; + + Ok(Some(())) + } else { + Ok(None) + } + } + + pub async fn get<'a, 'b, E>( + string: &str, + executor: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Acquire<'a, Database = sqlx::Postgres>, + { + Project::get_many(&[string], executor, redis) + .await + .map(|x| x.into_iter().next()) + } + + pub async fn get_id<'a, 'b, E>( + id: ProjectId, + executor: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Acquire<'a, Database = sqlx::Postgres>, + { + Project::get_many( + &[crate::models::ids::ProjectId::from(id)], + executor, + redis, + ) + .await + .map(|x| x.into_iter().next()) + } + + pub async fn get_many_ids<'a, E>( + project_ids: &[ProjectId], + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Acquire<'a, Database = sqlx::Postgres>, + { + let ids = project_ids + .iter() + .map(|x| crate::models::ids::ProjectId::from(*x)) + .collect::>(); + Project::get_many(&ids, exec, redis).await + } + + pub async fn get_many< + 'a, + E, + T: Display + Hash + Eq + PartialEq + Clone + Debug, + >( + project_strings: &[T], + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Acquire<'a, Database = sqlx::Postgres>, + { + let val = redis.get_cached_keys_with_slug( + PROJECTS_NAMESPACE, + PROJECTS_SLUGS_NAMESPACE, + false, + project_strings, + |ids| async move { + let mut exec = exec.acquire().await?; + let project_ids_parsed: Vec = ids + .iter() + .flat_map(|x| parse_base62(&x.to_string()).ok()) + .map(|x| x as i64) + .collect(); + let slugs = ids + .into_iter() + .map(|x| x.to_string().to_lowercase()) + .collect::>(); + + let all_version_ids = DashSet::new(); + let versions: DashMap)>> = sqlx::query!( + " + SELECT DISTINCT mod_id, v.id as id, date_published + FROM mods m + INNER JOIN versions v ON m.id = v.mod_id AND v.status = ANY($3) + WHERE m.id = ANY($1) OR m.slug = ANY($2) + ", + &project_ids_parsed, + &slugs, + &*crate::models::projects::VersionStatus::iterator() + .filter(|x| x.is_listed()) + .map(|x| x.to_string()) + .collect::>() + ) + .fetch(&mut *exec) + .try_fold( + DashMap::new(), + |acc: DashMap)>>, m| { + let version_id = VersionId(m.id); + let date_published = m.date_published; + all_version_ids.insert(version_id); + acc.entry(ProjectId(m.mod_id)) + .or_default() + .push((version_id, date_published)); + async move { Ok(acc) } + }, + ) + .await?; + + let loader_field_enum_value_ids = DashSet::new(); + let version_fields: DashMap> = sqlx::query!( + " + SELECT DISTINCT mod_id, version_id, field_id, int_value, enum_value, string_value + FROM versions v + INNER JOIN version_fields vf ON v.id = vf.version_id + WHERE v.id = ANY($1) + ", + &all_version_ids.iter().map(|x| x.0).collect::>() + ) + .fetch(&mut *exec) + .try_fold( + DashMap::new(), + |acc: DashMap>, m| { + let qvf = QueryVersionField { + version_id: VersionId(m.version_id), + field_id: LoaderFieldId(m.field_id), + int_value: m.int_value, + enum_value: m.enum_value.map(LoaderFieldEnumValueId), + string_value: m.string_value, + }; + + if let Some(enum_value) = m.enum_value { + loader_field_enum_value_ids.insert(LoaderFieldEnumValueId(enum_value)); + } + + acc.entry(ProjectId(m.mod_id)).or_default().push(qvf); + async move { Ok(acc) } + }, + ) + .await?; + + let loader_field_enum_values: Vec = sqlx::query!( + " + SELECT DISTINCT id, enum_id, value, ordering, created, metadata + FROM loader_field_enum_values lfev + WHERE id = ANY($1) + ORDER BY enum_id, ordering, created DESC + ", + &loader_field_enum_value_ids + .iter() + .map(|x| x.0) + .collect::>() + ) + .fetch(&mut *exec) + .map_ok(|m| QueryLoaderFieldEnumValue { + id: LoaderFieldEnumValueId(m.id), + enum_id: LoaderFieldEnumId(m.enum_id), + value: m.value, + ordering: m.ordering, + created: m.created, + metadata: m.metadata, + }) + .try_collect() + .await?; + + let mods_gallery: DashMap> = sqlx::query!( + " + SELECT DISTINCT mod_id, mg.image_url, mg.raw_image_url, mg.featured, mg.name, mg.description, mg.created, mg.ordering + FROM mods_gallery mg + INNER JOIN mods m ON mg.mod_id = m.id + WHERE m.id = ANY($1) OR m.slug = ANY($2) + ", + &project_ids_parsed, + &slugs + ).fetch(&mut *exec) + .try_fold(DashMap::new(), |acc : DashMap>, m| { + acc.entry(ProjectId(m.mod_id)) + .or_default() + .push(GalleryItem { + image_url: m.image_url, + raw_image_url: m.raw_image_url, + featured: m.featured.unwrap_or(false), + name: m.name, + description: m.description, + created: m.created, + ordering: m.ordering, + }); + async move { Ok(acc) } + } + ).await?; + + let links: DashMap> = sqlx::query!( + " + SELECT DISTINCT joining_mod_id as mod_id, joining_platform_id as platform_id, lp.name as platform_name, url, lp.donation as donation + FROM mods_links ml + INNER JOIN mods m ON ml.joining_mod_id = m.id + INNER JOIN link_platforms lp ON ml.joining_platform_id = lp.id + WHERE m.id = ANY($1) OR m.slug = ANY($2) + ", + &project_ids_parsed, + &slugs + ).fetch(&mut *exec) + .try_fold(DashMap::new(), |acc : DashMap>, m| { + acc.entry(ProjectId(m.mod_id)) + .or_default() + .push(LinkUrl { + platform_id: LinkPlatformId(m.platform_id), + platform_name: m.platform_name, + url: m.url, + donation: m.donation, + }); + async move { Ok(acc) } + } + ).await?; + + #[derive(Default)] + struct VersionLoaderData { + loaders: Vec, + project_types: Vec, + games: Vec, + loader_loader_field_ids: Vec, + } + + let loader_field_ids = DashSet::new(); + let loaders_ptypes_games: DashMap = sqlx::query!( + " + SELECT DISTINCT mod_id, + ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders, + ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types, + ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games, + ARRAY_AGG(DISTINCT lfl.loader_field_id) filter (where lfl.loader_field_id is not null) loader_fields + FROM versions v + INNER JOIN loaders_versions lv ON v.id = lv.version_id + INNER JOIN loaders l ON lv.loader_id = l.id + INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id + INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id + INNER JOIN loaders_project_types_games lptg ON lptg.loader_id = l.id AND lptg.project_type_id = pt.id + INNER JOIN games g ON lptg.game_id = g.id + LEFT JOIN loader_fields_loaders lfl ON lfl.loader_id = l.id + WHERE v.id = ANY($1) + GROUP BY mod_id + ", + &all_version_ids.iter().map(|x| x.0).collect::>() + ).fetch(&mut *exec) + .map_ok(|m| { + let project_id = ProjectId(m.mod_id); + + // Add loader fields to the set we need to fetch + let loader_loader_field_ids = m.loader_fields.unwrap_or_default().into_iter().map(LoaderFieldId).collect::>(); + for loader_field_id in loader_loader_field_ids.iter() { + loader_field_ids.insert(*loader_field_id); + } + + // Add loader + loader associated data to the map + let version_loader_data = VersionLoaderData { + loaders: m.loaders.unwrap_or_default(), + project_types: m.project_types.unwrap_or_default(), + games: m.games.unwrap_or_default(), + loader_loader_field_ids, + }; + + (project_id, version_loader_data) + + } + ).try_collect().await?; + + let loader_fields: Vec = sqlx::query!( + " + SELECT DISTINCT id, field, field_type, enum_type, min_val, max_val, optional + FROM loader_fields lf + WHERE id = ANY($1) + ", + &loader_field_ids.iter().map(|x| x.0).collect::>() + ) + .fetch(&mut *exec) + .map_ok(|m| QueryLoaderField { + id: LoaderFieldId(m.id), + field: m.field, + field_type: m.field_type, + enum_type: m.enum_type.map(LoaderFieldEnumId), + min_val: m.min_val, + max_val: m.max_val, + optional: m.optional, + }) + .try_collect() + .await?; + + let projects = sqlx::query!( + " + SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows, + m.icon_url icon_url, m.raw_icon_url raw_icon_url, m.description description, m.published published, + m.updated updated, m.approved approved, m.queued, m.status status, m.requested_status requested_status, + m.license_url license_url, + m.team_id team_id, m.organization_id organization_id, m.license license, m.slug slug, m.moderation_message moderation_message, m.moderation_message_body moderation_message_body, + m.webhook_sent, m.color, + t.id thread_id, m.monetization_status monetization_status, + ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is false) categories, + ARRAY_AGG(DISTINCT c.category) filter (where c.category is not null and mc.is_additional is true) additional_categories + FROM mods m + INNER JOIN threads t ON t.mod_id = m.id + LEFT JOIN mods_categories mc ON mc.joining_mod_id = m.id + LEFT JOIN categories c ON mc.joining_category_id = c.id + WHERE m.id = ANY($1) OR m.slug = ANY($2) + GROUP BY t.id, m.id; + ", + &project_ids_parsed, + &slugs, + ) + .fetch(&mut *exec) + .try_fold(DashMap::new(), |acc, m| { + let id = m.id; + let project_id = ProjectId(id); + let VersionLoaderData { + loaders, + project_types, + games, + loader_loader_field_ids, + } = loaders_ptypes_games.remove(&project_id).map(|x|x.1).unwrap_or_default(); + let mut versions = versions.remove(&project_id).map(|x| x.1).unwrap_or_default(); + let mut gallery = mods_gallery.remove(&project_id).map(|x| x.1).unwrap_or_default(); + let urls = links.remove(&project_id).map(|x| x.1).unwrap_or_default(); + let version_fields = version_fields.remove(&project_id).map(|x| x.1).unwrap_or_default(); + + let loader_fields = loader_fields.iter() + .filter(|x| loader_loader_field_ids.contains(&x.id)) + .collect::>(); + + let project = QueryProject { + inner: Project { + id: ProjectId(id), + team_id: TeamId(m.team_id), + organization_id: m.organization_id.map(OrganizationId), + name: m.name.clone(), + summary: m.summary.clone(), + downloads: m.downloads, + icon_url: m.icon_url.clone(), + raw_icon_url: m.raw_icon_url.clone(), + published: m.published, + updated: m.updated, + license_url: m.license_url.clone(), + status: ProjectStatus::from_string( + &m.status, + ), + requested_status: m.requested_status.map(|x| ProjectStatus::from_string( + &x, + )), + license: m.license.clone(), + slug: m.slug.clone(), + description: m.description.clone(), + follows: m.follows, + moderation_message: m.moderation_message, + moderation_message_body: m.moderation_message_body, + approved: m.approved, + webhook_sent: m.webhook_sent, + color: m.color.map(|x| x as u32), + queued: m.queued, + monetization_status: MonetizationStatus::from_string( + &m.monetization_status, + ), + loaders, + }, + categories: m.categories.unwrap_or_default(), + additional_categories: m.additional_categories.unwrap_or_default(), + project_types, + games, + versions: { + // Each version is a tuple of (VersionId, DateTime) + versions.sort_by(|a, b| a.1.cmp(&b.1)); + versions.into_iter().map(|x| x.0).collect() + }, + gallery_items: { + gallery.sort_by(|a, b| a.ordering.cmp(&b.ordering)); + gallery + }, + urls, + aggregate_version_fields: VersionField::from_query_json(version_fields, &loader_fields, &loader_field_enum_values, true), + thread_id: ThreadId(m.thread_id), + }; + + acc.insert(m.id, (m.slug, project)); + async move { Ok(acc) } + }) + .await?; + + Ok(projects) + }, + ).await?; + + Ok(val) + } + + pub async fn get_dependencies<'a, E>( + id: ProjectId, + exec: E, + redis: &RedisPool, + ) -> Result< + Vec<(Option, Option, Option)>, + DatabaseError, + > + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + type Dependencies = + Vec<(Option, Option, Option)>; + + let mut redis = redis.connect().await?; + + let dependencies = redis + .get_deserialized_from_json::( + PROJECTS_DEPENDENCIES_NAMESPACE, + &id.0.to_string(), + ) + .await?; + if let Some(dependencies) = dependencies { + return Ok(dependencies); + } + + let dependencies: Dependencies = sqlx::query!( + " + SELECT d.dependency_id, COALESCE(vd.mod_id, 0) mod_id, d.mod_dependency_id + FROM versions v + INNER JOIN dependencies d ON d.dependent_id = v.id + LEFT JOIN versions vd ON d.dependency_id = vd.id + WHERE v.mod_id = $1 + ", + id as ProjectId + ) + .fetch(exec) + .map_ok(|x| { + ( + x.dependency_id.map(VersionId), + if x.mod_id == Some(0) { + None + } else { + x.mod_id.map(ProjectId) + }, + x.mod_dependency_id.map(ProjectId), + ) + }) + .try_collect::() + .await?; + + redis + .set_serialized_to_json( + PROJECTS_DEPENDENCIES_NAMESPACE, + id.0, + &dependencies, + None, + ) + .await?; + Ok(dependencies) + } + + pub async fn clear_cache( + id: ProjectId, + slug: Option, + clear_dependencies: Option, + redis: &RedisPool, + ) -> Result<(), DatabaseError> { + let mut redis = redis.connect().await?; + + redis + .delete_many([ + (PROJECTS_NAMESPACE, Some(id.0.to_string())), + (PROJECTS_SLUGS_NAMESPACE, slug.map(|x| x.to_lowercase())), + ( + PROJECTS_DEPENDENCIES_NAMESPACE, + if clear_dependencies.unwrap_or(false) { + Some(id.0.to_string()) + } else { + None + }, + ), + ]) + .await?; + Ok(()) + } +} + +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct QueryProject { + pub inner: Project, + pub categories: Vec, + pub additional_categories: Vec, + pub versions: Vec, + pub project_types: Vec, + pub games: Vec, + pub urls: Vec, + pub gallery_items: Vec, + pub thread_id: ThreadId, + pub aggregate_version_fields: Vec, +} diff --git a/apps/labrinth/src/database/models/report_item.rs b/apps/labrinth/src/database/models/report_item.rs new file mode 100644 index 000000000..703c5d60b --- /dev/null +++ b/apps/labrinth/src/database/models/report_item.rs @@ -0,0 +1,158 @@ +use super::ids::*; +use chrono::{DateTime, Utc}; + +pub struct Report { + pub id: ReportId, + pub report_type_id: ReportTypeId, + pub project_id: Option, + pub version_id: Option, + pub user_id: Option, + pub body: String, + pub reporter: UserId, + pub created: DateTime, + pub closed: bool, +} + +pub struct QueryReport { + pub id: ReportId, + pub report_type: String, + pub project_id: Option, + pub version_id: Option, + pub user_id: Option, + pub body: String, + pub reporter: UserId, + pub created: DateTime, + pub closed: bool, + pub thread_id: ThreadId, +} + +impl Report { + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), sqlx::error::Error> { + sqlx::query!( + " + INSERT INTO reports ( + id, report_type_id, mod_id, version_id, user_id, + body, reporter + ) + VALUES ( + $1, $2, $3, $4, $5, + $6, $7 + ) + ", + self.id as ReportId, + self.report_type_id as ReportTypeId, + self.project_id.map(|x| x.0 as i64), + self.version_id.map(|x| x.0 as i64), + self.user_id.map(|x| x.0 as i64), + self.body, + self.reporter as UserId + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } + + pub async fn get<'a, E>( + id: ReportId, + exec: E, + ) -> Result, sqlx::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + Self::get_many(&[id], exec) + .await + .map(|x| x.into_iter().next()) + } + + pub async fn get_many<'a, E>( + report_ids: &[ReportId], + exec: E, + ) -> Result, sqlx::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + use futures::stream::TryStreamExt; + + let report_ids_parsed: Vec = + report_ids.iter().map(|x| x.0).collect(); + let reports = sqlx::query!( + " + SELECT r.id, rt.name, r.mod_id, r.version_id, r.user_id, r.body, r.reporter, r.created, t.id thread_id, r.closed + FROM reports r + INNER JOIN report_types rt ON rt.id = r.report_type_id + INNER JOIN threads t ON t.report_id = r.id + WHERE r.id = ANY($1) + ORDER BY r.created DESC + ", + &report_ids_parsed + ) + .fetch(exec) + .map_ok(|x| QueryReport { + id: ReportId(x.id), + report_type: x.name, + project_id: x.mod_id.map(ProjectId), + version_id: x.version_id.map(VersionId), + user_id: x.user_id.map(UserId), + body: x.body, + reporter: UserId(x.reporter), + created: x.created, + closed: x.closed, + thread_id: ThreadId(x.thread_id) + }) + .try_collect::>() + .await?; + + Ok(reports) + } + + pub async fn remove_full( + id: ReportId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result, sqlx::error::Error> { + let result = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM reports WHERE id = $1) + ", + id as ReportId + ) + .fetch_one(&mut **transaction) + .await?; + + if !result.exists.unwrap_or(false) { + return Ok(None); + } + + let thread_id = sqlx::query!( + " + SELECT id FROM threads + WHERE report_id = $1 + ", + id as ReportId + ) + .fetch_optional(&mut **transaction) + .await?; + + if let Some(thread_id) = thread_id { + crate::database::models::Thread::remove_full( + ThreadId(thread_id.id), + transaction, + ) + .await?; + } + + sqlx::query!( + " + DELETE FROM reports WHERE id = $1 + ", + id as ReportId, + ) + .execute(&mut **transaction) + .await?; + + Ok(Some(())) + } +} diff --git a/apps/labrinth/src/database/models/session_item.rs b/apps/labrinth/src/database/models/session_item.rs new file mode 100644 index 000000000..adb1659ea --- /dev/null +++ b/apps/labrinth/src/database/models/session_item.rs @@ -0,0 +1,298 @@ +use super::ids::*; +use crate::database::models::DatabaseError; +use crate::database::redis::RedisPool; +use crate::models::ids::base62_impl::parse_base62; +use chrono::{DateTime, Utc}; +use dashmap::DashMap; +use serde::{Deserialize, Serialize}; +use std::fmt::{Debug, Display}; +use std::hash::Hash; + +const SESSIONS_NAMESPACE: &str = "sessions"; +const SESSIONS_IDS_NAMESPACE: &str = "sessions_ids"; +const SESSIONS_USERS_NAMESPACE: &str = "sessions_users"; + +pub struct SessionBuilder { + pub session: String, + pub user_id: UserId, + + pub os: Option, + pub platform: Option, + + pub city: Option, + pub country: Option, + + pub ip: String, + pub user_agent: String, +} + +impl SessionBuilder { + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result { + let id = generate_session_id(transaction).await?; + + sqlx::query!( + " + INSERT INTO sessions ( + id, session, user_id, os, platform, + city, country, ip, user_agent + ) + VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, $9 + ) + ", + id as SessionId, + self.session, + self.user_id as UserId, + self.os, + self.platform, + self.city, + self.country, + self.ip, + self.user_agent, + ) + .execute(&mut **transaction) + .await?; + + Ok(id) + } +} + +#[derive(Deserialize, Serialize)] +pub struct Session { + pub id: SessionId, + pub session: String, + pub user_id: UserId, + + pub created: DateTime, + pub last_login: DateTime, + pub expires: DateTime, + pub refresh_expires: DateTime, + + pub os: Option, + pub platform: Option, + pub user_agent: String, + + pub city: Option, + pub country: Option, + pub ip: String, +} + +impl Session { + pub async fn get< + 'a, + E, + T: Display + Hash + Eq + PartialEq + Clone + Debug, + >( + id: T, + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + Self::get_many(&[id], exec, redis) + .await + .map(|x| x.into_iter().next()) + } + + pub async fn get_id<'a, 'b, E>( + id: SessionId, + executor: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + Session::get_many( + &[crate::models::ids::SessionId::from(id)], + executor, + redis, + ) + .await + .map(|x| x.into_iter().next()) + } + + pub async fn get_many_ids<'a, E>( + session_ids: &[SessionId], + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let ids = session_ids + .iter() + .map(|x| crate::models::ids::SessionId::from(*x)) + .collect::>(); + Session::get_many(&ids, exec, redis).await + } + + pub async fn get_many< + 'a, + E, + T: Display + Hash + Eq + PartialEq + Clone + Debug, + >( + session_strings: &[T], + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + use futures::TryStreamExt; + + let val = redis.get_cached_keys_with_slug( + SESSIONS_NAMESPACE, + SESSIONS_IDS_NAMESPACE, + true, + session_strings, + |ids| async move { + let session_ids: Vec = ids + .iter() + .flat_map(|x| parse_base62(&x.to_string()).ok()) + .map(|x| x as i64) + .collect(); + let slugs = ids + .into_iter() + .map(|x| x.to_string()) + .collect::>(); + let db_sessions = sqlx::query!( + " + SELECT id, user_id, session, created, last_login, expires, refresh_expires, os, platform, + city, country, ip, user_agent + FROM sessions + WHERE id = ANY($1) OR session = ANY($2) + ORDER BY created DESC + ", + &session_ids, + &slugs, + ) + .fetch(exec) + .try_fold(DashMap::new(), |acc, x| { + let session = Session { + id: SessionId(x.id), + session: x.session.clone(), + user_id: UserId(x.user_id), + created: x.created, + last_login: x.last_login, + expires: x.expires, + refresh_expires: x.refresh_expires, + os: x.os, + platform: x.platform, + city: x.city, + country: x.country, + ip: x.ip, + user_agent: x.user_agent, + }; + + acc.insert(x.id, (Some(x.session), session)); + + async move { Ok(acc) } + }) + .await?; + + Ok(db_sessions) + }).await?; + + Ok(val) + } + + pub async fn get_user_sessions<'a, E>( + user_id: UserId, + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let mut redis = redis.connect().await?; + + let res = redis + .get_deserialized_from_json::>( + SESSIONS_USERS_NAMESPACE, + &user_id.0.to_string(), + ) + .await?; + + if let Some(res) = res { + return Ok(res.into_iter().map(SessionId).collect()); + } + + use futures::TryStreamExt; + let db_sessions: Vec = sqlx::query!( + " + SELECT id + FROM sessions + WHERE user_id = $1 + ORDER BY created DESC + ", + user_id.0, + ) + .fetch(exec) + .map_ok(|x| SessionId(x.id)) + .try_collect::>() + .await?; + + redis + .set_serialized_to_json( + SESSIONS_USERS_NAMESPACE, + user_id.0, + &db_sessions, + None, + ) + .await?; + + Ok(db_sessions) + } + + pub async fn clear_cache( + clear_sessions: Vec<( + Option, + Option, + Option, + )>, + redis: &RedisPool, + ) -> Result<(), DatabaseError> { + let mut redis = redis.connect().await?; + + if clear_sessions.is_empty() { + return Ok(()); + } + + redis + .delete_many(clear_sessions.into_iter().flat_map( + |(id, session, user_id)| { + [ + (SESSIONS_NAMESPACE, id.map(|i| i.0.to_string())), + (SESSIONS_IDS_NAMESPACE, session), + ( + SESSIONS_USERS_NAMESPACE, + user_id.map(|i| i.0.to_string()), + ), + ] + }, + )) + .await?; + Ok(()) + } + + pub async fn remove( + id: SessionId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result, sqlx::error::Error> { + sqlx::query!( + " + DELETE FROM sessions WHERE id = $1 + ", + id as SessionId, + ) + .execute(&mut **transaction) + .await?; + + Ok(Some(())) + } +} diff --git a/apps/labrinth/src/database/models/team_item.rs b/apps/labrinth/src/database/models/team_item.rs new file mode 100644 index 000000000..8f6f811ef --- /dev/null +++ b/apps/labrinth/src/database/models/team_item.rs @@ -0,0 +1,730 @@ +use super::{ids::*, Organization, Project}; +use crate::{ + database::redis::RedisPool, + models::teams::{OrganizationPermissions, ProjectPermissions}, +}; +use dashmap::DashMap; +use futures::TryStreamExt; +use itertools::Itertools; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; + +const TEAMS_NAMESPACE: &str = "teams"; + +pub struct TeamBuilder { + pub members: Vec, +} +pub struct TeamMemberBuilder { + pub user_id: UserId, + pub role: String, + pub is_owner: bool, + pub permissions: ProjectPermissions, + pub organization_permissions: Option, + pub accepted: bool, + pub payouts_split: Decimal, + pub ordering: i64, +} + +impl TeamBuilder { + pub async fn insert( + self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result { + let team_id = generate_team_id(transaction).await?; + + let team = Team { id: team_id }; + + sqlx::query!( + " + INSERT INTO teams (id) + VALUES ($1) + ", + team.id as TeamId, + ) + .execute(&mut **transaction) + .await?; + + let mut team_member_ids = Vec::new(); + for _ in self.members.iter() { + team_member_ids.push(generate_team_member_id(transaction).await?.0); + } + let TeamBuilder { members } = self; + let ( + team_ids, + user_ids, + roles, + is_owners, + permissions, + organization_permissions, + accepteds, + payouts_splits, + orderings, + ): ( + Vec<_>, + Vec<_>, + Vec<_>, + Vec<_>, + Vec<_>, + Vec<_>, + Vec<_>, + Vec<_>, + Vec<_>, + ) = members + .into_iter() + .map(|m| { + ( + team.id.0, + m.user_id.0, + m.role, + m.is_owner, + m.permissions.bits() as i64, + m.organization_permissions.map(|p| p.bits() as i64), + m.accepted, + m.payouts_split, + m.ordering, + ) + }) + .multiunzip(); + sqlx::query!( + " + INSERT INTO team_members (id, team_id, user_id, role, is_owner, permissions, organization_permissions, accepted, payouts_split, ordering) + SELECT * FROM UNNEST ($1::int8[], $2::int8[], $3::int8[], $4::varchar[], $5::bool[], $6::int8[], $7::int8[], $8::bool[], $9::numeric[], $10::int8[]) + ", + &team_member_ids[..], + &team_ids[..], + &user_ids[..], + &roles[..], + &is_owners[..], + &permissions[..], + &organization_permissions[..] as &[Option], + &accepteds[..], + &payouts_splits[..], + &orderings[..], + ) + .execute(&mut **transaction) + .await?; + + Ok(team_id) + } +} + +/// A team of users who control a project +pub struct Team { + /// The id of the team + pub id: TeamId, +} + +#[derive(Deserialize, Serialize, Clone, Debug, Copy)] +pub enum TeamAssociationId { + Project(ProjectId), + Organization(OrganizationId), +} + +impl Team { + pub async fn get_association<'a, 'b, E>( + id: TeamId, + executor: E, + ) -> Result, super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT m.id AS pid, NULL AS oid + FROM mods m + WHERE m.team_id = $1 + + UNION ALL + + SELECT NULL AS pid, o.id AS oid + FROM organizations o + WHERE o.team_id = $1 + ", + id as TeamId + ) + .fetch_optional(executor) + .await?; + + if let Some(t) = result { + // Only one of project_id or organization_id will be set + let mut team_association_id = None; + if let Some(pid) = t.pid { + team_association_id = + Some(TeamAssociationId::Project(ProjectId(pid))); + } + if let Some(oid) = t.oid { + team_association_id = + Some(TeamAssociationId::Organization(OrganizationId(oid))); + } + return Ok(team_association_id); + } + Ok(None) + } +} + +/// A member of a team +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct TeamMember { + pub id: TeamMemberId, + pub team_id: TeamId, + + /// The ID of the user associated with the member + pub user_id: UserId, + pub role: String, + pub is_owner: bool, + + // The permissions of the user in this project team + // For an organization team, these are the fallback permissions for any project in the organization + pub permissions: ProjectPermissions, + + // The permissions of the user in this organization team + // For a project team, this is None + pub organization_permissions: Option, + + pub accepted: bool, + pub payouts_split: Decimal, + pub ordering: i64, +} + +impl TeamMember { + // Lists the full members of a team + pub async fn get_from_team_full<'a, 'b, E>( + id: TeamId, + executor: E, + redis: &RedisPool, + ) -> Result, super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + Self::get_from_team_full_many(&[id], executor, redis).await + } + + pub async fn get_from_team_full_many<'a, E>( + team_ids: &[TeamId], + exec: E, + redis: &RedisPool, + ) -> Result, super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + if team_ids.is_empty() { + return Ok(Vec::new()); + } + + let val = redis.get_cached_keys( + TEAMS_NAMESPACE, + &team_ids.iter().map(|x| x.0).collect::>(), + |team_ids| async move { + let teams = sqlx::query!( + " + SELECT id, team_id, role AS member_role, is_owner, permissions, organization_permissions, + accepted, payouts_split, + ordering, user_id + FROM team_members + WHERE team_id = ANY($1) + ORDER BY team_id, ordering; + ", + &team_ids + ) + .fetch(exec) + .try_fold(DashMap::new(), |acc: DashMap>, m| { + let member = TeamMember { + id: TeamMemberId(m.id), + team_id: TeamId(m.team_id), + role: m.member_role, + is_owner: m.is_owner, + permissions: ProjectPermissions::from_bits(m.permissions as u64) + .unwrap_or_default(), + organization_permissions: m + .organization_permissions + .map(|p| OrganizationPermissions::from_bits(p as u64).unwrap_or_default()), + accepted: m.accepted, + user_id: UserId(m.user_id), + payouts_split: m.payouts_split, + ordering: m.ordering, + }; + + acc.entry(m.team_id) + .or_default() + .push(member); + + async move { Ok(acc) } + }) + .await?; + + Ok(teams) + }, + ).await?; + + Ok(val.into_iter().flatten().collect()) + } + + pub async fn clear_cache( + id: TeamId, + redis: &RedisPool, + ) -> Result<(), super::DatabaseError> { + let mut redis = redis.connect().await?; + redis.delete(TEAMS_NAMESPACE, id.0).await?; + Ok(()) + } + + /// Gets a team member from a user id and team id. Does not return pending members. + pub async fn get_from_user_id<'a, 'b, E>( + id: TeamId, + user_id: UserId, + executor: E, + ) -> Result, super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + Self::get_from_user_id_many(&[id], user_id, executor) + .await + .map(|x| x.into_iter().next()) + } + + /// Gets team members from user ids and team ids. Does not return pending members. + pub async fn get_from_user_id_many<'a, 'b, E>( + team_ids: &[TeamId], + user_id: UserId, + executor: E, + ) -> Result, super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let team_ids_parsed: Vec = team_ids.iter().map(|x| x.0).collect(); + + let team_members = sqlx::query!( + " + SELECT id, team_id, role AS member_role, is_owner, permissions, organization_permissions, + accepted, payouts_split, role, + ordering, user_id + FROM team_members + WHERE (team_id = ANY($1) AND user_id = $2 AND accepted = TRUE) + ORDER BY ordering + ", + &team_ids_parsed, + user_id as UserId + ) + .fetch(executor) + .map_ok(|m| TeamMember { + id: TeamMemberId(m.id), + team_id: TeamId(m.team_id), + user_id, + role: m.role, + is_owner: m.is_owner, + permissions: ProjectPermissions::from_bits(m.permissions as u64) + .unwrap_or_default(), + organization_permissions: m + .organization_permissions + .map(|p| OrganizationPermissions::from_bits(p as u64).unwrap_or_default()), + accepted: m.accepted, + payouts_split: m.payouts_split, + ordering: m.ordering, + }) + .try_collect::>() + .await?; + + Ok(team_members) + } + + /// Gets a team member from a user id and team id, including pending members. + pub async fn get_from_user_id_pending<'a, 'b, E>( + id: TeamId, + user_id: UserId, + executor: E, + ) -> Result, super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT id, team_id, role AS member_role, is_owner, permissions, organization_permissions, + accepted, payouts_split, role, + ordering, user_id + + FROM team_members + WHERE (team_id = $1 AND user_id = $2) + ORDER BY ordering + ", + id as TeamId, + user_id as UserId + ) + .fetch_optional(executor) + .await?; + + if let Some(m) = result { + Ok(Some(TeamMember { + id: TeamMemberId(m.id), + team_id: id, + user_id, + role: m.role, + is_owner: m.is_owner, + permissions: ProjectPermissions::from_bits( + m.permissions as u64, + ) + .unwrap_or_default(), + organization_permissions: m.organization_permissions.map(|p| { + OrganizationPermissions::from_bits(p as u64) + .unwrap_or_default() + }), + accepted: m.accepted, + payouts_split: m.payouts_split, + ordering: m.ordering, + })) + } else { + Ok(None) + } + } + + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), sqlx::error::Error> { + sqlx::query!( + " + INSERT INTO team_members ( + id, team_id, user_id, role, permissions, organization_permissions, is_owner, accepted, payouts_split + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7, $8, $9 + ) + ", + self.id as TeamMemberId, + self.team_id as TeamId, + self.user_id as UserId, + self.role, + self.permissions.bits() as i64, + self.organization_permissions.map(|p| p.bits() as i64), + self.is_owner, + self.accepted, + self.payouts_split + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } + + pub async fn delete<'a, 'b>( + id: TeamId, + user_id: UserId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), super::DatabaseError> { + sqlx::query!( + " + DELETE FROM team_members + WHERE (team_id = $1 AND user_id = $2 AND NOT is_owner = TRUE) + ", + id as TeamId, + user_id as UserId, + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } + + #[allow(clippy::too_many_arguments)] + pub async fn edit_team_member( + id: TeamId, + user_id: UserId, + new_permissions: Option, + new_organization_permissions: Option, + new_role: Option, + new_accepted: Option, + new_payouts_split: Option, + new_ordering: Option, + new_is_owner: Option, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), super::DatabaseError> { + if let Some(permissions) = new_permissions { + sqlx::query!( + " + UPDATE team_members + SET permissions = $1 + WHERE (team_id = $2 AND user_id = $3) + ", + permissions.bits() as i64, + id as TeamId, + user_id as UserId, + ) + .execute(&mut **transaction) + .await?; + } + + if let Some(organization_permissions) = new_organization_permissions { + sqlx::query!( + " + UPDATE team_members + SET organization_permissions = $1 + WHERE (team_id = $2 AND user_id = $3) + ", + organization_permissions.bits() as i64, + id as TeamId, + user_id as UserId, + ) + .execute(&mut **transaction) + .await?; + } + + if let Some(role) = new_role { + sqlx::query!( + " + UPDATE team_members + SET role = $1 + WHERE (team_id = $2 AND user_id = $3) + ", + role, + id as TeamId, + user_id as UserId, + ) + .execute(&mut **transaction) + .await?; + } + + if let Some(accepted) = new_accepted { + if accepted { + sqlx::query!( + " + UPDATE team_members + SET accepted = TRUE + WHERE (team_id = $1 AND user_id = $2) + ", + id as TeamId, + user_id as UserId, + ) + .execute(&mut **transaction) + .await?; + } + } + + if let Some(payouts_split) = new_payouts_split { + sqlx::query!( + " + UPDATE team_members + SET payouts_split = $1 + WHERE (team_id = $2 AND user_id = $3) + ", + payouts_split, + id as TeamId, + user_id as UserId, + ) + .execute(&mut **transaction) + .await?; + } + + if let Some(ordering) = new_ordering { + sqlx::query!( + " + UPDATE team_members + SET ordering = $1 + WHERE (team_id = $2 AND user_id = $3) + ", + ordering, + id as TeamId, + user_id as UserId, + ) + .execute(&mut **transaction) + .await?; + } + + if let Some(is_owner) = new_is_owner { + sqlx::query!( + " + UPDATE team_members + SET is_owner = $1 + WHERE (team_id = $2 AND user_id = $3) + ", + is_owner, + id as TeamId, + user_id as UserId, + ) + .execute(&mut **transaction) + .await?; + } + + Ok(()) + } + + pub async fn get_from_user_id_project<'a, 'b, E>( + id: ProjectId, + user_id: UserId, + allow_pending: bool, + executor: E, + ) -> Result, super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let accepted = if allow_pending { + vec![true, false] + } else { + vec![true] + }; + + let result = sqlx::query!( + " + SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.is_owner, tm.permissions, tm.organization_permissions, tm.accepted, tm.payouts_split, tm.ordering + FROM mods m + INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2 AND accepted = ANY($3) + WHERE m.id = $1 + ", + id as ProjectId, + user_id as UserId, + &accepted + ) + .fetch_optional(executor) + .await?; + + if let Some(m) = result { + Ok(Some(TeamMember { + id: TeamMemberId(m.id), + team_id: TeamId(m.team_id), + user_id, + role: m.role, + is_owner: m.is_owner, + permissions: ProjectPermissions::from_bits( + m.permissions as u64, + ) + .unwrap_or_default(), + organization_permissions: m.organization_permissions.map(|p| { + OrganizationPermissions::from_bits(p as u64) + .unwrap_or_default() + }), + accepted: m.accepted, + payouts_split: m.payouts_split, + ordering: m.ordering, + })) + } else { + Ok(None) + } + } + + pub async fn get_from_user_id_organization<'a, 'b, E>( + id: OrganizationId, + user_id: UserId, + allow_pending: bool, + executor: E, + ) -> Result, super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let accepted = if allow_pending { + vec![true, false] + } else { + vec![true] + }; + let result = sqlx::query!( + " + SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.is_owner, tm.permissions, tm.organization_permissions, tm.accepted, tm.payouts_split, tm.ordering + FROM organizations o + INNER JOIN team_members tm ON tm.team_id = o.team_id AND user_id = $2 AND accepted = ANY($3) + WHERE o.id = $1 + ", + id as OrganizationId, + user_id as UserId, + &accepted + ) + .fetch_optional(executor) + .await?; + + if let Some(m) = result { + Ok(Some(TeamMember { + id: TeamMemberId(m.id), + team_id: TeamId(m.team_id), + user_id, + role: m.role, + is_owner: m.is_owner, + permissions: ProjectPermissions::from_bits( + m.permissions as u64, + ) + .unwrap_or_default(), + organization_permissions: m.organization_permissions.map(|p| { + OrganizationPermissions::from_bits(p as u64) + .unwrap_or_default() + }), + accepted: m.accepted, + payouts_split: m.payouts_split, + ordering: m.ordering, + })) + } else { + Ok(None) + } + } + + pub async fn get_from_user_id_version<'a, 'b, E>( + id: VersionId, + user_id: UserId, + executor: E, + ) -> Result, super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let result = sqlx::query!( + " + SELECT tm.id, tm.team_id, tm.user_id, tm.role, tm.is_owner, tm.permissions, tm.organization_permissions, tm.accepted, tm.payouts_split, tm.ordering, v.mod_id + FROM versions v + INNER JOIN mods m ON m.id = v.mod_id + INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.user_id = $2 AND tm.accepted = TRUE + WHERE v.id = $1 + ", + id as VersionId, + user_id as UserId + ) + .fetch_optional(executor) + .await?; + + if let Some(m) = result { + Ok(Some(TeamMember { + id: TeamMemberId(m.id), + team_id: TeamId(m.team_id), + user_id, + role: m.role, + is_owner: m.is_owner, + permissions: ProjectPermissions::from_bits( + m.permissions as u64, + ) + .unwrap_or_default(), + organization_permissions: m.organization_permissions.map(|p| { + OrganizationPermissions::from_bits(p as u64) + .unwrap_or_default() + }), + accepted: m.accepted, + payouts_split: m.payouts_split, + ordering: m.ordering, + })) + } else { + Ok(None) + } + } + + // Gets both required members for checking permissions of an action on a project + // - project team member (a user's membership to a given project) + // - organization team member (a user's membership to a given organization that owns a given project) + pub async fn get_for_project_permissions<'a, 'b, E>( + project: &Project, + user_id: UserId, + executor: E, + ) -> Result<(Option, Option), super::DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + let project_team_member = + Self::get_from_user_id(project.team_id, user_id, executor).await?; + + let organization = + Organization::get_associated_organization_project_id( + project.id, executor, + ) + .await?; + + let organization_team_member = if let Some(organization) = &organization + { + Self::get_from_user_id(organization.team_id, user_id, executor) + .await? + } else { + None + }; + + Ok((project_team_member, organization_team_member)) + } +} diff --git a/apps/labrinth/src/database/models/thread_item.rs b/apps/labrinth/src/database/models/thread_item.rs new file mode 100644 index 000000000..38d6cbe43 --- /dev/null +++ b/apps/labrinth/src/database/models/thread_item.rs @@ -0,0 +1,277 @@ +use super::ids::*; +use crate::database::models::DatabaseError; +use crate::models::threads::{MessageBody, ThreadType}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +pub struct ThreadBuilder { + pub type_: ThreadType, + pub members: Vec, + pub project_id: Option, + pub report_id: Option, +} + +#[derive(Clone, Serialize)] +pub struct Thread { + pub id: ThreadId, + + pub project_id: Option, + pub report_id: Option, + pub type_: ThreadType, + + pub messages: Vec, + pub members: Vec, +} + +pub struct ThreadMessageBuilder { + pub author_id: Option, + pub body: MessageBody, + pub thread_id: ThreadId, + pub hide_identity: bool, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct ThreadMessage { + pub id: ThreadMessageId, + pub thread_id: ThreadId, + pub author_id: Option, + pub body: MessageBody, + pub created: DateTime, + pub hide_identity: bool, +} + +impl ThreadMessageBuilder { + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result { + let thread_message_id = generate_thread_message_id(transaction).await?; + + sqlx::query!( + " + INSERT INTO threads_messages ( + id, author_id, body, thread_id, hide_identity + ) + VALUES ( + $1, $2, $3, $4, $5 + ) + ", + thread_message_id as ThreadMessageId, + self.author_id.map(|x| x.0), + serde_json::value::to_value(self.body.clone())?, + self.thread_id as ThreadId, + self.hide_identity + ) + .execute(&mut **transaction) + .await?; + + Ok(thread_message_id) + } +} + +impl ThreadBuilder { + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result { + let thread_id = generate_thread_id(&mut *transaction).await?; + sqlx::query!( + " + INSERT INTO threads ( + id, thread_type, mod_id, report_id + ) + VALUES ( + $1, $2, $3, $4 + ) + ", + thread_id as ThreadId, + self.type_.as_str(), + self.project_id.map(|x| x.0), + self.report_id.map(|x| x.0), + ) + .execute(&mut **transaction) + .await?; + + let (thread_ids, members): (Vec<_>, Vec<_>) = + self.members.iter().map(|m| (thread_id.0, m.0)).unzip(); + sqlx::query!( + " + INSERT INTO threads_members ( + thread_id, user_id + ) + SELECT * FROM UNNEST ($1::int8[], $2::int8[]) + ", + &thread_ids[..], + &members[..], + ) + .execute(&mut **transaction) + .await?; + + Ok(thread_id) + } +} + +impl Thread { + pub async fn get<'a, E>( + id: ThreadId, + exec: E, + ) -> Result, sqlx::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + Self::get_many(&[id], exec) + .await + .map(|x| x.into_iter().next()) + } + + pub async fn get_many<'a, E>( + thread_ids: &[ThreadId], + exec: E, + ) -> Result, sqlx::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + use futures::stream::TryStreamExt; + + let thread_ids_parsed: Vec = + thread_ids.iter().map(|x| x.0).collect(); + let threads = sqlx::query!( + " + SELECT t.id, t.thread_type, t.mod_id, t.report_id, + ARRAY_AGG(DISTINCT tm.user_id) filter (where tm.user_id is not null) members, + JSONB_AGG(DISTINCT jsonb_build_object('id', tmsg.id, 'author_id', tmsg.author_id, 'thread_id', tmsg.thread_id, 'body', tmsg.body, 'created', tmsg.created, 'hide_identity', tmsg.hide_identity)) filter (where tmsg.id is not null) messages + FROM threads t + LEFT OUTER JOIN threads_messages tmsg ON tmsg.thread_id = t.id + LEFT OUTER JOIN threads_members tm ON tm.thread_id = t.id + WHERE t.id = ANY($1) + GROUP BY t.id + ", + &thread_ids_parsed + ) + .fetch(exec) + .map_ok(|x| Thread { + id: ThreadId(x.id), + project_id: x.mod_id.map(ProjectId), + report_id: x.report_id.map(ReportId), + type_: ThreadType::from_string(&x.thread_type), + messages: { + let mut messages: Vec = serde_json::from_value( + x.messages.unwrap_or_default(), + ) + .ok() + .unwrap_or_default(); + messages.sort_by(|a, b| a.created.cmp(&b.created)); + messages + }, + members: x.members.unwrap_or_default().into_iter().map(UserId).collect(), + }) + .try_collect::>() + .await?; + + Ok(threads) + } + + pub async fn remove_full( + id: ThreadId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result, sqlx::error::Error> { + sqlx::query!( + " + DELETE FROM threads_messages + WHERE thread_id = $1 + ", + id as ThreadId, + ) + .execute(&mut **transaction) + .await?; + sqlx::query!( + " + DELETE FROM threads_members + WHERE thread_id = $1 + ", + id as ThreadId + ) + .execute(&mut **transaction) + .await?; + sqlx::query!( + " + DELETE FROM threads + WHERE id = $1 + ", + id as ThreadId, + ) + .execute(&mut **transaction) + .await?; + + Ok(Some(())) + } +} + +impl ThreadMessage { + pub async fn get<'a, E>( + id: ThreadMessageId, + exec: E, + ) -> Result, sqlx::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + Self::get_many(&[id], exec) + .await + .map(|x| x.into_iter().next()) + } + + pub async fn get_many<'a, E>( + message_ids: &[ThreadMessageId], + exec: E, + ) -> Result, sqlx::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + use futures::stream::TryStreamExt; + + let message_ids_parsed: Vec = + message_ids.iter().map(|x| x.0).collect(); + let messages = sqlx::query!( + " + SELECT tm.id, tm.author_id, tm.thread_id, tm.body, tm.created, tm.hide_identity + FROM threads_messages tm + WHERE tm.id = ANY($1) + ", + &message_ids_parsed + ) + .fetch(exec) + .map_ok(|x| ThreadMessage { + id: ThreadMessageId(x.id), + thread_id: ThreadId(x.thread_id), + author_id: x.author_id.map(UserId), + body: serde_json::from_value(x.body).unwrap_or(MessageBody::Deleted { private: false }), + created: x.created, + hide_identity: x.hide_identity, + }) + .try_collect::>() + .await?; + + Ok(messages) + } + + pub async fn remove_full( + id: ThreadMessageId, + private: bool, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result, sqlx::error::Error> { + sqlx::query!( + " + UPDATE threads_messages + SET body = $2 + WHERE id = $1 + ", + id as ThreadMessageId, + serde_json::to_value(MessageBody::Deleted { private }) + .unwrap_or(serde_json::json!({})) + ) + .execute(&mut **transaction) + .await?; + + Ok(Some(())) + } +} diff --git a/apps/labrinth/src/database/models/user_item.rs b/apps/labrinth/src/database/models/user_item.rs new file mode 100644 index 000000000..ec0809c13 --- /dev/null +++ b/apps/labrinth/src/database/models/user_item.rs @@ -0,0 +1,671 @@ +use super::ids::{ProjectId, UserId}; +use super::{CollectionId, ReportId, ThreadId}; +use crate::database::models; +use crate::database::models::{DatabaseError, OrganizationId}; +use crate::database::redis::RedisPool; +use crate::models::ids::base62_impl::{parse_base62, to_base62}; +use crate::models::users::Badges; +use chrono::{DateTime, Utc}; +use dashmap::DashMap; +use serde::{Deserialize, Serialize}; +use std::fmt::{Debug, Display}; +use std::hash::Hash; + +const USERS_NAMESPACE: &str = "users"; +const USER_USERNAMES_NAMESPACE: &str = "users_usernames"; +const USERS_PROJECTS_NAMESPACE: &str = "users_projects"; + +#[derive(Deserialize, Serialize, Clone, Debug)] +pub struct User { + pub id: UserId, + + pub github_id: Option, + pub discord_id: Option, + pub gitlab_id: Option, + pub google_id: Option, + pub steam_id: Option, + pub microsoft_id: Option, + pub password: Option, + + pub paypal_id: Option, + pub paypal_country: Option, + pub paypal_email: Option, + pub venmo_handle: Option, + pub stripe_customer_id: Option, + + pub totp_secret: Option, + + pub username: String, + pub email: Option, + pub email_verified: bool, + pub avatar_url: Option, + pub raw_avatar_url: Option, + pub bio: Option, + pub created: DateTime, + pub role: String, + pub badges: Badges, +} + +impl User { + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), sqlx::error::Error> { + sqlx::query!( + " + INSERT INTO users ( + id, username, email, + avatar_url, raw_avatar_url, bio, created, + github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id, + email_verified, password, paypal_id, paypal_country, paypal_email, + venmo_handle, stripe_customer_id + ) + VALUES ( + $1, $2, $3, $4, $5, + $6, $7, + $8, $9, $10, $11, $12, $13, + $14, $15, $16, $17, $18, $19, $20 + ) + ", + self.id as UserId, + &self.username, + self.email.as_ref(), + self.avatar_url.as_ref(), + self.raw_avatar_url.as_ref(), + self.bio.as_ref(), + self.created, + self.github_id, + self.discord_id, + self.gitlab_id, + self.google_id, + self.steam_id, + self.microsoft_id, + self.email_verified, + self.password, + self.paypal_id, + self.paypal_country, + self.paypal_email, + self.venmo_handle, + self.stripe_customer_id + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } + + pub async fn get<'a, 'b, E>( + string: &str, + executor: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + User::get_many(&[string], executor, redis) + .await + .map(|x| x.into_iter().next()) + } + + pub async fn get_id<'a, 'b, E>( + id: UserId, + executor: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + User::get_many(&[crate::models::ids::UserId::from(id)], executor, redis) + .await + .map(|x| x.into_iter().next()) + } + + pub async fn get_many_ids<'a, E>( + user_ids: &[UserId], + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + let ids = user_ids + .iter() + .map(|x| crate::models::ids::UserId::from(*x)) + .collect::>(); + User::get_many(&ids, exec, redis).await + } + + pub async fn get_many< + 'a, + E, + T: Display + Hash + Eq + PartialEq + Clone + Debug, + >( + users_strings: &[T], + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + use futures::TryStreamExt; + + let val = redis.get_cached_keys_with_slug( + USERS_NAMESPACE, + USER_USERNAMES_NAMESPACE, + false, + users_strings, + |ids| async move { + let user_ids: Vec = ids + .iter() + .flat_map(|x| parse_base62(&x.to_string()).ok()) + .map(|x| x as i64) + .collect(); + let slugs = ids + .into_iter() + .map(|x| x.to_string().to_lowercase()) + .collect::>(); + + let users = sqlx::query!( + " + SELECT id, email, + avatar_url, raw_avatar_url, username, bio, + created, role, badges, + github_id, discord_id, gitlab_id, google_id, steam_id, microsoft_id, + email_verified, password, totp_secret, paypal_id, paypal_country, paypal_email, + venmo_handle, stripe_customer_id + FROM users + WHERE id = ANY($1) OR LOWER(username) = ANY($2) + ", + &user_ids, + &slugs, + ) + .fetch(exec) + .try_fold(DashMap::new(), |acc, u| { + let user = User { + id: UserId(u.id), + github_id: u.github_id, + discord_id: u.discord_id, + gitlab_id: u.gitlab_id, + google_id: u.google_id, + steam_id: u.steam_id, + microsoft_id: u.microsoft_id, + email: u.email, + email_verified: u.email_verified, + avatar_url: u.avatar_url, + raw_avatar_url: u.raw_avatar_url, + username: u.username.clone(), + bio: u.bio, + created: u.created, + role: u.role, + badges: Badges::from_bits(u.badges as u64).unwrap_or_default(), + password: u.password, + paypal_id: u.paypal_id, + paypal_country: u.paypal_country, + paypal_email: u.paypal_email, + venmo_handle: u.venmo_handle, + stripe_customer_id: u.stripe_customer_id, + totp_secret: u.totp_secret, + }; + + acc.insert(u.id, (Some(u.username), user)); + async move { Ok(acc) } + }) + .await?; + + Ok(users) + }).await?; + Ok(val) + } + + pub async fn get_email<'a, E>( + email: &str, + exec: E, + ) -> Result, sqlx::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + let user_pass = sqlx::query!( + " + SELECT id FROM users + WHERE email = $1 + ", + email + ) + .fetch_optional(exec) + .await?; + + Ok(user_pass.map(|x| UserId(x.id))) + } + + pub async fn get_projects<'a, E>( + user_id: UserId, + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + use futures::stream::TryStreamExt; + + let mut redis = redis.connect().await?; + + let cached_projects = redis + .get_deserialized_from_json::>( + USERS_PROJECTS_NAMESPACE, + &user_id.0.to_string(), + ) + .await?; + + if let Some(projects) = cached_projects { + return Ok(projects); + } + + let db_projects = sqlx::query!( + " + SELECT m.id FROM mods m + INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.accepted = TRUE + WHERE tm.user_id = $1 + ORDER BY m.downloads DESC + ", + user_id as UserId, + ) + .fetch(exec) + .map_ok(|m| ProjectId(m.id)) + .try_collect::>() + .await?; + + redis + .set_serialized_to_json( + USERS_PROJECTS_NAMESPACE, + user_id.0, + &db_projects, + None, + ) + .await?; + + Ok(db_projects) + } + + pub async fn get_organizations<'a, E>( + user_id: UserId, + exec: E, + ) -> Result, sqlx::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + use futures::stream::TryStreamExt; + + let orgs = sqlx::query!( + " + SELECT o.id FROM organizations o + INNER JOIN team_members tm ON tm.team_id = o.team_id AND tm.accepted = TRUE + WHERE tm.user_id = $1 + ", + user_id as UserId, + ) + .fetch(exec) + .map_ok(|m| OrganizationId(m.id)) + .try_collect::>() + .await?; + + Ok(orgs) + } + + pub async fn get_collections<'a, E>( + user_id: UserId, + exec: E, + ) -> Result, sqlx::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + use futures::stream::TryStreamExt; + + let projects = sqlx::query!( + " + SELECT c.id FROM collections c + WHERE c.user_id = $1 + ", + user_id as UserId, + ) + .fetch(exec) + .map_ok(|m| CollectionId(m.id)) + .try_collect::>() + .await?; + + Ok(projects) + } + + pub async fn get_follows<'a, E>( + user_id: UserId, + exec: E, + ) -> Result, sqlx::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + use futures::stream::TryStreamExt; + + let projects = sqlx::query!( + " + SELECT mf.mod_id FROM mod_follows mf + WHERE mf.follower_id = $1 + ", + user_id as UserId, + ) + .fetch(exec) + .map_ok(|m| ProjectId(m.mod_id)) + .try_collect::>() + .await?; + + Ok(projects) + } + + pub async fn get_reports<'a, E>( + user_id: UserId, + exec: E, + ) -> Result, sqlx::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + use futures::stream::TryStreamExt; + + let reports = sqlx::query!( + " + SELECT r.id FROM reports r + WHERE r.user_id = $1 + ", + user_id as UserId, + ) + .fetch(exec) + .map_ok(|m| ReportId(m.id)) + .try_collect::>() + .await?; + + Ok(reports) + } + + pub async fn get_backup_codes<'a, E>( + user_id: UserId, + exec: E, + ) -> Result, sqlx::Error> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + use futures::stream::TryStreamExt; + + let codes = sqlx::query!( + " + SELECT code FROM user_backup_codes + WHERE user_id = $1 + ", + user_id as UserId, + ) + .fetch(exec) + .map_ok(|m| to_base62(m.code as u64)) + .try_collect::>() + .await?; + + Ok(codes) + } + + pub async fn clear_caches( + user_ids: &[(UserId, Option)], + redis: &RedisPool, + ) -> Result<(), DatabaseError> { + let mut redis = redis.connect().await?; + + redis + .delete_many(user_ids.iter().flat_map(|(id, username)| { + [ + (USERS_NAMESPACE, Some(id.0.to_string())), + ( + USER_USERNAMES_NAMESPACE, + username.clone().map(|i| i.to_lowercase()), + ), + ] + })) + .await?; + Ok(()) + } + + pub async fn clear_project_cache( + user_ids: &[UserId], + redis: &RedisPool, + ) -> Result<(), DatabaseError> { + let mut redis = redis.connect().await?; + + redis + .delete_many( + user_ids.iter().map(|id| { + (USERS_PROJECTS_NAMESPACE, Some(id.0.to_string())) + }), + ) + .await?; + + Ok(()) + } + + pub async fn remove( + id: UserId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &RedisPool, + ) -> Result, DatabaseError> { + let user = Self::get_id(id, &mut **transaction, redis).await?; + + if let Some(delete_user) = user { + User::clear_caches(&[(id, Some(delete_user.username))], redis) + .await?; + + let deleted_user: UserId = + crate::models::users::DELETED_USER.into(); + + sqlx::query!( + " + UPDATE team_members + SET user_id = $1 + WHERE (user_id = $2 AND is_owner = TRUE) + ", + deleted_user as UserId, + id as UserId, + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + UPDATE versions + SET author_id = $1 + WHERE (author_id = $2) + ", + deleted_user as UserId, + id as UserId, + ) + .execute(&mut **transaction) + .await?; + + use futures::TryStreamExt; + let notifications: Vec = sqlx::query!( + " + SELECT n.id FROM notifications n + WHERE n.user_id = $1 + ", + id as UserId, + ) + .fetch(&mut **transaction) + .map_ok(|m| m.id) + .try_collect::>() + .await?; + + sqlx::query!( + " + DELETE FROM notifications + WHERE user_id = $1 + ", + id as UserId, + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + DELETE FROM notifications_actions + WHERE notification_id = ANY($1) + ", + ¬ifications + ) + .execute(&mut **transaction) + .await?; + + let user_collections = sqlx::query!( + " + SELECT id + FROM collections + WHERE user_id = $1 + ", + id as UserId, + ) + .fetch(&mut **transaction) + .map_ok(|x| CollectionId(x.id)) + .try_collect::>() + .await?; + + for collection_id in user_collections { + models::Collection::remove(collection_id, transaction, redis) + .await?; + } + + let report_threads = sqlx::query!( + " + SELECT t.id + FROM threads t + INNER JOIN reports r ON t.report_id = r.id AND (r.user_id = $1 OR r.reporter = $1) + WHERE report_id IS NOT NULL + ", + id as UserId, + ) + .fetch(&mut **transaction) + .map_ok(|x| ThreadId(x.id)) + .try_collect::>() + .await?; + + for thread_id in report_threads { + models::Thread::remove_full(thread_id, transaction).await?; + } + + sqlx::query!( + " + DELETE FROM reports + WHERE user_id = $1 OR reporter = $1 + ", + id as UserId, + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + DELETE FROM mod_follows + WHERE follower_id = $1 + ", + id as UserId, + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + DELETE FROM team_members + WHERE user_id = $1 + ", + id as UserId, + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + DELETE FROM payouts_values + WHERE user_id = $1 + ", + id as UserId, + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + DELETE FROM payouts + WHERE user_id = $1 + ", + id as UserId, + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + r#" + UPDATE threads_messages + SET body = '{"type": "deleted"}', author_id = $2 + WHERE author_id = $1 + "#, + id as UserId, + deleted_user as UserId, + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + DELETE FROM threads_members + WHERE user_id = $1 + ", + id as UserId, + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + DELETE FROM sessions + WHERE user_id = $1 + ", + id as UserId, + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + DELETE FROM pats + WHERE user_id = $1 + ", + id as UserId, + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + DELETE FROM user_backup_codes + WHERE user_id = $1 + ", + id as UserId, + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + DELETE FROM users + WHERE id = $1 + ", + id as UserId, + ) + .execute(&mut **transaction) + .await?; + + Ok(Some(())) + } else { + Ok(None) + } + } +} diff --git a/apps/labrinth/src/database/models/user_subscription_item.rs b/apps/labrinth/src/database/models/user_subscription_item.rs new file mode 100644 index 000000000..edf2e1a59 --- /dev/null +++ b/apps/labrinth/src/database/models/user_subscription_item.rs @@ -0,0 +1,139 @@ +use crate::database::models::{ + DatabaseError, ProductPriceId, UserId, UserSubscriptionId, +}; +use crate::models::billing::{ + PriceDuration, SubscriptionMetadata, SubscriptionStatus, +}; +use chrono::{DateTime, Utc}; +use itertools::Itertools; +use std::convert::{TryFrom, TryInto}; + +pub struct UserSubscriptionItem { + pub id: UserSubscriptionId, + pub user_id: UserId, + pub price_id: ProductPriceId, + pub interval: PriceDuration, + pub created: DateTime, + pub status: SubscriptionStatus, + pub metadata: Option, +} + +struct UserSubscriptionResult { + id: i64, + user_id: i64, + price_id: i64, + interval: String, + pub created: DateTime, + pub status: String, + pub metadata: serde_json::Value, +} + +macro_rules! select_user_subscriptions_with_predicate { + ($predicate:tt, $param:ident) => { + sqlx::query_as!( + UserSubscriptionResult, + r#" + SELECT + us.id, us.user_id, us.price_id, us.interval, us.created, us.status, us.metadata + FROM users_subscriptions us + "# + + $predicate, + $param + ) + }; +} + +impl TryFrom for UserSubscriptionItem { + type Error = serde_json::Error; + + fn try_from(r: UserSubscriptionResult) -> Result { + Ok(UserSubscriptionItem { + id: UserSubscriptionId(r.id), + user_id: UserId(r.user_id), + price_id: ProductPriceId(r.price_id), + interval: PriceDuration::from_string(&r.interval), + created: r.created, + status: SubscriptionStatus::from_string(&r.status), + metadata: serde_json::from_value(r.metadata)?, + }) + } +} + +impl UserSubscriptionItem { + pub async fn get( + id: UserSubscriptionId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + Ok(Self::get_many(&[id], exec).await?.into_iter().next()) + } + + pub async fn get_many( + ids: &[UserSubscriptionId], + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let ids = ids.iter().map(|id| id.0).collect_vec(); + let ids_ref: &[i64] = &ids; + let results = select_user_subscriptions_with_predicate!( + "WHERE us.id = ANY($1::bigint[])", + ids_ref + ) + .fetch_all(exec) + .await?; + + Ok(results + .into_iter() + .map(|r| r.try_into()) + .collect::, serde_json::Error>>()?) + } + + pub async fn get_all_user( + user_id: UserId, + exec: impl sqlx::Executor<'_, Database = sqlx::Postgres>, + ) -> Result, DatabaseError> { + let user_id = user_id.0; + let results = select_user_subscriptions_with_predicate!( + "WHERE us.user_id = $1", + user_id + ) + .fetch_all(exec) + .await?; + + Ok(results + .into_iter() + .map(|r| r.try_into()) + .collect::, serde_json::Error>>()?) + } + + pub async fn upsert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + sqlx::query!( + " + INSERT INTO users_subscriptions ( + id, user_id, price_id, interval, created, status, metadata + ) + VALUES ( + $1, $2, $3, $4, $5, $6, $7 + ) + ON CONFLICT (id) + DO UPDATE + SET interval = EXCLUDED.interval, + status = EXCLUDED.status, + price_id = EXCLUDED.price_id, + metadata = EXCLUDED.metadata + ", + self.id.0, + self.user_id.0, + self.price_id.0, + self.interval.as_str(), + self.created, + self.status.as_str(), + serde_json::to_value(&self.metadata)?, + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } +} diff --git a/apps/labrinth/src/database/models/version_item.rs b/apps/labrinth/src/database/models/version_item.rs new file mode 100644 index 000000000..792c9ac0e --- /dev/null +++ b/apps/labrinth/src/database/models/version_item.rs @@ -0,0 +1,1052 @@ +use super::ids::*; +use super::loader_fields::VersionField; +use super::DatabaseError; +use crate::database::models::loader_fields::{ + QueryLoaderField, QueryLoaderFieldEnumValue, QueryVersionField, +}; +use crate::database::redis::RedisPool; +use crate::models::projects::{FileType, VersionStatus}; +use chrono::{DateTime, Utc}; +use dashmap::{DashMap, DashSet}; +use futures::TryStreamExt; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use std::cmp::Ordering; +use std::collections::HashMap; +use std::iter; + +pub const VERSIONS_NAMESPACE: &str = "versions"; +const VERSION_FILES_NAMESPACE: &str = "versions_files"; + +#[derive(Clone)] +pub struct VersionBuilder { + pub version_id: VersionId, + pub project_id: ProjectId, + pub author_id: UserId, + pub name: String, + pub version_number: String, + pub changelog: String, + pub files: Vec, + pub dependencies: Vec, + pub loaders: Vec, + pub version_fields: Vec, + pub version_type: String, + pub featured: bool, + pub status: VersionStatus, + pub requested_status: Option, + pub ordering: Option, +} + +#[derive(Clone)] +pub struct DependencyBuilder { + pub project_id: Option, + pub version_id: Option, + pub file_name: Option, + pub dependency_type: String, +} + +impl DependencyBuilder { + pub async fn insert_many( + builders: Vec, + version_id: VersionId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + let mut project_ids = Vec::new(); + for dependency in builders.iter() { + project_ids.push( + dependency + .try_get_project_id(transaction) + .await? + .map(|id| id.0), + ); + } + + let (version_ids, dependency_types, dependency_ids, filenames): ( + Vec<_>, + Vec<_>, + Vec<_>, + Vec<_>, + ) = builders + .into_iter() + .map(|d| { + ( + version_id.0, + d.dependency_type, + d.version_id.map(|v| v.0), + d.file_name, + ) + }) + .multiunzip(); + sqlx::query!( + " + INSERT INTO dependencies (dependent_id, dependency_type, dependency_id, mod_dependency_id, dependency_file_name) + SELECT * FROM UNNEST ($1::bigint[], $2::varchar[], $3::bigint[], $4::bigint[], $5::varchar[]) + ", + &version_ids[..], + &dependency_types[..], + &dependency_ids[..] as &[Option], + &project_ids[..] as &[Option], + &filenames[..] as &[Option], + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } + + async fn try_get_project_id( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result, DatabaseError> { + Ok(if let Some(project_id) = self.project_id { + Some(project_id) + } else if let Some(version_id) = self.version_id { + sqlx::query!( + " + SELECT mod_id FROM versions WHERE id = $1 + ", + version_id as VersionId, + ) + .fetch_optional(&mut **transaction) + .await? + .map(|x| ProjectId(x.mod_id)) + } else { + None + }) + } +} + +#[derive(Clone, Debug)] +pub struct VersionFileBuilder { + pub url: String, + pub filename: String, + pub hashes: Vec, + pub primary: bool, + pub size: u32, + pub file_type: Option, +} + +impl VersionFileBuilder { + pub async fn insert( + self, + version_id: VersionId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result { + let file_id = generate_file_id(&mut *transaction).await?; + + sqlx::query!( + " + INSERT INTO files (id, version_id, url, filename, is_primary, size, file_type) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ", + file_id as FileId, + version_id as VersionId, + self.url, + self.filename, + self.primary, + self.size as i32, + self.file_type.map(|x| x.as_str()), + ) + .execute(&mut **transaction) + .await?; + + for hash in self.hashes { + sqlx::query!( + " + INSERT INTO hashes (file_id, algorithm, hash) + VALUES ($1, $2, $3) + ", + file_id as FileId, + hash.algorithm, + hash.hash, + ) + .execute(&mut **transaction) + .await?; + } + + Ok(file_id) + } +} + +#[derive(Clone, Debug)] +pub struct HashBuilder { + pub algorithm: String, + pub hash: Vec, +} + +impl VersionBuilder { + pub async fn insert( + self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result { + let version = Version { + id: self.version_id, + project_id: self.project_id, + author_id: self.author_id, + name: self.name, + version_number: self.version_number, + changelog: self.changelog, + date_published: Utc::now(), + downloads: 0, + featured: self.featured, + version_type: self.version_type, + status: self.status, + requested_status: self.requested_status, + ordering: self.ordering, + }; + + version.insert(transaction).await?; + + sqlx::query!( + " + UPDATE mods + SET updated = NOW() + WHERE id = $1 + ", + self.project_id as ProjectId, + ) + .execute(&mut **transaction) + .await?; + + let VersionBuilder { + dependencies, + loaders, + files, + version_id, + .. + } = self; + + for file in files { + file.insert(version_id, transaction).await?; + } + + DependencyBuilder::insert_many( + dependencies, + self.version_id, + transaction, + ) + .await?; + + let loader_versions = loaders + .iter() + .map(|l| LoaderVersion::new(*l, version_id)) + .collect_vec(); + LoaderVersion::insert_many(loader_versions, transaction).await?; + + VersionField::insert_many(self.version_fields, transaction).await?; + + Ok(self.version_id) + } +} + +#[derive(derive_new::new, Serialize, Deserialize)] +pub struct LoaderVersion { + pub loader_id: LoaderId, + pub version_id: VersionId, +} + +impl LoaderVersion { + pub async fn insert_many( + items: Vec, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), DatabaseError> { + let (loader_ids, version_ids): (Vec<_>, Vec<_>) = items + .iter() + .map(|l| (l.loader_id.0, l.version_id.0)) + .unzip(); + sqlx::query!( + " + INSERT INTO loaders_versions (loader_id, version_id) + SELECT * FROM UNNEST($1::integer[], $2::bigint[]) + ", + &loader_ids[..], + &version_ids[..], + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } +} + +#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct Version { + pub id: VersionId, + pub project_id: ProjectId, + pub author_id: UserId, + pub name: String, + pub version_number: String, + pub changelog: String, + pub date_published: DateTime, + pub downloads: i32, + pub version_type: String, + pub featured: bool, + pub status: VersionStatus, + pub requested_status: Option, + pub ordering: Option, +} + +impl Version { + pub async fn insert( + &self, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), sqlx::error::Error> { + sqlx::query!( + " + INSERT INTO versions ( + id, mod_id, author_id, name, version_number, + changelog, date_published, downloads, + version_type, featured, status, ordering + ) + VALUES ( + $1, $2, $3, $4, $5, + $6, $7, $8, + $9, $10, $11, $12 + ) + ", + self.id as VersionId, + self.project_id as ProjectId, + self.author_id as UserId, + &self.name, + &self.version_number, + self.changelog, + self.date_published, + self.downloads, + &self.version_type, + self.featured, + self.status.as_str(), + self.ordering + ) + .execute(&mut **transaction) + .await?; + + Ok(()) + } + + pub async fn remove_full( + id: VersionId, + redis: &RedisPool, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result, DatabaseError> { + let result = Self::get(id, &mut **transaction, redis).await?; + + let result = if let Some(result) = result { + result + } else { + return Ok(None); + }; + + Version::clear_cache(&result, redis).await?; + + sqlx::query!( + " + UPDATE reports + SET version_id = NULL + WHERE version_id = $1 + ", + id as VersionId, + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + DELETE FROM version_fields vf + WHERE vf.version_id = $1 + ", + id as VersionId, + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + DELETE FROM loaders_versions + WHERE loaders_versions.version_id = $1 + ", + id as VersionId, + ) + .execute(&mut **transaction) + .await?; + + 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(&mut **transaction) + .await?; + + sqlx::query!( + " + DELETE FROM files + WHERE files.version_id = $1 + ", + id as VersionId, + ) + .execute(&mut **transaction) + .await?; + + // Sync dependencies + + let project_id = sqlx::query!( + " + SELECT mod_id FROM versions WHERE id = $1 + ", + id as VersionId, + ) + .fetch_one(&mut **transaction) + .await?; + + sqlx::query!( + " + UPDATE dependencies + SET dependency_id = NULL, mod_dependency_id = $2 + WHERE dependency_id = $1 + ", + id as VersionId, + project_id.mod_id, + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + DELETE FROM dependencies WHERE mod_dependency_id = NULL AND dependency_id = NULL AND dependency_file_name = NULL + ", + ) + .execute(&mut **transaction) + .await?; + + sqlx::query!( + " + DELETE FROM dependencies WHERE dependent_id = $1 + ", + id as VersionId, + ) + .execute(&mut **transaction) + .await?; + + // delete version + + sqlx::query!( + " + DELETE FROM versions WHERE id = $1 + ", + id as VersionId, + ) + .execute(&mut **transaction) + .await?; + + crate::database::models::Project::clear_cache( + ProjectId(project_id.mod_id), + None, + None, + redis, + ) + .await?; + + Ok(Some(())) + } + + pub async fn get<'a, 'b, E>( + id: VersionId, + executor: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Acquire<'a, Database = sqlx::Postgres>, + { + Self::get_many(&[id], executor, redis) + .await + .map(|x| x.into_iter().next()) + } + + pub async fn get_many<'a, E>( + version_ids: &[VersionId], + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Acquire<'a, Database = sqlx::Postgres>, + { + let mut val = redis.get_cached_keys( + VERSIONS_NAMESPACE, + &version_ids.iter().map(|x| x.0).collect::>(), + |version_ids| async move { + let mut exec = exec.acquire().await?; + + let loader_field_enum_value_ids = DashSet::new(); + let version_fields: DashMap> = sqlx::query!( + " + SELECT version_id, field_id, int_value, enum_value, string_value + FROM version_fields + WHERE version_id = ANY($1) + ", + &version_ids + ) + .fetch(&mut *exec) + .try_fold( + DashMap::new(), + |acc: DashMap>, m| { + let qvf = QueryVersionField { + version_id: VersionId(m.version_id), + field_id: LoaderFieldId(m.field_id), + int_value: m.int_value, + enum_value: m.enum_value.map(LoaderFieldEnumValueId), + string_value: m.string_value, + }; + + if let Some(enum_value) = m.enum_value { + loader_field_enum_value_ids.insert(LoaderFieldEnumValueId(enum_value)); + } + + acc.entry(VersionId(m.version_id)).or_default().push(qvf); + async move { Ok(acc) } + }, + ) + .await?; + + #[derive(Default)] + struct VersionLoaderData { + loaders: Vec, + project_types: Vec, + games: Vec, + loader_loader_field_ids: Vec, + } + + let loader_field_ids = DashSet::new(); + let loaders_ptypes_games: DashMap = sqlx::query!( + " + SELECT DISTINCT version_id, + ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders, + ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types, + ARRAY_AGG(DISTINCT g.slug) filter (where g.slug is not null) games, + ARRAY_AGG(DISTINCT lfl.loader_field_id) filter (where lfl.loader_field_id is not null) loader_fields + FROM versions v + INNER JOIN loaders_versions lv ON v.id = lv.version_id + INNER JOIN loaders l ON lv.loader_id = l.id + INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id + INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id + INNER JOIN loaders_project_types_games lptg ON lptg.loader_id = l.id AND lptg.project_type_id = pt.id + INNER JOIN games g ON lptg.game_id = g.id + LEFT JOIN loader_fields_loaders lfl ON lfl.loader_id = l.id + WHERE v.id = ANY($1) + GROUP BY version_id + ", + &version_ids + ).fetch(&mut *exec) + .map_ok(|m| { + let version_id = VersionId(m.version_id); + + // Add loader fields to the set we need to fetch + let loader_loader_field_ids = m.loader_fields.unwrap_or_default().into_iter().map(LoaderFieldId).collect::>(); + for loader_field_id in loader_loader_field_ids.iter() { + loader_field_ids.insert(*loader_field_id); + } + + // Add loader + loader associated data to the map + let version_loader_data = VersionLoaderData { + loaders: m.loaders.unwrap_or_default(), + project_types: m.project_types.unwrap_or_default(), + games: m.games.unwrap_or_default(), + loader_loader_field_ids, + }; + (version_id,version_loader_data) + + } + ).try_collect().await?; + + // Fetch all loader fields from any version + let loader_fields: Vec = sqlx::query!( + " + SELECT DISTINCT id, field, field_type, enum_type, min_val, max_val, optional + FROM loader_fields lf + WHERE id = ANY($1) + ", + &loader_field_ids.iter().map(|x| x.0).collect::>() + ) + .fetch(&mut *exec) + .map_ok(|m| QueryLoaderField { + id: LoaderFieldId(m.id), + field: m.field, + field_type: m.field_type, + enum_type: m.enum_type.map(LoaderFieldEnumId), + min_val: m.min_val, + max_val: m.max_val, + optional: m.optional, + }) + .try_collect() + .await?; + + let loader_field_enum_values: Vec = sqlx::query!( + " + SELECT DISTINCT id, enum_id, value, ordering, created, metadata + FROM loader_field_enum_values lfev + WHERE id = ANY($1) + ORDER BY enum_id, ordering, created ASC + ", + &loader_field_enum_value_ids + .iter() + .map(|x| x.0) + .collect::>() + ) + .fetch(&mut *exec) + .map_ok(|m| QueryLoaderFieldEnumValue { + id: LoaderFieldEnumValueId(m.id), + enum_id: LoaderFieldEnumId(m.enum_id), + value: m.value, + ordering: m.ordering, + created: m.created, + metadata: m.metadata, + }) + .try_collect() + .await?; + + #[derive(Deserialize)] + struct Hash { + pub file_id: FileId, + pub algorithm: String, + pub hash: String, + } + + #[derive(Deserialize)] + struct File { + pub id: FileId, + pub url: String, + pub filename: String, + pub primary: bool, + pub size: u32, + pub file_type: Option, + } + + let file_ids = DashSet::new(); + let reverse_file_map = DashMap::new(); + let files : DashMap> = sqlx::query!( + " + SELECT DISTINCT version_id, f.id, f.url, f.filename, f.is_primary, f.size, f.file_type + FROM files f + WHERE f.version_id = ANY($1) + ", + &version_ids + ).fetch(&mut *exec) + .try_fold(DashMap::new(), |acc : DashMap>, m| { + let file = File { + id: FileId(m.id), + url: m.url, + filename: m.filename, + primary: m.is_primary, + size: m.size as u32, + file_type: m.file_type.map(|x| FileType::from_string(&x)), + }; + + file_ids.insert(FileId(m.id)); + reverse_file_map.insert(FileId(m.id), VersionId(m.version_id)); + + acc.entry(VersionId(m.version_id)) + .or_default() + .push(file); + async move { Ok(acc) } + } + ).await?; + + let hashes: DashMap> = sqlx::query!( + " + SELECT DISTINCT file_id, algorithm, encode(hash, 'escape') hash + FROM hashes + WHERE file_id = ANY($1) + ", + &file_ids.iter().map(|x| x.0).collect::>() + ) + .fetch(&mut *exec) + .try_fold(DashMap::new(), |acc: DashMap>, m| { + if let Some(found_hash) = m.hash { + let hash = Hash { + file_id: FileId(m.file_id), + algorithm: m.algorithm, + hash: found_hash, + }; + + if let Some(version_id) = reverse_file_map.get(&FileId(m.file_id)) { + acc.entry(*version_id).or_default().push(hash); + } + } + async move { Ok(acc) } + }) + .await?; + + let dependencies : DashMap> = sqlx::query!( + " + SELECT DISTINCT dependent_id as version_id, d.mod_dependency_id as dependency_project_id, d.dependency_id as dependency_version_id, d.dependency_file_name as file_name, d.dependency_type as dependency_type + FROM dependencies d + WHERE dependent_id = ANY($1) + ", + &version_ids + ).fetch(&mut *exec) + .try_fold(DashMap::new(), |acc : DashMap<_,Vec>, m| { + let dependency = QueryDependency { + project_id: m.dependency_project_id.map(ProjectId), + version_id: m.dependency_version_id.map(VersionId), + file_name: m.file_name, + dependency_type: m.dependency_type, + }; + + acc.entry(VersionId(m.version_id)) + .or_default() + .push(dependency); + async move { Ok(acc) } + } + ).await?; + + let res = sqlx::query!( + " + SELECT v.id id, v.mod_id mod_id, v.author_id author_id, v.name version_name, v.version_number version_number, + v.changelog changelog, v.date_published date_published, v.downloads downloads, + v.version_type version_type, v.featured featured, v.status status, v.requested_status requested_status, v.ordering ordering + FROM versions v + WHERE v.id = ANY($1); + ", + &version_ids + ) + .fetch(&mut *exec) + .try_fold(DashMap::new(), |acc, v| { + let version_id = VersionId(v.id); + let VersionLoaderData { + loaders, + project_types, + games, + loader_loader_field_ids, + } = loaders_ptypes_games.remove(&version_id).map(|x|x.1).unwrap_or_default(); + let files = files.remove(&version_id).map(|x|x.1).unwrap_or_default(); + let hashes = hashes.remove(&version_id).map(|x|x.1).unwrap_or_default(); + let version_fields = version_fields.remove(&version_id).map(|x|x.1).unwrap_or_default(); + let dependencies = dependencies.remove(&version_id).map(|x|x.1).unwrap_or_default(); + + let loader_fields = loader_fields.iter() + .filter(|x| loader_loader_field_ids.contains(&x.id)) + .collect::>(); + + let query_version = QueryVersion { + inner: Version { + id: VersionId(v.id), + project_id: ProjectId(v.mod_id), + author_id: UserId(v.author_id), + name: v.version_name, + version_number: v.version_number, + changelog: v.changelog, + date_published: v.date_published, + downloads: v.downloads, + version_type: v.version_type, + featured: v.featured, + status: VersionStatus::from_string(&v.status), + requested_status: v.requested_status + .map(|x| VersionStatus::from_string(&x)), + ordering: v.ordering, + }, + files: { + let mut files = files.into_iter().map(|x| { + let mut file_hashes = HashMap::new(); + + for hash in hashes.iter() { + if hash.file_id == x.id { + file_hashes.insert( + hash.algorithm.clone(), + hash.hash.clone(), + ); + } + } + + QueryFile { + id: x.id, + url: x.url.clone(), + filename: x.filename.clone(), + hashes: file_hashes, + primary: x.primary, + size: x.size, + file_type: x.file_type, + } + }).collect::>(); + + files.sort_by(|a, b| { + if a.primary { + Ordering::Less + } else if b.primary { + Ordering::Greater + } else { + a.filename.cmp(&b.filename) + } + }); + + files + }, + version_fields: VersionField::from_query_json(version_fields, &loader_fields, &loader_field_enum_values, false), + loaders, + project_types, + games, + dependencies, + }; + + acc.insert(v.id, query_version); + async move { Ok(acc) } + }) + .await?; + + Ok(res) + }, + ).await?; + + val.sort(); + + Ok(val) + } + + pub async fn get_file_from_hash<'a, 'b, E>( + algo: String, + hash: String, + version_id: Option, + executor: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + Self::get_files_from_hash(algo, &[hash], executor, redis) + .await + .map(|x| { + x.into_iter() + .find_or_first(|x| Some(x.version_id) == version_id) + }) + } + + pub async fn get_files_from_hash<'a, 'b, E>( + algorithm: String, + hashes: &[String], + executor: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres> + Copy, + { + let val = redis.get_cached_keys( + VERSION_FILES_NAMESPACE, + &hashes.iter().map(|x| format!("{algorithm}_{x}")).collect::>(), + |file_ids| async move { + let files = sqlx::query!( + " + SELECT f.id, f.version_id, v.mod_id, f.url, f.filename, f.is_primary, f.size, f.file_type, + JSONB_AGG(DISTINCT jsonb_build_object('algorithm', h.algorithm, 'hash', encode(h.hash, 'escape'))) filter (where h.hash is not null) hashes + FROM files f + INNER JOIN versions v on v.id = f.version_id + INNER JOIN hashes h on h.file_id = f.id + WHERE h.algorithm = $1 AND h.hash = ANY($2) + GROUP BY f.id, v.mod_id, v.date_published + ORDER BY v.date_published + ", + algorithm, + &file_ids.into_iter().flat_map(|x| x.split('_').last().map(|x| x.as_bytes().to_vec())).collect::>(), + ) + .fetch(executor) + .try_fold(DashMap::new(), |acc, f| { + #[derive(Deserialize)] + struct Hash { + pub algorithm: String, + pub hash: String, + } + + let hashes = serde_json::from_value::>( + f.hashes.unwrap_or_default(), + ) + .ok() + .unwrap_or_default().into_iter().map(|x| (x.algorithm, x.hash)) + .collect::>(); + + if let Some(hash) = hashes.get(&algorithm) { + let key = format!("{algorithm}_{hash}"); + + let file = SingleFile { + id: FileId(f.id), + version_id: VersionId(f.version_id), + project_id: ProjectId(f.mod_id), + url: f.url, + filename: f.filename, + hashes, + primary: f.is_primary, + size: f.size as u32, + file_type: f.file_type.map(|x| FileType::from_string(&x)), + }; + + acc.insert(key, file); + } + + async move { Ok(acc) } + }) + .await?; + + Ok(files) + } + ).await?; + + Ok(val) + } + + pub async fn clear_cache( + version: &QueryVersion, + redis: &RedisPool, + ) -> Result<(), DatabaseError> { + let mut redis = redis.connect().await?; + + redis + .delete_many( + iter::once(( + VERSIONS_NAMESPACE, + Some(version.inner.id.0.to_string()), + )) + .chain(version.files.iter().flat_map( + |file| { + file.hashes.iter().map(|(algo, hash)| { + ( + VERSION_FILES_NAMESPACE, + Some(format!("{}_{}", algo, hash)), + ) + }) + }, + )), + ) + .await?; + Ok(()) + } +} + +#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct QueryVersion { + pub inner: Version, + + pub files: Vec, + pub version_fields: Vec, + pub loaders: Vec, + pub project_types: Vec, + pub games: Vec, + pub dependencies: Vec, +} + +#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct QueryDependency { + pub project_id: Option, + pub version_id: Option, + pub file_name: Option, + pub dependency_type: String, +} + +#[derive(Clone, Deserialize, Serialize, PartialEq, Eq)] +pub struct QueryFile { + pub id: FileId, + pub url: String, + pub filename: String, + pub hashes: HashMap, + pub primary: bool, + pub size: u32, + pub file_type: Option, +} + +#[derive(Clone, Deserialize, Serialize)] +pub struct SingleFile { + pub id: FileId, + pub version_id: VersionId, + pub project_id: ProjectId, + pub url: String, + pub filename: String, + pub hashes: HashMap, + pub primary: bool, + pub size: u32, + pub file_type: Option, +} + +impl std::cmp::Ord for QueryVersion { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.inner.cmp(&other.inner) + } +} + +impl std::cmp::PartialOrd for QueryVersion { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl std::cmp::Ord for Version { + fn cmp(&self, other: &Self) -> Ordering { + let ordering_order = match (self.ordering, other.ordering) { + (None, None) => Ordering::Equal, + (None, Some(_)) => Ordering::Greater, + (Some(_), None) => Ordering::Less, + (Some(a), Some(b)) => a.cmp(&b), + }; + + match ordering_order { + Ordering::Equal => self.date_published.cmp(&other.date_published), + ordering => ordering, + } + } +} + +impl std::cmp::PartialOrd for Version { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +#[cfg(test)] +mod tests { + use chrono::Months; + + use super::*; + + #[test] + fn test_version_sorting() { + let versions = vec![ + get_version(4, None, months_ago(6)), + get_version(3, None, months_ago(7)), + get_version(2, Some(1), months_ago(6)), + get_version(1, Some(0), months_ago(4)), + get_version(0, Some(0), months_ago(5)), + ]; + + let sorted = versions.iter().cloned().sorted().collect_vec(); + + let expected_sorted_ids = vec![0, 1, 2, 3, 4]; + let actual_sorted_ids = sorted.iter().map(|v| v.id.0).collect_vec(); + assert_eq!(expected_sorted_ids, actual_sorted_ids); + } + + fn months_ago(months: u32) -> DateTime { + Utc::now().checked_sub_months(Months::new(months)).unwrap() + } + + fn get_version( + id: i64, + ordering: Option, + date_published: DateTime, + ) -> Version { + Version { + id: VersionId(id), + ordering, + date_published, + project_id: ProjectId(0), + author_id: UserId(0), + name: Default::default(), + version_number: Default::default(), + changelog: Default::default(), + downloads: Default::default(), + version_type: Default::default(), + featured: Default::default(), + status: VersionStatus::Listed, + requested_status: Default::default(), + } + } +} diff --git a/apps/labrinth/src/database/postgres_database.rs b/apps/labrinth/src/database/postgres_database.rs new file mode 100644 index 000000000..65601bde9 --- /dev/null +++ b/apps/labrinth/src/database/postgres_database.rs @@ -0,0 +1,47 @@ +use log::info; +use sqlx::migrate::MigrateDatabase; +use sqlx::postgres::{PgPool, PgPoolOptions}; +use sqlx::{Connection, PgConnection, Postgres}; +use std::time::Duration; + +pub async fn connect() -> Result { + info!("Initializing database connection"); + let database_url = + dotenvy::var("DATABASE_URL").expect("`DATABASE_URL` not in .env"); + let pool = PgPoolOptions::new() + .min_connections( + dotenvy::var("DATABASE_MIN_CONNECTIONS") + .ok() + .and_then(|x| x.parse().ok()) + .unwrap_or(0), + ) + .max_connections( + dotenvy::var("DATABASE_MAX_CONNECTIONS") + .ok() + .and_then(|x| x.parse().ok()) + .unwrap_or(16), + ) + .max_lifetime(Some(Duration::from_secs(60 * 60))) + .connect(&database_url) + .await?; + + Ok(pool) +} +pub async fn check_for_migrations() -> Result<(), sqlx::Error> { + let uri = dotenvy::var("DATABASE_URL").expect("`DATABASE_URL` not in .env"); + let uri = uri.as_str(); + if !Postgres::database_exists(uri).await? { + info!("Creating database..."); + Postgres::create_database(uri).await?; + } + + info!("Applying migrations..."); + + let mut conn: PgConnection = PgConnection::connect(uri).await?; + sqlx::migrate!() + .run(&mut conn) + .await + .expect("Error while running database migrations!"); + + Ok(()) +} diff --git a/apps/labrinth/src/database/redis.rs b/apps/labrinth/src/database/redis.rs new file mode 100644 index 000000000..24ea51c5b --- /dev/null +++ b/apps/labrinth/src/database/redis.rs @@ -0,0 +1,631 @@ +use super::models::DatabaseError; +use crate::models::ids::base62_impl::{parse_base62, to_base62}; +use chrono::{TimeZone, Utc}; +use dashmap::DashMap; +use deadpool_redis::{Config, Runtime}; +use redis::{cmd, Cmd, ExistenceCheck, SetExpiry, SetOptions}; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::fmt::{Debug, Display}; +use std::future::Future; +use std::hash::Hash; +use std::pin::Pin; +use std::time::Duration; + +const DEFAULT_EXPIRY: i64 = 60 * 60 * 12; // 12 hours +const ACTUAL_EXPIRY: i64 = 60 * 30; // 30 minutes + +#[derive(Clone)] +pub struct RedisPool { + pub pool: deadpool_redis::Pool, + meta_namespace: String, +} + +pub struct RedisConnection { + pub connection: deadpool_redis::Connection, + meta_namespace: String, +} + +impl RedisPool { + // initiate a new redis pool + // testing pool uses a hashmap to mimic redis behaviour for very small data sizes (ie: tests) + // PANICS: production pool will panic if redis url is not set + pub fn new(meta_namespace: Option) -> Self { + let redis_pool = Config::from_url( + dotenvy::var("REDIS_URL").expect("Redis URL not set"), + ) + .builder() + .expect("Error building Redis pool") + .max_size( + dotenvy::var("DATABASE_MAX_CONNECTIONS") + .ok() + .and_then(|x| x.parse().ok()) + .unwrap_or(10000), + ) + .runtime(Runtime::Tokio1) + .build() + .expect("Redis connection failed"); + + RedisPool { + pool: redis_pool, + meta_namespace: meta_namespace.unwrap_or("".to_string()), + } + } + + pub async fn connect(&self) -> Result { + Ok(RedisConnection { + connection: self.pool.get().await?, + meta_namespace: self.meta_namespace.clone(), + }) + } + + pub async fn get_cached_keys( + &self, + namespace: &str, + keys: &[K], + closure: F, + ) -> Result, DatabaseError> + where + F: FnOnce(Vec) -> Fut, + Fut: Future, DatabaseError>>, + T: Serialize + DeserializeOwned, + K: Display + + Hash + + Eq + + PartialEq + + Clone + + DeserializeOwned + + Serialize + + Debug, + { + Ok(self + .get_cached_keys_raw(namespace, keys, closure) + .await? + .into_iter() + .map(|x| x.1) + .collect()) + } + + pub async fn get_cached_keys_raw( + &self, + namespace: &str, + keys: &[K], + closure: F, + ) -> Result, DatabaseError> + where + F: FnOnce(Vec) -> Fut, + Fut: Future, DatabaseError>>, + T: Serialize + DeserializeOwned, + K: Display + + Hash + + Eq + + PartialEq + + Clone + + DeserializeOwned + + Serialize + + Debug, + { + self.get_cached_keys_raw_with_slug( + namespace, + None, + false, + keys, + |ids| async move { + Ok(closure(ids) + .await? + .into_iter() + .map(|(key, val)| (key, (None::, val))) + .collect()) + }, + ) + .await + } + + pub async fn get_cached_keys_with_slug( + &self, + namespace: &str, + slug_namespace: &str, + case_sensitive: bool, + keys: &[I], + closure: F, + ) -> Result, DatabaseError> + where + F: FnOnce(Vec) -> Fut, + Fut: Future, T)>, DatabaseError>>, + T: Serialize + DeserializeOwned, + I: Display + Hash + Eq + PartialEq + Clone + Debug, + K: Display + + Hash + + Eq + + PartialEq + + Clone + + DeserializeOwned + + Serialize, + S: Display + Clone + DeserializeOwned + Serialize + Debug, + { + Ok(self + .get_cached_keys_raw_with_slug( + namespace, + Some(slug_namespace), + case_sensitive, + keys, + closure, + ) + .await? + .into_iter() + .map(|x| x.1) + .collect()) + } + + pub async fn get_cached_keys_raw_with_slug( + &self, + namespace: &str, + slug_namespace: Option<&str>, + case_sensitive: bool, + keys: &[I], + closure: F, + ) -> Result, DatabaseError> + where + F: FnOnce(Vec) -> Fut, + Fut: Future, T)>, DatabaseError>>, + T: Serialize + DeserializeOwned, + I: Display + Hash + Eq + PartialEq + Clone + Debug, + K: Display + + Hash + + Eq + + PartialEq + + Clone + + DeserializeOwned + + Serialize, + S: Display + Clone + DeserializeOwned + Serialize + Debug, + { + let connection = self.connect().await?.connection; + + let ids = keys + .iter() + .map(|x| (x.to_string(), x.clone())) + .collect::>(); + + if ids.is_empty() { + return Ok(HashMap::new()); + } + + let get_cached_values = + |ids: DashMap, + mut connection: deadpool_redis::Connection| async move { + let slug_ids = if let Some(slug_namespace) = slug_namespace { + cmd("MGET") + .arg( + ids.iter() + .map(|x| { + format!( + "{}_{slug_namespace}:{}", + self.meta_namespace, + if case_sensitive { + x.value().to_string() + } else { + x.value().to_string().to_lowercase() + } + ) + }) + .collect::>(), + ) + .query_async::>>(&mut connection) + .await? + .into_iter() + .flatten() + .collect::>() + } else { + Vec::new() + }; + + let cached_values = cmd("MGET") + .arg( + ids.iter() + .map(|x| x.value().to_string()) + .chain(ids.iter().filter_map(|x| { + parse_base62(&x.value().to_string()) + .ok() + .map(|x| x.to_string()) + })) + .chain(slug_ids) + .map(|x| { + format!( + "{}_{namespace}:{x}", + self.meta_namespace + ) + }) + .collect::>(), + ) + .query_async::>>(&mut connection) + .await? + .into_iter() + .filter_map(|x| { + x.and_then(|val| { + serde_json::from_str::>(&val) + .ok() + }) + .map(|val| (val.key.clone(), val)) + }) + .collect::>(); + + Ok::<_, DatabaseError>((cached_values, connection, ids)) + }; + + let current_time = Utc::now(); + let mut expired_values = HashMap::new(); + + let (cached_values_raw, mut connection, ids) = + get_cached_values(ids, connection).await?; + let mut cached_values = cached_values_raw + .into_iter() + .filter_map(|(key, val)| { + if Utc.timestamp_opt(val.iat + ACTUAL_EXPIRY, 0).unwrap() + < current_time + { + expired_values.insert(val.key.to_string(), val); + + None + } else { + let key_str = val.key.to_string(); + ids.remove(&key_str); + + if let Ok(value) = key_str.parse::() { + let base62 = to_base62(value); + ids.remove(&base62); + } + + if let Some(ref alias) = val.alias { + ids.remove(&alias.to_string()); + } + + Some((key, val)) + } + }) + .collect::>(); + + let subscribe_ids = DashMap::new(); + + if !ids.is_empty() { + let mut pipe = redis::pipe(); + + let fetch_ids = + ids.iter().map(|x| x.key().clone()).collect::>(); + + fetch_ids.iter().for_each(|key| { + pipe.atomic().set_options( + format!("{}_{namespace}:{}/lock", self.meta_namespace, key), + 100, + SetOptions::default() + .get(true) + .conditional_set(ExistenceCheck::NX) + .with_expiration(SetExpiry::EX(60)), + ); + }); + let results = pipe + .query_async::>>(&mut connection) + .await?; + + for (idx, key) in fetch_ids.into_iter().enumerate() { + if let Some(locked) = results.get(idx) { + if locked.is_none() { + continue; + } + } + + if let Some((key, raw_key)) = ids.remove(&key) { + if let Some(val) = expired_values.remove(&key) { + if let Some(ref alias) = val.alias { + ids.remove(&alias.to_string()); + } + + if let Ok(value) = val.key.to_string().parse::() { + let base62 = to_base62(value); + ids.remove(&base62); + } + + cached_values.insert(val.key.clone(), val); + } else { + subscribe_ids.insert(key, raw_key); + } + } + } + } + + #[allow(clippy::type_complexity)] + let mut fetch_tasks: Vec< + Pin< + Box< + dyn Future< + Output = Result< + HashMap>, + DatabaseError, + >, + >, + >, + >, + > = Vec::new(); + + if !ids.is_empty() { + fetch_tasks.push(Box::pin(async { + let fetch_ids = + ids.iter().map(|x| x.value().clone()).collect::>(); + + let vals = closure(fetch_ids).await?; + let mut return_values = HashMap::new(); + + let mut pipe = redis::pipe(); + if !vals.is_empty() { + for (key, (slug, value)) in vals { + let value = RedisValue { + key: key.clone(), + iat: Utc::now().timestamp(), + val: value, + alias: slug.clone(), + }; + + pipe.atomic().set_ex( + format!( + "{}_{namespace}:{key}", + self.meta_namespace + ), + serde_json::to_string(&value)?, + DEFAULT_EXPIRY as u64, + ); + + if let Some(slug) = slug { + ids.remove(&slug.to_string()); + + if let Some(slug_namespace) = slug_namespace { + let actual_slug = if case_sensitive { + slug.to_string() + } else { + slug.to_string().to_lowercase() + }; + + pipe.atomic().set_ex( + format!( + "{}_{slug_namespace}:{}", + self.meta_namespace, actual_slug + ), + key.to_string(), + DEFAULT_EXPIRY as u64, + ); + + pipe.atomic().del(format!( + "{}_{namespace}:{}/lock", + self.meta_namespace, actual_slug + )); + } + } + + let key_str = key.to_string(); + ids.remove(&key_str); + + if let Ok(value) = key_str.parse::() { + let base62 = to_base62(value); + ids.remove(&base62); + + pipe.atomic().del(format!( + "{}_{namespace}:{base62}/lock", + self.meta_namespace + )); + } + + pipe.atomic().del(format!( + "{}_{namespace}:{key}/lock", + self.meta_namespace + )); + + return_values.insert(key, value); + } + } + + for (key, _) in ids { + pipe.atomic().del(format!( + "{}_{namespace}:{key}/lock", + self.meta_namespace + )); + } + + pipe.query_async::<()>(&mut connection).await?; + + Ok(return_values) + })); + } + + if !subscribe_ids.is_empty() { + fetch_tasks.push(Box::pin(async { + let mut connection = self.pool.get().await?; + + let mut interval = + tokio::time::interval(Duration::from_millis(100)); + let start = Utc::now(); + loop { + let results = cmd("MGET") + .arg( + subscribe_ids + .iter() + .map(|x| { + format!( + "{}_{namespace}:{}/lock", + self.meta_namespace, + x.key() + ) + }) + .collect::>(), + ) + .query_async::>>(&mut connection) + .await?; + + if results.into_iter().all(|x| x.is_none()) { + break; + } + + if (Utc::now() - start) > chrono::Duration::seconds(5) { + return Err(DatabaseError::CacheTimeout); + } + + interval.tick().await; + } + + let (return_values, _, _) = + get_cached_values(subscribe_ids, connection).await?; + + Ok(return_values) + })); + } + + if !fetch_tasks.is_empty() { + for map in futures::future::try_join_all(fetch_tasks).await? { + for (key, value) in map { + cached_values.insert(key, value); + } + } + } + + Ok(cached_values.into_iter().map(|x| (x.0, x.1.val)).collect()) + } +} + +impl RedisConnection { + pub async fn set( + &mut self, + namespace: &str, + id: &str, + data: &str, + expiry: Option, + ) -> Result<(), DatabaseError> { + let mut cmd = cmd("SET"); + redis_args( + &mut cmd, + vec![ + format!("{}_{}:{}", self.meta_namespace, namespace, id), + data.to_string(), + "EX".to_string(), + expiry.unwrap_or(DEFAULT_EXPIRY).to_string(), + ] + .as_slice(), + ); + redis_execute::<()>(&mut cmd, &mut self.connection).await?; + Ok(()) + } + + pub async fn set_serialized_to_json( + &mut self, + namespace: &str, + id: Id, + data: D, + expiry: Option, + ) -> Result<(), DatabaseError> + where + Id: Display, + D: serde::Serialize, + { + self.set( + namespace, + &id.to_string(), + &serde_json::to_string(&data)?, + expiry, + ) + .await + } + + pub async fn get( + &mut self, + namespace: &str, + id: &str, + ) -> Result, DatabaseError> { + let mut cmd = cmd("GET"); + redis_args( + &mut cmd, + vec![format!("{}_{}:{}", self.meta_namespace, namespace, id)] + .as_slice(), + ); + let res = redis_execute(&mut cmd, &mut self.connection).await?; + Ok(res) + } + + pub async fn get_deserialized_from_json( + &mut self, + namespace: &str, + id: &str, + ) -> Result, DatabaseError> + where + R: for<'a> serde::Deserialize<'a>, + { + Ok(self + .get(namespace, id) + .await? + .and_then(|x| serde_json::from_str(&x).ok())) + } + + pub async fn delete( + &mut self, + namespace: &str, + id: T1, + ) -> Result<(), DatabaseError> + where + T1: Display, + { + let mut cmd = cmd("DEL"); + redis_args( + &mut cmd, + vec![format!("{}_{}:{}", self.meta_namespace, namespace, id)] + .as_slice(), + ); + redis_execute::<()>(&mut cmd, &mut self.connection).await?; + Ok(()) + } + + pub async fn delete_many( + &mut self, + iter: impl IntoIterator)>, + ) -> Result<(), DatabaseError> { + let mut cmd = cmd("DEL"); + let mut any = false; + for (namespace, id) in iter { + if let Some(id) = id { + redis_args( + &mut cmd, + [format!("{}_{}:{}", self.meta_namespace, namespace, id)] + .as_slice(), + ); + any = true; + } + } + + if any { + redis_execute::<()>(&mut cmd, &mut self.connection).await?; + } + + Ok(()) + } +} + +#[derive(Serialize, Deserialize)] +pub struct RedisValue { + key: K, + #[serde(skip_serializing_if = "Option::is_none")] + alias: Option, + iat: i64, + val: T, +} + +pub fn redis_args(cmd: &mut Cmd, args: &[String]) { + for arg in args { + cmd.arg(arg); + } +} + +pub async fn redis_execute( + cmd: &mut Cmd, + redis: &mut deadpool_redis::Connection, +) -> Result +where + T: redis::FromRedisValue, +{ + let res = cmd.query_async::(redis).await?; + Ok(res) +} diff --git a/apps/labrinth/src/file_hosting/backblaze.rs b/apps/labrinth/src/file_hosting/backblaze.rs new file mode 100644 index 000000000..28d302245 --- /dev/null +++ b/apps/labrinth/src/file_hosting/backblaze.rs @@ -0,0 +1,108 @@ +use super::{DeleteFileData, FileHost, FileHostingError, UploadFileData}; +use async_trait::async_trait; +use bytes::Bytes; +use reqwest::Response; +use serde::Deserialize; +use sha2::Digest; + +mod authorization; +mod delete; +mod upload; + +pub struct BackblazeHost { + upload_url_data: authorization::UploadUrlData, + authorization_data: authorization::AuthorizationData, +} + +impl BackblazeHost { + pub async fn new(key_id: &str, key: &str, bucket_id: &str) -> Self { + let authorization_data = + authorization::authorize_account(key_id, key).await.unwrap(); + let upload_url_data = + authorization::get_upload_url(&authorization_data, bucket_id) + .await + .unwrap(); + + BackblazeHost { + upload_url_data, + authorization_data, + } + } +} + +#[async_trait] +impl FileHost for BackblazeHost { + async fn upload_file( + &self, + content_type: &str, + file_name: &str, + file_bytes: Bytes, + ) -> Result { + let content_sha512 = format!("{:x}", sha2::Sha512::digest(&file_bytes)); + + let upload_data = upload::upload_file( + &self.upload_url_data, + content_type, + file_name, + file_bytes, + ) + .await?; + Ok(UploadFileData { + file_id: upload_data.file_id, + file_name: upload_data.file_name, + content_length: upload_data.content_length, + content_sha512, + content_sha1: upload_data.content_sha1, + content_md5: upload_data.content_md5, + content_type: upload_data.content_type, + upload_timestamp: upload_data.upload_timestamp, + }) + } + + /* + async fn upload_file_streaming( + &self, + content_type: &str, + file_name: &str, + stream: reqwest::Body + ) -> Result { + use futures::stream::StreamExt; + + let mut data = Vec::new(); + while let Some(chunk) = stream.next().await { + data.extend_from_slice(&chunk.map_err(|e| FileHostingError::Other(e))?); + } + self.upload_file(content_type, file_name, data).await + } + */ + + async fn delete_file_version( + &self, + file_id: &str, + file_name: &str, + ) -> Result { + let delete_data = delete::delete_file_version( + &self.authorization_data, + file_id, + file_name, + ) + .await?; + Ok(DeleteFileData { + file_id: delete_data.file_id, + file_name: delete_data.file_name, + }) + } +} + +pub async fn process_response( + response: Response, +) -> Result +where + T: for<'de> Deserialize<'de>, +{ + if response.status().is_success() { + Ok(response.json().await?) + } else { + Err(FileHostingError::BackblazeError(response.json().await?)) + } +} diff --git a/apps/labrinth/src/file_hosting/backblaze/authorization.rs b/apps/labrinth/src/file_hosting/backblaze/authorization.rs new file mode 100644 index 000000000..9ab9e5982 --- /dev/null +++ b/apps/labrinth/src/file_hosting/backblaze/authorization.rs @@ -0,0 +1,81 @@ +use crate::file_hosting::FileHostingError; +use base64::Engine; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct AuthorizationPermissions { + bucket_id: Option, + bucket_name: Option, + capabilities: Vec, + name_prefix: Option, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct AuthorizationData { + pub absolute_minimum_part_size: i32, + pub account_id: String, + pub allowed: AuthorizationPermissions, + pub api_url: String, + pub authorization_token: String, + pub download_url: String, + pub recommended_part_size: i32, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct UploadUrlData { + pub bucket_id: String, + pub upload_url: String, + pub authorization_token: String, +} + +pub async fn authorize_account( + key_id: &str, + application_key: &str, +) -> Result { + let combined_key = format!("{key_id}:{application_key}"); + let formatted_key = format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(combined_key) + ); + + let response = reqwest::Client::new() + .get("https://api.backblazeb2.com/b2api/v2/b2_authorize_account") + .header(reqwest::header::CONTENT_TYPE, "application/json") + .header(reqwest::header::AUTHORIZATION, formatted_key) + .send() + .await?; + + super::process_response(response).await +} + +pub async fn get_upload_url( + authorization_data: &AuthorizationData, + bucket_id: &str, +) -> Result { + let response = reqwest::Client::new() + .post( + format!( + "{}/b2api/v2/b2_get_upload_url", + authorization_data.api_url + ) + .to_string(), + ) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .header( + reqwest::header::AUTHORIZATION, + &authorization_data.authorization_token, + ) + .body( + serde_json::json!({ + "bucketId": bucket_id, + }) + .to_string(), + ) + .send() + .await?; + + super::process_response(response).await +} diff --git a/apps/labrinth/src/file_hosting/backblaze/delete.rs b/apps/labrinth/src/file_hosting/backblaze/delete.rs new file mode 100644 index 000000000..87e24ac3c --- /dev/null +++ b/apps/labrinth/src/file_hosting/backblaze/delete.rs @@ -0,0 +1,38 @@ +use super::authorization::AuthorizationData; +use crate::file_hosting::FileHostingError; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct DeleteFileData { + pub file_id: String, + pub file_name: String, +} + +pub async fn delete_file_version( + authorization_data: &AuthorizationData, + file_id: &str, + file_name: &str, +) -> Result { + let response = reqwest::Client::new() + .post(format!( + "{}/b2api/v2/b2_delete_file_version", + authorization_data.api_url + )) + .header(reqwest::header::CONTENT_TYPE, "application/json") + .header( + reqwest::header::AUTHORIZATION, + &authorization_data.authorization_token, + ) + .body( + serde_json::json!({ + "fileName": file_name, + "fileId": file_id + }) + .to_string(), + ) + .send() + .await?; + + super::process_response(response).await +} diff --git a/apps/labrinth/src/file_hosting/backblaze/upload.rs b/apps/labrinth/src/file_hosting/backblaze/upload.rs new file mode 100644 index 000000000..b43aa1b57 --- /dev/null +++ b/apps/labrinth/src/file_hosting/backblaze/upload.rs @@ -0,0 +1,45 @@ +use super::authorization::UploadUrlData; +use crate::file_hosting::FileHostingError; +use bytes::Bytes; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct UploadFileData { + pub file_id: String, + pub file_name: String, + pub account_id: String, + pub bucket_id: String, + pub content_length: u32, + pub content_sha1: String, + pub content_md5: Option, + pub content_type: String, + pub upload_timestamp: u64, +} + +//Content Types found here: https://www.backblaze.com/b2/docs/content-types.html +pub async fn upload_file( + url_data: &UploadUrlData, + content_type: &str, + file_name: &str, + file_bytes: Bytes, +) -> Result { + let response = reqwest::Client::new() + .post(&url_data.upload_url) + .header( + reqwest::header::AUTHORIZATION, + &url_data.authorization_token, + ) + .header("X-Bz-File-Name", file_name) + .header(reqwest::header::CONTENT_TYPE, content_type) + .header(reqwest::header::CONTENT_LENGTH, file_bytes.len()) + .header( + "X-Bz-Content-Sha1", + sha1::Sha1::from(&file_bytes).hexdigest(), + ) + .body(file_bytes) + .send() + .await?; + + super::process_response(response).await +} diff --git a/apps/labrinth/src/file_hosting/mock.rs b/apps/labrinth/src/file_hosting/mock.rs new file mode 100644 index 000000000..f520bd11a --- /dev/null +++ b/apps/labrinth/src/file_hosting/mock.rs @@ -0,0 +1,62 @@ +use super::{DeleteFileData, FileHost, FileHostingError, UploadFileData}; +use async_trait::async_trait; +use bytes::Bytes; +use chrono::Utc; +use sha2::Digest; + +#[derive(Default)] +pub struct MockHost(()); + +impl MockHost { + pub fn new() -> Self { + MockHost(()) + } +} + +#[async_trait] +impl FileHost for MockHost { + async fn upload_file( + &self, + content_type: &str, + file_name: &str, + file_bytes: Bytes, + ) -> Result { + let path = + std::path::Path::new(&dotenvy::var("MOCK_FILE_PATH").unwrap()) + .join(file_name.replace("../", "")); + std::fs::create_dir_all( + path.parent().ok_or(FileHostingError::InvalidFilename)?, + )?; + let content_sha1 = sha1::Sha1::from(&file_bytes).hexdigest(); + let content_sha512 = format!("{:x}", sha2::Sha512::digest(&file_bytes)); + + std::fs::write(path, &*file_bytes)?; + Ok(UploadFileData { + file_id: String::from("MOCK_FILE_ID"), + file_name: file_name.to_string(), + content_length: file_bytes.len() as u32, + content_sha512, + content_sha1, + content_md5: None, + content_type: content_type.to_string(), + upload_timestamp: Utc::now().timestamp() as u64, + }) + } + + async fn delete_file_version( + &self, + file_id: &str, + file_name: &str, + ) -> Result { + let path = + std::path::Path::new(&dotenvy::var("MOCK_FILE_PATH").unwrap()) + .join(file_name.replace("../", "")); + if path.exists() { + std::fs::remove_file(path)?; + } + Ok(DeleteFileData { + file_id: file_id.to_string(), + file_name: file_name.to_string(), + }) + } +} diff --git a/apps/labrinth/src/file_hosting/mod.rs b/apps/labrinth/src/file_hosting/mod.rs new file mode 100644 index 000000000..b89d35cbb --- /dev/null +++ b/apps/labrinth/src/file_hosting/mod.rs @@ -0,0 +1,59 @@ +use async_trait::async_trait; +use thiserror::Error; + +mod backblaze; +mod mock; +mod s3_host; + +pub use backblaze::BackblazeHost; +use bytes::Bytes; +pub use mock::MockHost; +pub use s3_host::S3Host; + +#[derive(Error, Debug)] +pub enum FileHostingError { + #[error("Error while accessing the data from backblaze")] + HttpError(#[from] reqwest::Error), + #[error("Backblaze error: {0}")] + BackblazeError(serde_json::Value), + #[error("S3 error: {0}")] + S3Error(String), + #[error("File system error in file hosting: {0}")] + FileSystemError(#[from] std::io::Error), + #[error("Invalid Filename")] + InvalidFilename, +} + +#[derive(Debug, Clone)] +pub struct UploadFileData { + pub file_id: String, + pub file_name: String, + pub content_length: u32, + pub content_sha512: String, + pub content_sha1: String, + pub content_md5: Option, + pub content_type: String, + pub upload_timestamp: u64, +} + +#[derive(Debug, Clone)] +pub struct DeleteFileData { + pub file_id: String, + pub file_name: String, +} + +#[async_trait] +pub trait FileHost { + async fn upload_file( + &self, + content_type: &str, + file_name: &str, + file_bytes: Bytes, + ) -> Result; + + async fn delete_file_version( + &self, + file_id: &str, + file_name: &str, + ) -> Result; +} diff --git a/apps/labrinth/src/file_hosting/s3_host.rs b/apps/labrinth/src/file_hosting/s3_host.rs new file mode 100644 index 000000000..87be229ab --- /dev/null +++ b/apps/labrinth/src/file_hosting/s3_host.rs @@ -0,0 +1,114 @@ +use crate::file_hosting::{ + DeleteFileData, FileHost, FileHostingError, UploadFileData, +}; +use async_trait::async_trait; +use bytes::Bytes; +use chrono::Utc; +use s3::bucket::Bucket; +use s3::creds::Credentials; +use s3::region::Region; +use sha2::Digest; + +pub struct S3Host { + bucket: Bucket, +} + +impl S3Host { + pub fn new( + bucket_name: &str, + bucket_region: &str, + url: &str, + access_token: &str, + secret: &str, + ) -> Result { + let bucket = Bucket::new( + bucket_name, + if bucket_region == "r2" { + Region::R2 { + account_id: url.to_string(), + } + } else { + Region::Custom { + region: bucket_region.to_string(), + endpoint: url.to_string(), + } + }, + Credentials::new( + Some(access_token), + Some(secret), + None, + None, + None, + ) + .map_err(|_| { + FileHostingError::S3Error( + "Error while creating credentials".to_string(), + ) + })?, + ) + .map_err(|_| { + FileHostingError::S3Error( + "Error while creating Bucket instance".to_string(), + ) + })?; + + Ok(S3Host { bucket }) + } +} + +#[async_trait] +impl FileHost for S3Host { + async fn upload_file( + &self, + content_type: &str, + file_name: &str, + file_bytes: Bytes, + ) -> Result { + let content_sha1 = sha1::Sha1::from(&file_bytes).hexdigest(); + let content_sha512 = format!("{:x}", sha2::Sha512::digest(&file_bytes)); + + self.bucket + .put_object_with_content_type( + format!("/{file_name}"), + &file_bytes, + content_type, + ) + .await + .map_err(|_| { + FileHostingError::S3Error( + "Error while uploading file to S3".to_string(), + ) + })?; + + Ok(UploadFileData { + file_id: file_name.to_string(), + file_name: file_name.to_string(), + content_length: file_bytes.len() as u32, + content_sha512, + content_sha1, + content_md5: None, + content_type: content_type.to_string(), + upload_timestamp: Utc::now().timestamp() as u64, + }) + } + + async fn delete_file_version( + &self, + file_id: &str, + file_name: &str, + ) -> Result { + self.bucket + .delete_object(format!("/{file_name}")) + .await + .map_err(|_| { + FileHostingError::S3Error( + "Error while deleting file from S3".to_string(), + ) + })?; + + Ok(DeleteFileData { + file_id: file_id.to_string(), + file_name: file_name.to_string(), + }) + } +} diff --git a/apps/labrinth/src/lib.rs b/apps/labrinth/src/lib.rs new file mode 100644 index 000000000..9bfc70ba5 --- /dev/null +++ b/apps/labrinth/src/lib.rs @@ -0,0 +1,490 @@ +use std::num::NonZeroU32; +use std::sync::Arc; +use std::time::Duration; + +use actix_web::web; +use database::redis::RedisPool; +use log::{info, warn}; +use queue::{ + analytics::AnalyticsQueue, payouts::PayoutsQueue, session::AuthQueue, + socket::ActiveSockets, +}; +use sqlx::Postgres; +use tokio::sync::RwLock; + +extern crate clickhouse as clickhouse_crate; +use clickhouse_crate::Client; +use governor::middleware::StateInformationMiddleware; +use governor::{Quota, RateLimiter}; +use util::cors::default_cors; + +use crate::queue::moderation::AutomatedModerationQueue; +use crate::util::ratelimit::KeyedRateLimiter; +use crate::{ + queue::payouts::process_payout, + search::indexing::index_projects, + util::env::{parse_strings_from_var, parse_var}, +}; + +pub mod auth; +pub mod clickhouse; +pub mod database; +pub mod file_hosting; +pub mod models; +pub mod queue; +pub mod routes; +pub mod scheduler; +pub mod search; +pub mod util; +pub mod validate; + +#[derive(Clone)] +pub struct Pepper { + pub pepper: String, +} + +#[derive(Clone)] +pub struct LabrinthConfig { + pub pool: sqlx::Pool, + pub redis_pool: RedisPool, + pub clickhouse: Client, + pub file_host: Arc, + pub maxmind: Arc, + pub scheduler: Arc, + pub ip_salt: Pepper, + pub search_config: search::SearchConfig, + pub session_queue: web::Data, + pub payouts_queue: web::Data, + pub analytics_queue: Arc, + pub active_sockets: web::Data>, + pub automated_moderation_queue: web::Data, + pub rate_limiter: KeyedRateLimiter, + pub stripe_client: stripe::Client, +} + +pub fn app_setup( + pool: sqlx::Pool, + redis_pool: RedisPool, + search_config: search::SearchConfig, + clickhouse: &mut Client, + file_host: Arc, + maxmind: Arc, +) -> LabrinthConfig { + info!( + "Starting Labrinth on {}", + dotenvy::var("BIND_ADDR").unwrap() + ); + + let automated_moderation_queue = + web::Data::new(AutomatedModerationQueue::default()); + + { + let automated_moderation_queue_ref = automated_moderation_queue.clone(); + let pool_ref = pool.clone(); + let redis_pool_ref = redis_pool.clone(); + actix_rt::spawn(async move { + automated_moderation_queue_ref + .task(pool_ref, redis_pool_ref) + .await; + }); + } + + let mut scheduler = scheduler::Scheduler::new(); + + let limiter: KeyedRateLimiter = Arc::new( + RateLimiter::keyed(Quota::per_minute(NonZeroU32::new(300).unwrap())) + .with_middleware::(), + ); + let limiter_clone = Arc::clone(&limiter); + scheduler.run(Duration::from_secs(60), move || { + info!( + "Clearing ratelimiter, storage size: {}", + limiter_clone.len() + ); + limiter_clone.retain_recent(); + info!( + "Done clearing ratelimiter, storage size: {}", + limiter_clone.len() + ); + + async move {} + }); + + // The interval in seconds at which the local database is indexed + // for searching. Defaults to 1 hour if unset. + let local_index_interval = std::time::Duration::from_secs( + parse_var("LOCAL_INDEX_INTERVAL").unwrap_or(3600), + ); + + let pool_ref = pool.clone(); + let search_config_ref = search_config.clone(); + let redis_pool_ref = redis_pool.clone(); + scheduler.run(local_index_interval, move || { + let pool_ref = pool_ref.clone(); + let redis_pool_ref = redis_pool_ref.clone(); + let search_config_ref = search_config_ref.clone(); + async move { + info!("Indexing local database"); + let result = index_projects( + pool_ref, + redis_pool_ref.clone(), + &search_config_ref, + ) + .await; + if let Err(e) = result { + warn!("Local project indexing failed: {:?}", e); + } + info!("Done indexing local database"); + } + }); + + // Changes statuses of scheduled projects/versions + let pool_ref = pool.clone(); + // TODO: Clear cache when these are run + scheduler.run(std::time::Duration::from_secs(60 * 5), move || { + let pool_ref = pool_ref.clone(); + info!("Releasing scheduled versions/projects!"); + + async move { + let projects_results = sqlx::query!( + " + UPDATE mods + SET status = requested_status + WHERE status = $1 AND approved < CURRENT_DATE AND requested_status IS NOT NULL + ", + crate::models::projects::ProjectStatus::Scheduled.as_str(), + ) + .execute(&pool_ref) + .await; + + if let Err(e) = projects_results { + warn!("Syncing scheduled releases for projects failed: {:?}", e); + } + + let versions_results = sqlx::query!( + " + UPDATE versions + SET status = requested_status + WHERE status = $1 AND date_published < CURRENT_DATE AND requested_status IS NOT NULL + ", + crate::models::projects::VersionStatus::Scheduled.as_str(), + ) + .execute(&pool_ref) + .await; + + if let Err(e) = versions_results { + warn!("Syncing scheduled releases for versions failed: {:?}", e); + } + + info!("Finished releasing scheduled versions/projects"); + } + }); + + scheduler::schedule_versions( + &mut scheduler, + pool.clone(), + redis_pool.clone(), + ); + + let session_queue = web::Data::new(AuthQueue::new()); + + let pool_ref = pool.clone(); + let redis_ref = redis_pool.clone(); + let session_queue_ref = session_queue.clone(); + scheduler.run(std::time::Duration::from_secs(60 * 30), move || { + let pool_ref = pool_ref.clone(); + let redis_ref = redis_ref.clone(); + let session_queue_ref = session_queue_ref.clone(); + + async move { + info!("Indexing sessions queue"); + let result = session_queue_ref.index(&pool_ref, &redis_ref).await; + if let Err(e) = result { + warn!("Indexing sessions queue failed: {:?}", e); + } + info!("Done indexing sessions queue"); + } + }); + + let reader = maxmind.clone(); + { + let reader_ref = reader; + scheduler.run(std::time::Duration::from_secs(60 * 60 * 24), move || { + let reader_ref = reader_ref.clone(); + + async move { + info!("Downloading MaxMind GeoLite2 country database"); + let result = reader_ref.index().await; + if let Err(e) = result { + warn!( + "Downloading MaxMind GeoLite2 country database failed: {:?}", + e + ); + } + info!("Done downloading MaxMind GeoLite2 country database"); + } + }); + } + info!("Downloading MaxMind GeoLite2 country database"); + + let analytics_queue = Arc::new(AnalyticsQueue::new()); + { + let client_ref = clickhouse.clone(); + let analytics_queue_ref = analytics_queue.clone(); + let pool_ref = pool.clone(); + let redis_ref = redis_pool.clone(); + scheduler.run(std::time::Duration::from_secs(15), move || { + let client_ref = client_ref.clone(); + let analytics_queue_ref = analytics_queue_ref.clone(); + let pool_ref = pool_ref.clone(); + let redis_ref = redis_ref.clone(); + + async move { + info!("Indexing analytics queue"); + let result = analytics_queue_ref + .index(client_ref, &redis_ref, &pool_ref) + .await; + if let Err(e) = result { + warn!("Indexing analytics queue failed: {:?}", e); + } + info!("Done indexing analytics queue"); + } + }); + } + + { + let pool_ref = pool.clone(); + let client_ref = clickhouse.clone(); + scheduler.run(std::time::Duration::from_secs(60 * 60 * 6), move || { + let pool_ref = pool_ref.clone(); + let client_ref = client_ref.clone(); + + async move { + info!("Started running payouts"); + let result = process_payout(&pool_ref, &client_ref).await; + if let Err(e) = result { + warn!("Payouts run failed: {:?}", e); + } + info!("Done running payouts"); + } + }); + } + + let stripe_client = + stripe::Client::new(dotenvy::var("STRIPE_API_KEY").unwrap()); + { + let pool_ref = pool.clone(); + let redis_ref = redis_pool.clone(); + let stripe_client_ref = stripe_client.clone(); + + actix_rt::spawn(async move { + routes::internal::billing::task( + stripe_client_ref, + pool_ref, + redis_ref, + ) + .await; + }); + } + + { + let pool_ref = pool.clone(); + let redis_ref = redis_pool.clone(); + + actix_rt::spawn(async move { + routes::internal::billing::subscription_task(pool_ref, redis_ref) + .await; + }); + } + + let ip_salt = Pepper { + pepper: models::ids::Base62Id(models::ids::random_base62(11)) + .to_string(), + }; + + let payouts_queue = web::Data::new(PayoutsQueue::new()); + let active_sockets = web::Data::new(RwLock::new(ActiveSockets::default())); + + LabrinthConfig { + pool, + redis_pool, + clickhouse: clickhouse.clone(), + file_host, + maxmind, + scheduler: Arc::new(scheduler), + ip_salt, + search_config, + session_queue, + payouts_queue, + analytics_queue, + active_sockets, + automated_moderation_queue, + rate_limiter: limiter, + stripe_client, + } +} + +pub fn app_config( + cfg: &mut web::ServiceConfig, + labrinth_config: LabrinthConfig, +) { + cfg.app_data(web::FormConfig::default().error_handler(|err, _req| { + routes::ApiError::Validation(err.to_string()).into() + })) + .app_data(web::PathConfig::default().error_handler(|err, _req| { + routes::ApiError::Validation(err.to_string()).into() + })) + .app_data(web::QueryConfig::default().error_handler(|err, _req| { + routes::ApiError::Validation(err.to_string()).into() + })) + .app_data(web::JsonConfig::default().error_handler(|err, _req| { + routes::ApiError::Validation(err.to_string()).into() + })) + .app_data(web::Data::new(labrinth_config.redis_pool.clone())) + .app_data(web::Data::new(labrinth_config.pool.clone())) + .app_data(web::Data::new(labrinth_config.file_host.clone())) + .app_data(web::Data::new(labrinth_config.search_config.clone())) + .app_data(labrinth_config.session_queue.clone()) + .app_data(labrinth_config.payouts_queue.clone()) + .app_data(web::Data::new(labrinth_config.ip_salt.clone())) + .app_data(web::Data::new(labrinth_config.analytics_queue.clone())) + .app_data(web::Data::new(labrinth_config.clickhouse.clone())) + .app_data(web::Data::new(labrinth_config.maxmind.clone())) + .app_data(labrinth_config.active_sockets.clone()) + .app_data(labrinth_config.automated_moderation_queue.clone()) + .app_data(web::Data::new(labrinth_config.stripe_client.clone())) + .configure(routes::v2::config) + .configure(routes::v3::config) + .configure(routes::internal::config) + .configure(routes::root_config) + .default_service(web::get().wrap(default_cors()).to(routes::not_found)); +} + +// This is so that env vars not used immediately don't panic at runtime +pub fn check_env_vars() -> bool { + let mut failed = false; + + fn check_var(var: &'static str) -> bool { + let check = parse_var::(var).is_none(); + if check { + warn!( + "Variable `{}` missing in dotenv or not of type `{}`", + var, + std::any::type_name::() + ); + } + check + } + + failed |= check_var::("SITE_URL"); + failed |= check_var::("CDN_URL"); + failed |= check_var::("LABRINTH_ADMIN_KEY"); + failed |= check_var::("RATE_LIMIT_IGNORE_KEY"); + failed |= check_var::("DATABASE_URL"); + failed |= check_var::("MEILISEARCH_ADDR"); + failed |= check_var::("MEILISEARCH_KEY"); + failed |= check_var::("REDIS_URL"); + failed |= check_var::("BIND_ADDR"); + failed |= check_var::("SELF_ADDR"); + + failed |= check_var::("STORAGE_BACKEND"); + + let storage_backend = dotenvy::var("STORAGE_BACKEND").ok(); + match storage_backend.as_deref() { + Some("backblaze") => { + failed |= check_var::("BACKBLAZE_KEY_ID"); + failed |= check_var::("BACKBLAZE_KEY"); + failed |= check_var::("BACKBLAZE_BUCKET_ID"); + } + Some("s3") => { + failed |= check_var::("S3_ACCESS_TOKEN"); + failed |= check_var::("S3_SECRET"); + failed |= check_var::("S3_URL"); + failed |= check_var::("S3_REGION"); + failed |= check_var::("S3_BUCKET_NAME"); + } + Some("local") => { + failed |= check_var::("MOCK_FILE_PATH"); + } + Some(backend) => { + warn!("Variable `STORAGE_BACKEND` contains an invalid value: {}. Expected \"backblaze\", \"s3\", or \"local\".", backend); + failed |= true; + } + _ => { + warn!("Variable `STORAGE_BACKEND` is not set!"); + failed |= true; + } + } + + failed |= check_var::("LOCAL_INDEX_INTERVAL"); + failed |= check_var::("VERSION_INDEX_INTERVAL"); + + if parse_strings_from_var("WHITELISTED_MODPACK_DOMAINS").is_none() { + warn!("Variable `WHITELISTED_MODPACK_DOMAINS` missing in dotenv or not a json array of strings"); + failed |= true; + } + + if parse_strings_from_var("ALLOWED_CALLBACK_URLS").is_none() { + warn!("Variable `ALLOWED_CALLBACK_URLS` missing in dotenv or not a json array of strings"); + failed |= true; + } + + failed |= check_var::("GITHUB_CLIENT_ID"); + failed |= check_var::("GITHUB_CLIENT_SECRET"); + failed |= check_var::("GITLAB_CLIENT_ID"); + failed |= check_var::("GITLAB_CLIENT_SECRET"); + failed |= check_var::("DISCORD_CLIENT_ID"); + failed |= check_var::("DISCORD_CLIENT_SECRET"); + failed |= check_var::("MICROSOFT_CLIENT_ID"); + failed |= check_var::("MICROSOFT_CLIENT_SECRET"); + failed |= check_var::("GOOGLE_CLIENT_ID"); + failed |= check_var::("GOOGLE_CLIENT_SECRET"); + failed |= check_var::("STEAM_API_KEY"); + + failed |= check_var::("TREMENDOUS_API_URL"); + failed |= check_var::("TREMENDOUS_API_KEY"); + failed |= check_var::("TREMENDOUS_PRIVATE_KEY"); + + failed |= check_var::("PAYPAL_API_URL"); + failed |= check_var::("PAYPAL_WEBHOOK_ID"); + failed |= check_var::("PAYPAL_CLIENT_ID"); + failed |= check_var::("PAYPAL_CLIENT_SECRET"); + + failed |= check_var::("TURNSTILE_SECRET"); + + failed |= check_var::("SMTP_USERNAME"); + failed |= check_var::("SMTP_PASSWORD"); + failed |= check_var::("SMTP_HOST"); + + failed |= check_var::("SITE_VERIFY_EMAIL_PATH"); + failed |= check_var::("SITE_RESET_PASSWORD_PATH"); + failed |= check_var::("SITE_BILLING_PATH"); + + failed |= check_var::("BEEHIIV_PUBLICATION_ID"); + failed |= check_var::("BEEHIIV_API_KEY"); + + if parse_strings_from_var("ANALYTICS_ALLOWED_ORIGINS").is_none() { + warn!( + "Variable `ANALYTICS_ALLOWED_ORIGINS` missing in dotenv or not a json array of strings" + ); + failed |= true; + } + + failed |= check_var::("CLICKHOUSE_URL"); + failed |= check_var::("CLICKHOUSE_USER"); + failed |= check_var::("CLICKHOUSE_PASSWORD"); + failed |= check_var::("CLICKHOUSE_DATABASE"); + + failed |= check_var::("MAXMIND_LICENSE_KEY"); + + failed |= check_var::("FLAME_ANVIL_URL"); + + failed |= check_var::("STRIPE_API_KEY"); + failed |= check_var::("STRIPE_WEBHOOK_SECRET"); + + failed |= check_var::("ADITUDE_API_KEY"); + + failed |= check_var::("PYRO_API_KEY"); + + failed +} diff --git a/apps/labrinth/src/main.rs b/apps/labrinth/src/main.rs new file mode 100644 index 000000000..336150c8f --- /dev/null +++ b/apps/labrinth/src/main.rs @@ -0,0 +1,123 @@ +use actix_web::{App, HttpServer}; +use actix_web_prom::PrometheusMetricsBuilder; +use env_logger::Env; +use labrinth::database::redis::RedisPool; +use labrinth::file_hosting::S3Host; +use labrinth::search; +use labrinth::util::ratelimit::RateLimit; +use labrinth::{check_env_vars, clickhouse, database, file_hosting, queue}; +use log::{error, info}; +use std::sync::Arc; + +#[cfg(feature = "jemalloc")] +#[global_allocator] +static ALLOC: jemallocator::Jemalloc = jemallocator::Jemalloc; + +#[derive(Clone)] +pub struct Pepper { + pub pepper: String, +} + +#[actix_rt::main] +async fn main() -> std::io::Result<()> { + dotenvy::dotenv().ok(); + env_logger::Builder::from_env(Env::default().default_filter_or("info")) + .init(); + + if check_env_vars() { + error!("Some environment variables are missing!"); + } + + // DSN is from SENTRY_DSN env variable. + // Has no effect if not set. + let sentry = sentry::init(sentry::ClientOptions { + release: sentry::release_name!(), + traces_sample_rate: 0.1, + ..Default::default() + }); + if sentry.is_enabled() { + info!("Enabled Sentry integration"); + std::env::set_var("RUST_BACKTRACE", "1"); + } + + info!( + "Starting Labrinth on {}", + dotenvy::var("BIND_ADDR").unwrap() + ); + + database::check_for_migrations() + .await + .expect("An error occurred while running migrations."); + + // Database Connector + let pool = database::connect() + .await + .expect("Database connection failed"); + + // Redis connector + let redis_pool = RedisPool::new(None); + + let storage_backend = + dotenvy::var("STORAGE_BACKEND").unwrap_or_else(|_| "local".to_string()); + + let file_host: Arc = + match storage_backend.as_str() { + "backblaze" => Arc::new( + file_hosting::BackblazeHost::new( + &dotenvy::var("BACKBLAZE_KEY_ID").unwrap(), + &dotenvy::var("BACKBLAZE_KEY").unwrap(), + &dotenvy::var("BACKBLAZE_BUCKET_ID").unwrap(), + ) + .await, + ), + "s3" => Arc::new( + S3Host::new( + &dotenvy::var("S3_BUCKET_NAME").unwrap(), + &dotenvy::var("S3_REGION").unwrap(), + &dotenvy::var("S3_URL").unwrap(), + &dotenvy::var("S3_ACCESS_TOKEN").unwrap(), + &dotenvy::var("S3_SECRET").unwrap(), + ) + .unwrap(), + ), + "local" => Arc::new(file_hosting::MockHost::new()), + _ => panic!("Invalid storage backend specified. Aborting startup!"), + }; + + info!("Initializing clickhouse connection"); + let mut clickhouse = clickhouse::init_client().await.unwrap(); + + let maxmind_reader = + Arc::new(queue::maxmind::MaxMindIndexer::new().await.unwrap()); + + let prometheus = PrometheusMetricsBuilder::new("labrinth") + .endpoint("/metrics") + .build() + .expect("Failed to create prometheus metrics middleware"); + + let search_config = search::SearchConfig::new(None); + + let labrinth_config = labrinth::app_setup( + pool.clone(), + redis_pool.clone(), + search_config.clone(), + &mut clickhouse, + file_host.clone(), + maxmind_reader.clone(), + ); + + info!("Starting Actix HTTP server!"); + + // Init App + HttpServer::new(move || { + App::new() + .wrap(prometheus.clone()) + .wrap(RateLimit(Arc::clone(&labrinth_config.rate_limiter))) + .wrap(actix_web::middleware::Compress::default()) + .wrap(sentry_actix::Sentry::new()) + .configure(|cfg| labrinth::app_config(cfg, labrinth_config.clone())) + }) + .bind(dotenvy::var("BIND_ADDR").unwrap())? + .run() + .await +} diff --git a/apps/labrinth/src/models/error.rs b/apps/labrinth/src/models/error.rs new file mode 100644 index 000000000..28f737c16 --- /dev/null +++ b/apps/labrinth/src/models/error.rs @@ -0,0 +1,8 @@ +use serde::{Deserialize, Serialize}; + +/// An error returned by the API +#[derive(Serialize, Deserialize)] +pub struct ApiError<'a> { + pub error: &'a str, + pub description: String, +} diff --git a/apps/labrinth/src/models/mod.rs b/apps/labrinth/src/models/mod.rs new file mode 100644 index 000000000..aea510d79 --- /dev/null +++ b/apps/labrinth/src/models/mod.rs @@ -0,0 +1,21 @@ +pub mod error; +pub mod v2; +pub mod v3; + +pub use v3::analytics; +pub use v3::billing; +pub use v3::collections; +pub use v3::ids; +pub use v3::images; +pub use v3::notifications; +pub use v3::oauth_clients; +pub use v3::organizations; +pub use v3::pack; +pub use v3::pats; +pub use v3::payouts; +pub use v3::projects; +pub use v3::reports; +pub use v3::sessions; +pub use v3::teams; +pub use v3::threads; +pub use v3::users; diff --git a/apps/labrinth/src/models/v2/mod.rs b/apps/labrinth/src/models/v2/mod.rs new file mode 100644 index 000000000..ed955b3ad --- /dev/null +++ b/apps/labrinth/src/models/v2/mod.rs @@ -0,0 +1,8 @@ +// Legacy models from V2, where its useful to keep the struct for rerouting/conversion +pub mod notifications; +pub mod projects; +pub mod reports; +pub mod search; +pub mod teams; +pub mod threads; +pub mod user; diff --git a/apps/labrinth/src/models/v2/notifications.rs b/apps/labrinth/src/models/v2/notifications.rs new file mode 100644 index 000000000..6e4166a56 --- /dev/null +++ b/apps/labrinth/src/models/v2/notifications.rs @@ -0,0 +1,194 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::models::{ + ids::{ + NotificationId, OrganizationId, ProjectId, ReportId, TeamId, ThreadId, + ThreadMessageId, UserId, VersionId, + }, + notifications::{Notification, NotificationAction, NotificationBody}, + projects::ProjectStatus, +}; + +#[derive(Serialize, Deserialize)] +pub struct LegacyNotification { + pub id: NotificationId, + pub user_id: UserId, + pub read: bool, + pub created: DateTime, + pub body: LegacyNotificationBody, + + // DEPRECATED: use body field instead + #[serde(rename = "type")] + pub type_: Option, + pub title: String, + pub text: String, + pub link: String, + pub actions: Vec, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct LegacyNotificationAction { + pub title: String, + /// The route to call when this notification action is called. Formatted HTTP Method, route + pub action_route: (String, String), +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum LegacyNotificationBody { + ProjectUpdate { + project_id: ProjectId, + version_id: VersionId, + }, + TeamInvite { + project_id: ProjectId, + team_id: TeamId, + invited_by: UserId, + role: String, + }, + OrganizationInvite { + organization_id: OrganizationId, + invited_by: UserId, + team_id: TeamId, + role: String, + }, + StatusChange { + project_id: ProjectId, + old_status: ProjectStatus, + new_status: ProjectStatus, + }, + ModeratorMessage { + thread_id: ThreadId, + message_id: ThreadMessageId, + + project_id: Option, + report_id: Option, + }, + LegacyMarkdown { + notification_type: Option, + title: String, + text: String, + link: String, + actions: Vec, + }, + Unknown, +} + +impl LegacyNotification { + pub fn from(notification: Notification) -> Self { + let type_ = match ¬ification.body { + NotificationBody::ProjectUpdate { .. } => { + Some("project_update".to_string()) + } + NotificationBody::TeamInvite { .. } => { + Some("team_invite".to_string()) + } + NotificationBody::OrganizationInvite { .. } => { + Some("organization_invite".to_string()) + } + NotificationBody::StatusChange { .. } => { + Some("status_change".to_string()) + } + NotificationBody::ModeratorMessage { .. } => { + Some("moderator_message".to_string()) + } + NotificationBody::LegacyMarkdown { + notification_type, .. + } => notification_type.clone(), + NotificationBody::Unknown => None, + }; + + let legacy_body = match notification.body { + NotificationBody::ProjectUpdate { + project_id, + version_id, + } => LegacyNotificationBody::ProjectUpdate { + project_id, + version_id, + }, + NotificationBody::TeamInvite { + project_id, + team_id, + invited_by, + role, + } => LegacyNotificationBody::TeamInvite { + project_id, + team_id, + invited_by, + role, + }, + NotificationBody::OrganizationInvite { + organization_id, + invited_by, + team_id, + role, + } => LegacyNotificationBody::OrganizationInvite { + organization_id, + invited_by, + team_id, + role, + }, + NotificationBody::StatusChange { + project_id, + old_status, + new_status, + } => LegacyNotificationBody::StatusChange { + project_id, + old_status, + new_status, + }, + NotificationBody::ModeratorMessage { + thread_id, + message_id, + project_id, + report_id, + } => LegacyNotificationBody::ModeratorMessage { + thread_id, + message_id, + project_id, + report_id, + }, + NotificationBody::LegacyMarkdown { + notification_type, + name, + text, + link, + actions, + } => LegacyNotificationBody::LegacyMarkdown { + notification_type, + title: name, + text, + link, + actions, + }, + NotificationBody::Unknown => LegacyNotificationBody::Unknown, + }; + + Self { + id: notification.id, + user_id: notification.user_id, + read: notification.read, + created: notification.created, + body: legacy_body, + type_, + title: notification.name, + text: notification.text, + link: notification.link, + actions: notification + .actions + .into_iter() + .map(LegacyNotificationAction::from) + .collect(), + } + } +} + +impl LegacyNotificationAction { + pub fn from(notification_action: NotificationAction) -> Self { + Self { + title: notification_action.name, + action_route: notification_action.action_route, + } + } +} diff --git a/apps/labrinth/src/models/v2/projects.rs b/apps/labrinth/src/models/v2/projects.rs new file mode 100644 index 000000000..a96d51707 --- /dev/null +++ b/apps/labrinth/src/models/v2/projects.rs @@ -0,0 +1,422 @@ +use std::convert::TryFrom; + +use std::collections::HashMap; + +use super::super::ids::OrganizationId; +use super::super::teams::TeamId; +use super::super::users::UserId; +use crate::database::models::{version_item, DatabaseError}; +use crate::database::redis::RedisPool; +use crate::models::ids::{ProjectId, VersionId}; +use crate::models::projects::{ + Dependency, License, Link, Loader, ModeratorMessage, MonetizationStatus, + Project, ProjectStatus, Version, VersionFile, VersionStatus, VersionType, +}; +use crate::models::threads::ThreadId; +use crate::routes::v2_reroute::{self, capitalize_first}; +use chrono::{DateTime, Utc}; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +/// A project returned from the API +#[derive(Serialize, Deserialize, Clone)] +pub struct LegacyProject { + /// Relevant V2 fields- these were removed or modfified in V3, + /// and are now part of the dynamic fields system + /// The support range for the client project* + pub client_side: LegacySideType, + /// The support range for the server project + pub server_side: LegacySideType, + /// A list of game versions this project supports + pub game_versions: Vec, + + // All other fields are the same as V3 + // If they change, or their constituent types change, we may need to + // add a new struct for them here. + pub id: ProjectId, + pub slug: Option, + pub project_type: String, + pub team: TeamId, + pub organization: Option, + pub title: String, + pub description: String, + pub body: String, + pub body_url: Option, + pub published: DateTime, + pub updated: DateTime, + pub approved: Option>, + pub queued: Option>, + pub status: ProjectStatus, + pub requested_status: Option, + pub moderator_message: Option, + pub license: License, + pub downloads: u32, + pub followers: u32, + pub categories: Vec, + pub additional_categories: Vec, + pub loaders: Vec, + pub versions: Vec, + pub icon_url: Option, + pub issues_url: Option, + pub source_url: Option, + pub wiki_url: Option, + pub discord_url: Option, + pub donation_urls: Option>, + pub gallery: Vec, + pub color: Option, + pub thread_id: ThreadId, + pub monetization_status: MonetizationStatus, +} + +impl LegacyProject { + // Returns visible v2 project_type and also 'og' selected project type + // These are often identical, but we want to display 'mod' for datapacks and plugins + // The latter can be used for further processing, such as determining side types of plugins + pub fn get_project_type(project_types: &[String]) -> (String, String) { + // V2 versions only have one project type- v3 versions can rarely have multiple. + // We'll prioritize 'modpack' first, and if neither are found, use the first one. + // If there are no project types, default to 'project' + let mut project_types = project_types.to_vec(); + if project_types.contains(&"modpack".to_string()) { + project_types = vec!["modpack".to_string()]; + } + + let og_project_type = project_types + .first() + .cloned() + .unwrap_or("project".to_string()); // Default to 'project' if none are found + + let project_type = + if og_project_type == "datapack" || og_project_type == "plugin" { + // These are not supported in V2, so we'll just use 'mod' instead + "mod".to_string() + } else { + og_project_type.clone() + }; + + (project_type, og_project_type) + } + + // Convert from a standard V3 project to a V2 project + // Requires any queried versions to be passed in, to get access to certain version fields contained within. + // - This can be any version, because the fields are ones that used to be on the project itself. + // - Its conceivable that certain V3 projects that have many different ones may not have the same fields on all of them. + // It's safe to use a db version_item for this as the only info is side types, game versions, and loader fields (for loaders), which used to be public on project anyway. + pub fn from( + data: Project, + versions_item: Option, + ) -> Self { + let mut client_side = LegacySideType::Unknown; + let mut server_side = LegacySideType::Unknown; + + // V2 versions only have one project type- v3 versions can rarely have multiple. + // We'll prioritize 'modpack' first, and if neither are found, use the first one. + // If there are no project types, default to 'project' + let project_types = data.project_types; + let (mut project_type, og_project_type) = + Self::get_project_type(&project_types); + + let mut loaders = data.loaders; + + let game_versions = data + .fields + .get("game_versions") + .unwrap_or(&Vec::new()) + .iter() + .filter_map(|v| v.as_str()) + .map(|v| v.to_string()) + .collect(); + + if let Some(versions_item) = versions_item { + // Extract side types from remaining fields (singleplayer, client_only, etc) + let fields = versions_item + .version_fields + .iter() + .map(|f| { + (f.field_name.clone(), f.value.clone().serialize_internal()) + }) + .collect::>(); + (client_side, server_side) = v2_reroute::convert_side_types_v2( + &fields, + Some(&*og_project_type), + ); + + // - if loader is mrpack, this is a modpack + // the loaders are whatever the corresponding loader fields are + if loaders.contains(&"mrpack".to_string()) { + project_type = "modpack".to_string(); + if let Some(mrpack_loaders) = + data.fields.iter().find(|f| f.0 == "mrpack_loaders") + { + let values = mrpack_loaders + .1 + .iter() + .filter_map(|v| v.as_str()) + .map(|v| v.to_string()) + .collect::>(); + + // drop mrpack from loaders + loaders = loaders + .into_iter() + .filter(|l| l != "mrpack") + .collect::>(); + // and replace with mrpack_loaders + loaders.extend(values); + // remove duplicate loaders + loaders = loaders.into_iter().unique().collect::>(); + } + } + } + + let issues_url = data.link_urls.get("issues").map(|l| l.url.clone()); + let source_url = data.link_urls.get("source").map(|l| l.url.clone()); + let wiki_url = data.link_urls.get("wiki").map(|l| l.url.clone()); + let discord_url = data.link_urls.get("discord").map(|l| l.url.clone()); + + let donation_urls = data + .link_urls + .iter() + .filter(|(_, l)| l.donation) + .map(|(_, l)| DonationLink::try_from(l.clone()).ok()) + .collect::>>(); + + Self { + id: data.id, + slug: data.slug, + project_type, + team: data.team_id, + organization: data.organization, + title: data.name, + description: data.summary, // V2 description is V3 summary + body: data.description, // V2 body is V3 description + body_url: None, // Always None even in V2 + published: data.published, + updated: data.updated, + approved: data.approved, + queued: data.queued, + status: data.status, + requested_status: data.requested_status, + moderator_message: data.moderator_message, + license: data.license, + downloads: data.downloads, + followers: data.followers, + categories: data.categories, + additional_categories: data.additional_categories, + loaders, + versions: data.versions, + icon_url: data.icon_url, + issues_url, + source_url, + wiki_url, + discord_url, + donation_urls, + gallery: data + .gallery + .into_iter() + .map(LegacyGalleryItem::from) + .collect(), + color: data.color, + thread_id: data.thread_id, + monetization_status: data.monetization_status, + client_side, + server_side, + game_versions, + } + } + + // Because from needs a version_item, this is a helper function to get many from one db query. + pub async fn from_many<'a, E>( + data: Vec, + exec: E, + redis: &RedisPool, + ) -> Result, DatabaseError> + where + E: sqlx::Acquire<'a, Database = sqlx::Postgres>, + { + let version_ids: Vec<_> = data + .iter() + .filter_map(|p| p.versions.first().map(|i| (*i).into())) + .collect(); + let example_versions = + version_item::Version::get_many(&version_ids, exec, redis).await?; + let mut legacy_projects = Vec::new(); + for project in data { + let version_item = example_versions + .iter() + .find(|v| v.inner.project_id == project.id.into()) + .cloned(); + let project = LegacyProject::from(project, version_item); + legacy_projects.push(project); + } + Ok(legacy_projects) + } +} + +#[derive(Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Copy)] +#[serde(rename_all = "kebab-case")] +pub enum LegacySideType { + Required, + Optional, + Unsupported, + Unknown, +} + +impl std::fmt::Display for LegacySideType { + fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(fmt, "{}", self.as_str()) + } +} + +impl LegacySideType { + // These are constant, so this can remove unneccessary allocations (`to_string`) + pub fn as_str(&self) -> &'static str { + match self { + LegacySideType::Required => "required", + LegacySideType::Optional => "optional", + LegacySideType::Unsupported => "unsupported", + LegacySideType::Unknown => "unknown", + } + } + + pub fn from_string(string: &str) -> LegacySideType { + match string { + "required" => LegacySideType::Required, + "optional" => LegacySideType::Optional, + "unsupported" => LegacySideType::Unsupported, + _ => LegacySideType::Unknown, + } + } +} + +/// A specific version of a project +#[derive(Serialize, Deserialize, Clone)] +pub struct LegacyVersion { + /// Relevant V2 fields- these were removed or modfified in V3, + /// and are now part of the dynamic fields system + /// A list of game versions this project supports + pub game_versions: Vec, + + /// A list of loaders this project supports (has a newtype struct) + pub loaders: Vec, + + pub id: VersionId, + pub project_id: ProjectId, + pub author_id: UserId, + pub featured: bool, + pub name: String, + pub version_number: String, + pub changelog: String, + pub changelog_url: Option, + pub date_published: DateTime, + pub downloads: u32, + pub version_type: VersionType, + pub status: VersionStatus, + pub requested_status: Option, + pub files: Vec, + pub dependencies: Vec, +} + +impl From for LegacyVersion { + fn from(data: Version) -> Self { + let mut game_versions = Vec::new(); + if let Some(value) = + data.fields.get("game_versions").and_then(|v| v.as_array()) + { + for gv in value { + if let Some(game_version) = gv.as_str() { + game_versions.push(game_version.to_string()); + } + } + } + + // - if loader is mrpack, this is a modpack + // the v2 loaders are whatever the corresponding loader fields are + let mut loaders = + data.loaders.into_iter().map(|l| l.0).collect::>(); + if loaders.contains(&"mrpack".to_string()) { + if let Some((_, mrpack_loaders)) = data + .fields + .into_iter() + .find(|(key, _)| key == "mrpack_loaders") + { + if let Ok(mrpack_loaders) = + serde_json::from_value(mrpack_loaders) + { + loaders = mrpack_loaders; + } + } + } + let loaders = loaders.into_iter().map(Loader).collect::>(); + + Self { + id: data.id, + project_id: data.project_id, + author_id: data.author_id, + featured: data.featured, + name: data.name, + version_number: data.version_number, + changelog: data.changelog, + changelog_url: None, // Always None even in V2 + date_published: data.date_published, + downloads: data.downloads, + version_type: data.version_type, + status: data.status, + requested_status: data.requested_status, + files: data.files, + dependencies: data.dependencies, + game_versions, + loaders, + } + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct LegacyGalleryItem { + pub url: String, + pub raw_url: String, + pub featured: bool, + pub title: Option, + pub description: Option, + pub created: DateTime, + pub ordering: i64, +} + +impl LegacyGalleryItem { + fn from(data: crate::models::projects::GalleryItem) -> Self { + Self { + url: data.url, + raw_url: data.raw_url, + featured: data.featured, + title: data.name, + description: data.description, + created: data.created, + ordering: data.ordering, + } + } +} + +#[derive(Serialize, Deserialize, Validate, Clone, Eq, PartialEq)] +pub struct DonationLink { + pub id: String, + pub platform: String, + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + pub url: String, +} + +impl TryFrom for DonationLink { + type Error = String; + fn try_from(link: Link) -> Result { + if !link.donation { + return Err("Not a donation".to_string()); + } + Ok(Self { + platform: capitalize_first(&link.platform), + url: link.url, + id: link.platform, + }) + } +} diff --git a/apps/labrinth/src/models/v2/reports.rs b/apps/labrinth/src/models/v2/reports.rs new file mode 100644 index 000000000..4e531326c --- /dev/null +++ b/apps/labrinth/src/models/v2/reports.rs @@ -0,0 +1,52 @@ +use crate::models::ids::{ReportId, ThreadId, UserId}; +use crate::models::reports::{ItemType, Report}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub struct LegacyReport { + pub id: ReportId, + pub report_type: String, + pub item_id: String, + pub item_type: LegacyItemType, + pub reporter: UserId, + pub body: String, + pub created: DateTime, + pub closed: bool, + pub thread_id: ThreadId, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "kebab-case")] +pub enum LegacyItemType { + Project, + Version, + User, + Unknown, +} +impl From for LegacyItemType { + fn from(x: ItemType) -> Self { + match x { + ItemType::Project => LegacyItemType::Project, + ItemType::Version => LegacyItemType::Version, + ItemType::User => LegacyItemType::User, + ItemType::Unknown => LegacyItemType::Unknown, + } + } +} + +impl From for LegacyReport { + fn from(x: Report) -> Self { + LegacyReport { + id: x.id, + report_type: x.report_type, + item_id: x.item_id, + item_type: x.item_type.into(), + reporter: x.reporter, + body: x.body, + created: x.created, + closed: x.closed, + thread_id: x.thread_id, + } + } +} diff --git a/apps/labrinth/src/models/v2/search.rs b/apps/labrinth/src/models/v2/search.rs new file mode 100644 index 000000000..dfc9356b7 --- /dev/null +++ b/apps/labrinth/src/models/v2/search.rs @@ -0,0 +1,183 @@ +use itertools::Itertools; +use serde::{Deserialize, Serialize}; + +use crate::{routes::v2_reroute, search::ResultSearchProject}; + +#[derive(Serialize, Deserialize, Debug)] +pub struct LegacySearchResults { + pub hits: Vec, + pub offset: usize, + pub limit: usize, + pub total_hits: usize, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct LegacyResultSearchProject { + pub project_id: String, + pub project_type: String, + pub slug: Option, + pub author: String, + pub title: String, + pub description: String, + pub categories: Vec, + pub display_categories: Vec, + pub versions: Vec, + pub downloads: i32, + pub follows: i32, + pub icon_url: String, + /// RFC 3339 formatted creation date of the project + pub date_created: String, + /// RFC 3339 formatted modification date of the project + pub date_modified: String, + pub latest_version: String, + pub license: String, + pub client_side: String, + pub server_side: String, + pub gallery: Vec, + pub featured_gallery: Option, + pub color: Option, +} + +// TODO: In other PR, when these are merged, make sure the v2 search testing functions use these +impl LegacyResultSearchProject { + pub fn from(result_search_project: ResultSearchProject) -> Self { + let mut categories = result_search_project.categories; + categories.extend(result_search_project.loaders.clone()); + if categories.contains(&"mrpack".to_string()) { + if let Some(mrpack_loaders) = result_search_project + .project_loader_fields + .get("mrpack_loaders") + { + categories.extend( + mrpack_loaders + .iter() + .filter_map(|c| c.as_str()) + .map(String::from), + ); + categories.retain(|c| c != "mrpack"); + } + } + let mut display_categories = result_search_project.display_categories; + display_categories.extend(result_search_project.loaders); + if display_categories.contains(&"mrpack".to_string()) { + if let Some(mrpack_loaders) = result_search_project + .project_loader_fields + .get("mrpack_loaders") + { + categories.extend( + mrpack_loaders + .iter() + .filter_map(|c| c.as_str()) + .map(String::from), + ); + display_categories.retain(|c| c != "mrpack"); + } + } + + // Sort then remove duplicates + categories.sort(); + categories.dedup(); + display_categories.sort(); + display_categories.dedup(); + + // V2 versions only have one project type- v3 versions can rarely have multiple. + // We'll prioritize 'modpack' first, and if neither are found, use the first one. + // If there are no project types, default to 'project' + let mut project_types = result_search_project.project_types; + if project_types.contains(&"modpack".to_string()) { + project_types = vec!["modpack".to_string()]; + } + let og_project_type = project_types + .first() + .cloned() + .unwrap_or("project".to_string()); // Default to 'project' if none are found + + let project_type = + if og_project_type == "datapack" || og_project_type == "plugin" { + // These are not supported in V2, so we'll just use 'mod' instead + "mod".to_string() + } else { + og_project_type.clone() + }; + + let project_loader_fields = + result_search_project.project_loader_fields.clone(); + let get_one_bool_loader_field = |key: &str| { + project_loader_fields + .get(key) + .cloned() + .unwrap_or_default() + .first() + .and_then(|s| s.as_bool()) + }; + + let singleplayer = get_one_bool_loader_field("singleplayer"); + let client_only = + get_one_bool_loader_field("client_only").unwrap_or(false); + let server_only = + get_one_bool_loader_field("server_only").unwrap_or(false); + let client_and_server = get_one_bool_loader_field("client_and_server"); + + let (client_side, server_side) = + v2_reroute::convert_side_types_v2_bools( + singleplayer, + client_only, + server_only, + client_and_server, + Some(&*og_project_type), + ); + let client_side = client_side.to_string(); + let server_side = server_side.to_string(); + + let versions = result_search_project + .project_loader_fields + .get("game_versions") + .cloned() + .unwrap_or_default() + .into_iter() + .filter_map(|s| s.as_str().map(String::from)) + .collect_vec(); + + Self { + project_type, + client_side, + server_side, + versions, + latest_version: result_search_project.version_id, + categories, + + project_id: result_search_project.project_id, + slug: result_search_project.slug, + author: result_search_project.author, + title: result_search_project.name, + description: result_search_project.summary, + display_categories, + downloads: result_search_project.downloads, + follows: result_search_project.follows, + icon_url: result_search_project.icon_url.unwrap_or_default(), + license: result_search_project.license, + date_created: result_search_project.date_created, + date_modified: result_search_project.date_modified, + gallery: result_search_project.gallery, + featured_gallery: result_search_project.featured_gallery, + color: result_search_project.color, + } + } +} + +impl LegacySearchResults { + pub fn from(search_results: crate::search::SearchResults) -> Self { + let limit = search_results.hits_per_page; + let offset = (search_results.page - 1) * limit; + Self { + hits: search_results + .hits + .into_iter() + .map(LegacyResultSearchProject::from) + .collect(), + offset, + limit, + total_hits: search_results.total_hits, + } + } +} diff --git a/apps/labrinth/src/models/v2/teams.rs b/apps/labrinth/src/models/v2/teams.rs new file mode 100644 index 000000000..f265b7701 --- /dev/null +++ b/apps/labrinth/src/models/v2/teams.rs @@ -0,0 +1,41 @@ +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; + +use crate::models::{ + ids::TeamId, + teams::{ProjectPermissions, TeamMember}, + users::User, +}; + +/// A member of a team +#[derive(Serialize, Deserialize, Clone)] +pub struct LegacyTeamMember { + pub role: String, + // is_owner removed, and role hardcoded to Owner if true, + pub team_id: TeamId, + pub user: User, + pub permissions: Option, + pub accepted: bool, + + #[serde(with = "rust_decimal::serde::float_option")] + pub payouts_split: Option, + pub ordering: i64, +} + +impl LegacyTeamMember { + pub fn from(team_member: TeamMember) -> Self { + LegacyTeamMember { + role: match (team_member.is_owner, team_member.role.as_str()) { + (true, _) => "Owner".to_string(), + (false, "Owner") => "Member".to_string(), // The odd case of a non-owner with the owner role should show as 'Member' + (false, role) => role.to_string(), + }, + team_id: team_member.team_id, + user: team_member.user, + permissions: team_member.permissions, + accepted: team_member.accepted, + payouts_split: team_member.payouts_split, + ordering: team_member.ordering, + } + } +} diff --git a/apps/labrinth/src/models/v2/threads.rs b/apps/labrinth/src/models/v2/threads.rs new file mode 100644 index 000000000..064be2802 --- /dev/null +++ b/apps/labrinth/src/models/v2/threads.rs @@ -0,0 +1,131 @@ +use crate::models::ids::{ + ImageId, ProjectId, ReportId, ThreadId, ThreadMessageId, +}; +use crate::models::projects::ProjectStatus; +use crate::models::users::{User, UserId}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub struct LegacyThread { + pub id: ThreadId, + #[serde(rename = "type")] + pub type_: LegacyThreadType, + pub project_id: Option, + pub report_id: Option, + pub messages: Vec, + pub members: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct LegacyThreadMessage { + pub id: ThreadMessageId, + pub author_id: Option, + pub body: LegacyMessageBody, + pub created: DateTime, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum LegacyMessageBody { + Text { + body: String, + #[serde(default)] + private: bool, + replying_to: Option, + #[serde(default)] + associated_images: Vec, + }, + StatusChange { + new_status: ProjectStatus, + old_status: ProjectStatus, + }, + ThreadClosure, + ThreadReopen, + Deleted { + #[serde(default)] + private: bool, + }, +} + +#[derive(Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] +#[serde(rename_all = "snake_case")] +pub enum LegacyThreadType { + Report, + Project, + DirectMessage, +} + +impl From for LegacyThreadType { + fn from(t: crate::models::v3::threads::ThreadType) -> Self { + match t { + crate::models::v3::threads::ThreadType::Report => { + LegacyThreadType::Report + } + crate::models::v3::threads::ThreadType::Project => { + LegacyThreadType::Project + } + crate::models::v3::threads::ThreadType::DirectMessage => { + LegacyThreadType::DirectMessage + } + } + } +} + +impl From for LegacyMessageBody { + fn from(b: crate::models::v3::threads::MessageBody) -> Self { + match b { + crate::models::v3::threads::MessageBody::Text { + body, + private, + replying_to, + associated_images, + } => LegacyMessageBody::Text { + body, + private, + replying_to, + associated_images, + }, + crate::models::v3::threads::MessageBody::StatusChange { + new_status, + old_status, + } => LegacyMessageBody::StatusChange { + new_status, + old_status, + }, + crate::models::v3::threads::MessageBody::ThreadClosure => { + LegacyMessageBody::ThreadClosure + } + crate::models::v3::threads::MessageBody::ThreadReopen => { + LegacyMessageBody::ThreadReopen + } + crate::models::v3::threads::MessageBody::Deleted { private } => { + LegacyMessageBody::Deleted { private } + } + } + } +} + +impl From for LegacyThreadMessage { + fn from(m: crate::models::v3::threads::ThreadMessage) -> Self { + LegacyThreadMessage { + id: m.id, + author_id: m.author_id, + body: m.body.into(), + created: m.created, + } + } +} + +impl From for LegacyThread { + fn from(t: crate::models::v3::threads::Thread) -> Self { + LegacyThread { + id: t.id, + type_: t.type_.into(), + project_id: t.project_id, + report_id: t.report_id, + messages: t.messages.into_iter().map(|m| m.into()).collect(), + members: t.members, + } + } +} diff --git a/apps/labrinth/src/models/v2/user.rs b/apps/labrinth/src/models/v2/user.rs new file mode 100644 index 000000000..cc5c6d63a --- /dev/null +++ b/apps/labrinth/src/models/v2/user.rs @@ -0,0 +1,53 @@ +use crate::{ + auth::AuthProvider, + models::{ + ids::UserId, + users::{Badges, Role, UserPayoutData}, + }, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct LegacyUser { + pub id: UserId, + pub username: String, + pub name: Option, + pub avatar_url: Option, + pub bio: Option, + pub created: DateTime, + pub role: Role, + pub badges: Badges, + + pub auth_providers: Option>, // this was changed in v3, but not changes ones we want to keep out of v2 + pub email: Option, + pub email_verified: Option, + pub has_password: Option, + pub has_totp: Option, + pub payout_data: Option, // this was changed in v3, but not ones we want to keep out of v2 + + // DEPRECATED. Always returns None + pub github_id: Option, +} + +impl From for LegacyUser { + fn from(data: crate::models::v3::users::User) -> Self { + Self { + id: data.id, + username: data.username, + name: None, + email: data.email, + email_verified: data.email_verified, + avatar_url: data.avatar_url, + bio: data.bio, + created: data.created, + role: data.role, + badges: data.badges, + payout_data: data.payout_data, + auth_providers: data.auth_providers, + has_password: data.has_password, + has_totp: data.has_totp, + github_id: data.github_id, + } + } +} diff --git a/apps/labrinth/src/models/v3/analytics.rs b/apps/labrinth/src/models/v3/analytics.rs new file mode 100644 index 000000000..b59254a75 --- /dev/null +++ b/apps/labrinth/src/models/v3/analytics.rs @@ -0,0 +1,64 @@ +use clickhouse::Row; +use serde::{Deserialize, Serialize}; +use std::hash::Hash; +use std::net::Ipv6Addr; + +#[derive(Row, Serialize, Deserialize, Clone, Eq, PartialEq, Hash)] +pub struct Download { + pub recorded: i64, + pub domain: String, + pub site_path: String, + + // Modrinth User ID for logged in users, default 0 + pub user_id: u64, + // default is 0 if unknown + pub project_id: u64, + // default is 0 if unknown + pub version_id: u64, + + // The below information is used exclusively for data aggregation and fraud detection + // (ex: download botting). + pub ip: Ipv6Addr, + pub country: String, + pub user_agent: String, + pub headers: Vec<(String, String)>, +} + +#[derive(Row, Serialize, Deserialize, Clone, Eq, PartialEq, Hash)] +pub struct PageView { + pub recorded: i64, + pub domain: String, + pub site_path: String, + + // Modrinth User ID for logged in users + pub user_id: u64, + // Modrinth Project ID (used for payouts) + pub project_id: u64, + // whether this view will be monetized / counted for payouts + pub monetized: bool, + + // The below information is used exclusively for data aggregation and fraud detection + // (ex: page view botting). + pub ip: Ipv6Addr, + pub country: String, + pub user_agent: String, + pub headers: Vec<(String, String)>, +} + +#[derive(Row, Serialize, Deserialize, Clone, Debug, Eq, PartialEq, Hash)] +pub struct Playtime { + pub recorded: i64, + pub seconds: u64, + + // Modrinth User ID for logged in users (unused atm) + pub user_id: u64, + // Modrinth Project ID + pub project_id: u64, + // Modrinth Version ID + pub version_id: u64, + + pub loader: String, + pub game_version: String, + /// Parent modpack this playtime was recorded in + pub parent: u64, +} diff --git a/apps/labrinth/src/models/v3/billing.rs b/apps/labrinth/src/models/v3/billing.rs new file mode 100644 index 000000000..afe879019 --- /dev/null +++ b/apps/labrinth/src/models/v3/billing.rs @@ -0,0 +1,234 @@ +use crate::models::ids::Base62Id; +use crate::models::ids::UserId; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct ProductId(pub u64); + +#[derive(Serialize, Deserialize)] +pub struct Product { + pub id: ProductId, + pub metadata: ProductMetadata, + pub prices: Vec, + pub unitary: bool, +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum ProductMetadata { + Midas, + Pyro { + cpu: u32, + ram: u32, + swap: u32, + storage: u32, + }, +} + +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct ProductPriceId(pub u64); + +#[derive(Serialize, Deserialize)] +pub struct ProductPrice { + pub id: ProductPriceId, + pub product_id: ProductId, + pub prices: Price, + pub currency_code: String, +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum Price { + OneTime { + price: i32, + }, + Recurring { + intervals: HashMap, + }, +} + +#[derive(Serialize, Deserialize, Hash, Eq, PartialEq, Debug, Copy, Clone)] +#[serde(rename_all = "kebab-case")] +pub enum PriceDuration { + Monthly, + Yearly, +} + +impl PriceDuration { + pub fn duration(&self) -> chrono::Duration { + match self { + PriceDuration::Monthly => chrono::Duration::days(30), + PriceDuration::Yearly => chrono::Duration::days(365), + } + } + + pub fn from_string(string: &str) -> PriceDuration { + match string { + "monthly" => PriceDuration::Monthly, + "yearly" => PriceDuration::Yearly, + _ => PriceDuration::Monthly, + } + } + pub fn as_str(&self) -> &'static str { + match self { + PriceDuration::Monthly => "monthly", + PriceDuration::Yearly => "yearly", + } + } + + pub fn iterator() -> impl Iterator { + vec![PriceDuration::Monthly, PriceDuration::Yearly].into_iter() + } +} + +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct UserSubscriptionId(pub u64); + +#[derive(Serialize, Deserialize)] +pub struct UserSubscription { + pub id: UserSubscriptionId, + pub user_id: UserId, + pub price_id: ProductPriceId, + pub interval: PriceDuration, + pub status: SubscriptionStatus, + pub created: DateTime, + pub metadata: Option, +} + +impl From + for UserSubscription +{ + fn from( + x: crate::database::models::user_subscription_item::UserSubscriptionItem, + ) -> Self { + Self { + id: x.id.into(), + user_id: x.user_id.into(), + price_id: x.price_id.into(), + interval: x.interval, + status: x.status, + created: x.created, + metadata: x.metadata, + } + } +} + +#[derive(Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] +#[serde(rename_all = "kebab-case")] +pub enum SubscriptionStatus { + Provisioned, + Unprovisioned, +} + +impl SubscriptionStatus { + pub fn from_string(string: &str) -> SubscriptionStatus { + match string { + "provisioned" => SubscriptionStatus::Provisioned, + "unprovisioned" => SubscriptionStatus::Unprovisioned, + _ => SubscriptionStatus::Provisioned, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + SubscriptionStatus::Provisioned => "provisioned", + SubscriptionStatus::Unprovisioned => "unprovisioned", + } + } +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum SubscriptionMetadata { + Pyro { id: String }, +} + +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct ChargeId(pub u64); + +#[derive(Serialize, Deserialize)] +pub struct Charge { + pub id: ChargeId, + pub user_id: UserId, + pub price_id: ProductPriceId, + pub amount: i64, + pub currency_code: String, + pub status: ChargeStatus, + pub due: DateTime, + pub last_attempt: Option>, + #[serde(flatten)] + pub type_: ChargeType, + pub subscription_id: Option, + pub subscription_interval: Option, +} + +#[derive(Serialize, Deserialize)] +#[serde(tag = "type", rename_all = "kebab-case")] +pub enum ChargeType { + OneTime, + Subscription, + Proration, +} + +impl ChargeType { + pub fn as_str(&self) -> &'static str { + match self { + ChargeType::OneTime => "one-time", + ChargeType::Subscription { .. } => "subscription", + ChargeType::Proration { .. } => "proration", + } + } + + pub fn from_string(string: &str) -> ChargeType { + match string { + "one-time" => ChargeType::OneTime, + "subscription" => ChargeType::Subscription, + "proration" => ChargeType::Proration, + _ => ChargeType::OneTime, + } + } +} + +#[derive(Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] +#[serde(rename_all = "kebab-case")] +pub enum ChargeStatus { + // Open charges are for the next billing interval + Open, + Processing, + Succeeded, + Failed, + Cancelled, +} + +impl ChargeStatus { + pub fn from_string(string: &str) -> ChargeStatus { + match string { + "processing" => ChargeStatus::Processing, + "succeeded" => ChargeStatus::Succeeded, + "failed" => ChargeStatus::Failed, + "open" => ChargeStatus::Open, + "cancelled" => ChargeStatus::Cancelled, + _ => ChargeStatus::Failed, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + ChargeStatus::Processing => "processing", + ChargeStatus::Succeeded => "succeeded", + ChargeStatus::Failed => "failed", + ChargeStatus::Open => "open", + ChargeStatus::Cancelled => "cancelled", + } + } +} diff --git a/apps/labrinth/src/models/v3/collections.rs b/apps/labrinth/src/models/v3/collections.rs new file mode 100644 index 000000000..52a937bd5 --- /dev/null +++ b/apps/labrinth/src/models/v3/collections.rs @@ -0,0 +1,132 @@ +use super::{ + ids::{Base62Id, ProjectId}, + users::UserId, +}; +use crate::database; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// The ID of a specific collection, encoded as base62 for usage in the API +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct CollectionId(pub u64); + +/// A collection returned from the API +#[derive(Serialize, Deserialize, Clone)] +pub struct Collection { + /// The ID of the collection, encoded as a base62 string. + pub id: CollectionId, + /// The person that has ownership of this collection. + pub user: UserId, + /// The title or name of the collection. + pub name: String, + /// A short description of the collection. + pub description: Option, + + /// An icon URL for the collection. + pub icon_url: Option, + /// Color of the collection. + pub color: Option, + + /// The status of the collectin (eg: whether collection is public or not) + pub status: CollectionStatus, + + /// The date at which the collection was first published. + pub created: DateTime, + + /// The date at which the collection was updated. + pub updated: DateTime, + + /// A list of ProjectIds that are in this collection. + pub projects: Vec, +} + +impl From for Collection { + fn from(c: database::models::Collection) -> Self { + Self { + id: c.id.into(), + user: c.user_id.into(), + created: c.created, + name: c.name, + description: c.description, + updated: c.updated, + projects: c.projects.into_iter().map(|x| x.into()).collect(), + icon_url: c.icon_url, + color: c.color, + status: c.status, + } + } +} + +/// A status decides the visibility of a collection in search, URLs, and the whole site itself. +/// Listed - collection is displayed on search, and accessible by URL (for if/when search is implemented for collections) +/// Unlisted - collection is not displayed on search, but accessible by URL +/// Rejected - collection is disabled +#[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug)] +#[serde(rename_all = "lowercase")] +pub enum CollectionStatus { + Listed, + Unlisted, + Private, + Rejected, + Unknown, +} + +impl std::fmt::Display for CollectionStatus { + fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(fmt, "{}", self.as_str()) + } +} + +impl CollectionStatus { + pub fn from_string(string: &str) -> CollectionStatus { + match string { + "listed" => CollectionStatus::Listed, + "unlisted" => CollectionStatus::Unlisted, + "private" => CollectionStatus::Private, + "rejected" => CollectionStatus::Rejected, + _ => CollectionStatus::Unknown, + } + } + pub fn as_str(&self) -> &'static str { + match self { + CollectionStatus::Listed => "listed", + CollectionStatus::Unlisted => "unlisted", + CollectionStatus::Private => "private", + CollectionStatus::Rejected => "rejected", + CollectionStatus::Unknown => "unknown", + } + } + + // Project pages + info cannot be viewed + pub fn is_hidden(&self) -> bool { + match self { + CollectionStatus::Rejected => true, + CollectionStatus::Private => true, + CollectionStatus::Listed => false, + CollectionStatus::Unlisted => false, + CollectionStatus::Unknown => false, + } + } + + pub fn is_approved(&self) -> bool { + match self { + CollectionStatus::Listed => true, + CollectionStatus::Private => true, + CollectionStatus::Unlisted => true, + CollectionStatus::Rejected => false, + CollectionStatus::Unknown => false, + } + } + + pub fn can_be_requested(&self) -> bool { + match self { + CollectionStatus::Listed => true, + CollectionStatus::Private => true, + CollectionStatus::Unlisted => true, + CollectionStatus::Rejected => false, + CollectionStatus::Unknown => false, + } + } +} diff --git a/apps/labrinth/src/models/v3/ids.rs b/apps/labrinth/src/models/v3/ids.rs new file mode 100644 index 000000000..5a2997c80 --- /dev/null +++ b/apps/labrinth/src/models/v3/ids.rs @@ -0,0 +1,233 @@ +pub use super::collections::CollectionId; +pub use super::images::ImageId; +pub use super::notifications::NotificationId; +pub use super::oauth_clients::OAuthClientAuthorizationId; +pub use super::oauth_clients::{OAuthClientId, OAuthRedirectUriId}; +pub use super::organizations::OrganizationId; +pub use super::pats::PatId; +pub use super::payouts::PayoutId; +pub use super::projects::{ProjectId, VersionId}; +pub use super::reports::ReportId; +pub use super::sessions::SessionId; +pub use super::teams::TeamId; +pub use super::threads::ThreadId; +pub use super::threads::ThreadMessageId; +pub use super::users::UserId; +pub use crate::models::billing::{ + ChargeId, ProductId, ProductPriceId, UserSubscriptionId, +}; +use thiserror::Error; + +/// Generates a random 64 bit integer that is exactly `n` characters +/// long when encoded as base62. +/// +/// Uses `rand`'s thread rng on every call. +/// +/// # Panics +/// +/// This method panics if `n` is 0 or greater than 11, since a `u64` +/// can only represent up to 11 character base62 strings +#[inline] +pub fn random_base62(n: usize) -> u64 { + random_base62_rng(&mut rand::thread_rng(), n) +} + +/// Generates a random 64 bit integer that is exactly `n` characters +/// long when encoded as base62, using the given rng. +/// +/// # Panics +/// +/// This method panics if `n` is 0 or greater than 11, since a `u64` +/// can only represent up to 11 character base62 strings +pub fn random_base62_rng(rng: &mut R, n: usize) -> u64 { + random_base62_rng_range(rng, n, n) +} + +pub fn random_base62_rng_range( + rng: &mut R, + n_min: usize, + n_max: usize, +) -> u64 { + use rand::Rng; + assert!(n_min > 0 && n_max <= 11 && n_min <= n_max); + // gen_range is [low, high): max value is `MULTIPLES[n] - 1`, + // which is n characters long when encoded + rng.gen_range(MULTIPLES[n_min - 1]..MULTIPLES[n_max]) +} + +const MULTIPLES: [u64; 12] = [ + 1, + 62, + 62 * 62, + 62 * 62 * 62, + 62 * 62 * 62 * 62, + 62 * 62 * 62 * 62 * 62, + 62 * 62 * 62 * 62 * 62 * 62, + 62 * 62 * 62 * 62 * 62 * 62 * 62, + 62 * 62 * 62 * 62 * 62 * 62 * 62 * 62, + 62 * 62 * 62 * 62 * 62 * 62 * 62 * 62 * 62, + 62 * 62 * 62 * 62 * 62 * 62 * 62 * 62 * 62 * 62, + u64::MAX, +]; + +/// An ID encoded as base62 for use in the API. +/// +/// All ids should be random and encode to 8-10 character base62 strings, +/// to avoid enumeration and other attacks. +#[derive(Copy, Clone, PartialEq, Eq)] +pub struct Base62Id(pub u64); + +/// An error decoding a number from base62. +#[derive(Error, Debug)] +pub enum DecodingError { + /// Encountered a non-base62 character in a base62 string + #[error("Invalid character {0:?} in base62 encoding")] + InvalidBase62(char), + /// Encountered integer overflow when decoding a base62 id. + #[error("Base62 decoding overflowed")] + Overflow, +} + +macro_rules! from_base62id { + ($($struct:ty, $con:expr;)+) => { + $( + impl From for $struct { + fn from(id: Base62Id) -> $struct { + $con(id.0) + } + } + impl From<$struct> for Base62Id { + fn from(id: $struct) -> Base62Id { + Base62Id(id.0) + } + } + )+ + }; +} + +macro_rules! impl_base62_display { + ($struct:ty) => { + impl std::fmt::Display for $struct { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&base62_impl::to_base62(self.0)) + } + } + }; +} +impl_base62_display!(Base62Id); + +macro_rules! base62_id_impl { + ($struct:ty, $cons:expr) => { + from_base62id!($struct, $cons;); + impl_base62_display!($struct); + } +} +base62_id_impl!(ProjectId, ProjectId); +base62_id_impl!(UserId, UserId); +base62_id_impl!(VersionId, VersionId); +base62_id_impl!(CollectionId, CollectionId); +base62_id_impl!(TeamId, TeamId); +base62_id_impl!(OrganizationId, OrganizationId); +base62_id_impl!(ReportId, ReportId); +base62_id_impl!(NotificationId, NotificationId); +base62_id_impl!(ThreadId, ThreadId); +base62_id_impl!(ThreadMessageId, ThreadMessageId); +base62_id_impl!(SessionId, SessionId); +base62_id_impl!(PatId, PatId); +base62_id_impl!(ImageId, ImageId); +base62_id_impl!(OAuthClientId, OAuthClientId); +base62_id_impl!(OAuthRedirectUriId, OAuthRedirectUriId); +base62_id_impl!(OAuthClientAuthorizationId, OAuthClientAuthorizationId); +base62_id_impl!(PayoutId, PayoutId); +base62_id_impl!(ProductId, ProductId); +base62_id_impl!(ProductPriceId, ProductPriceId); +base62_id_impl!(UserSubscriptionId, UserSubscriptionId); +base62_id_impl!(ChargeId, ChargeId); + +pub mod base62_impl { + use serde::de::{self, Deserializer, Visitor}; + use serde::ser::Serializer; + use serde::{Deserialize, Serialize}; + + use super::{Base62Id, DecodingError}; + + impl<'de> Deserialize<'de> for Base62Id { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + struct Base62Visitor; + + impl<'de> Visitor<'de> for Base62Visitor { + type Value = Base62Id; + + fn expecting( + &self, + formatter: &mut std::fmt::Formatter, + ) -> std::fmt::Result { + formatter.write_str("a base62 string id") + } + + fn visit_str(self, string: &str) -> Result + where + E: de::Error, + { + parse_base62(string).map(Base62Id).map_err(E::custom) + } + } + + deserializer.deserialize_str(Base62Visitor) + } + } + + impl Serialize for Base62Id { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&to_base62(self.0)) + } + } + + const BASE62_CHARS: [u8; 62] = + *b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; + + pub fn to_base62(mut num: u64) -> String { + let length = (num as f64).log(62.0).ceil() as usize; + let mut output = String::with_capacity(length); + + while num > 0 { + // Could be done more efficiently, but requires byte + // manipulation of strings & Vec -> String conversion + output.insert(0, BASE62_CHARS[(num % 62) as usize] as char); + num /= 62; + } + output + } + + pub fn parse_base62(string: &str) -> Result { + let mut num: u64 = 0; + for c in string.chars() { + let next_digit; + if c.is_ascii_digit() { + next_digit = (c as u8 - b'0') as u64; + } else if c.is_ascii_uppercase() { + next_digit = 10 + (c as u8 - b'A') as u64; + } else if c.is_ascii_lowercase() { + next_digit = 36 + (c as u8 - b'a') as u64; + } else { + return Err(DecodingError::InvalidBase62(c)); + } + + // We don't want this panicking or wrapping on integer overflow + if let Some(n) = + num.checked_mul(62).and_then(|n| n.checked_add(next_digit)) + { + num = n; + } else { + return Err(DecodingError::Overflow); + } + } + Ok(num) + } +} diff --git a/apps/labrinth/src/models/v3/images.rs b/apps/labrinth/src/models/v3/images.rs new file mode 100644 index 000000000..5e814f533 --- /dev/null +++ b/apps/labrinth/src/models/v3/images.rs @@ -0,0 +1,126 @@ +use super::{ + ids::{Base62Id, ProjectId, ThreadMessageId, VersionId}, + pats::Scopes, + reports::ReportId, + users::UserId, +}; +use crate::database::models::image_item::Image as DBImage; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct ImageId(pub u64); + +#[derive(Serialize, Deserialize)] +pub struct Image { + pub id: ImageId, + pub url: String, + pub size: u64, + pub created: DateTime, + pub owner_id: UserId, + + // context it is associated with + #[serde(flatten)] + pub context: ImageContext, +} + +impl From for Image { + fn from(x: DBImage) -> Self { + let mut context = ImageContext::from_str(&x.context, None); + match &mut context { + ImageContext::Project { project_id } => { + *project_id = x.project_id.map(|x| x.into()); + } + ImageContext::Version { version_id } => { + *version_id = x.version_id.map(|x| x.into()); + } + ImageContext::ThreadMessage { thread_message_id } => { + *thread_message_id = x.thread_message_id.map(|x| x.into()); + } + ImageContext::Report { report_id } => { + *report_id = x.report_id.map(|x| x.into()); + } + ImageContext::Unknown => {} + } + + Image { + id: x.id.into(), + url: x.url, + size: x.size, + created: x.created, + owner_id: x.owner_id.into(), + context, + } + } +} + +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] +#[serde(tag = "context")] +#[serde(rename_all = "snake_case")] +pub enum ImageContext { + Project { + project_id: Option, + }, + Version { + // version changelogs + version_id: Option, + }, + ThreadMessage { + thread_message_id: Option, + }, + Report { + report_id: Option, + }, + Unknown, +} + +impl ImageContext { + pub fn context_as_str(&self) -> &'static str { + match self { + ImageContext::Project { .. } => "project", + ImageContext::Version { .. } => "version", + ImageContext::ThreadMessage { .. } => "thread_message", + ImageContext::Report { .. } => "report", + ImageContext::Unknown => "unknown", + } + } + pub fn inner_id(&self) -> Option { + match self { + ImageContext::Project { project_id } => project_id.map(|x| x.0), + ImageContext::Version { version_id } => version_id.map(|x| x.0), + ImageContext::ThreadMessage { thread_message_id } => { + thread_message_id.map(|x| x.0) + } + ImageContext::Report { report_id } => report_id.map(|x| x.0), + ImageContext::Unknown => None, + } + } + pub fn relevant_scope(&self) -> Scopes { + match self { + ImageContext::Project { .. } => Scopes::PROJECT_WRITE, + ImageContext::Version { .. } => Scopes::VERSION_WRITE, + ImageContext::ThreadMessage { .. } => Scopes::THREAD_WRITE, + ImageContext::Report { .. } => Scopes::REPORT_WRITE, + ImageContext::Unknown => Scopes::NONE, + } + } + pub fn from_str(context: &str, id: Option) -> Self { + match context { + "project" => ImageContext::Project { + project_id: id.map(ProjectId), + }, + "version" => ImageContext::Version { + version_id: id.map(VersionId), + }, + "thread_message" => ImageContext::ThreadMessage { + thread_message_id: id.map(ThreadMessageId), + }, + "report" => ImageContext::Report { + report_id: id.map(ReportId), + }, + _ => ImageContext::Unknown, + } + } +} diff --git a/apps/labrinth/src/models/v3/mod.rs b/apps/labrinth/src/models/v3/mod.rs new file mode 100644 index 000000000..d9ffb8451 --- /dev/null +++ b/apps/labrinth/src/models/v3/mod.rs @@ -0,0 +1,17 @@ +pub mod analytics; +pub mod billing; +pub mod collections; +pub mod ids; +pub mod images; +pub mod notifications; +pub mod oauth_clients; +pub mod organizations; +pub mod pack; +pub mod pats; +pub mod payouts; +pub mod projects; +pub mod reports; +pub mod sessions; +pub mod teams; +pub mod threads; +pub mod users; diff --git a/apps/labrinth/src/models/v3/notifications.rs b/apps/labrinth/src/models/v3/notifications.rs new file mode 100644 index 000000000..2d0813102 --- /dev/null +++ b/apps/labrinth/src/models/v3/notifications.rs @@ -0,0 +1,218 @@ +use super::ids::Base62Id; +use super::ids::OrganizationId; +use super::users::UserId; +use crate::database::models::notification_item::Notification as DBNotification; +use crate::database::models::notification_item::NotificationAction as DBNotificationAction; +use crate::models::ids::{ + ProjectId, ReportId, TeamId, ThreadId, ThreadMessageId, VersionId, +}; +use crate::models::projects::ProjectStatus; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct NotificationId(pub u64); + +#[derive(Serialize, Deserialize)] +pub struct Notification { + pub id: NotificationId, + pub user_id: UserId, + pub read: bool, + pub created: DateTime, + pub body: NotificationBody, + + pub name: String, + pub text: String, + pub link: String, + pub actions: Vec, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum NotificationBody { + ProjectUpdate { + project_id: ProjectId, + version_id: VersionId, + }, + TeamInvite { + project_id: ProjectId, + team_id: TeamId, + invited_by: UserId, + role: String, + }, + OrganizationInvite { + organization_id: OrganizationId, + invited_by: UserId, + team_id: TeamId, + role: String, + }, + StatusChange { + project_id: ProjectId, + old_status: ProjectStatus, + new_status: ProjectStatus, + }, + ModeratorMessage { + thread_id: ThreadId, + message_id: ThreadMessageId, + + project_id: Option, + report_id: Option, + }, + LegacyMarkdown { + notification_type: Option, + name: String, + text: String, + link: String, + actions: Vec, + }, + Unknown, +} + +impl From for Notification { + fn from(notif: DBNotification) -> Self { + let (name, text, link, actions) = { + match ¬if.body { + NotificationBody::ProjectUpdate { + project_id, + version_id, + } => ( + "A project you follow has been updated!".to_string(), + format!( + "The project {} has released a new version: {}", + project_id, version_id + ), + format!("/project/{}/version/{}", project_id, version_id), + vec![], + ), + NotificationBody::TeamInvite { + project_id, + role, + team_id, + .. + } => ( + "You have been invited to join a team!".to_string(), + format!("An invite has been sent for you to be {} of a team", role), + format!("/project/{}", project_id), + vec![ + NotificationAction { + name: "Accept".to_string(), + action_route: ("POST".to_string(), format!("team/{team_id}/join")), + }, + NotificationAction { + name: "Deny".to_string(), + action_route: ( + "DELETE".to_string(), + format!("team/{team_id}/members/{}", UserId::from(notif.user_id)), + ), + }, + ], + ), + NotificationBody::OrganizationInvite { + organization_id, + role, + team_id, + .. + } => ( + "You have been invited to join an organization!".to_string(), + format!( + "An invite has been sent for you to be {} of an organization", + role + ), + format!("/organization/{}", organization_id), + vec![ + NotificationAction { + name: "Accept".to_string(), + action_route: ("POST".to_string(), format!("team/{team_id}/join")), + }, + NotificationAction { + name: "Deny".to_string(), + action_route: ( + "DELETE".to_string(), + format!( + "organization/{organization_id}/members/{}", + UserId::from(notif.user_id) + ), + ), + }, + ], + ), + NotificationBody::StatusChange { + old_status, + new_status, + project_id, + } => ( + "Project status has changed".to_string(), + format!( + "Status has changed from {} to {}", + old_status.as_friendly_str(), + new_status.as_friendly_str() + ), + format!("/project/{}", project_id), + vec![], + ), + NotificationBody::ModeratorMessage { + project_id, + report_id, + .. + } => ( + "A moderator has sent you a message!".to_string(), + "Click on the link to read more.".to_string(), + if let Some(project_id) = project_id { + format!("/project/{}", project_id) + } else if let Some(report_id) = report_id { + format!("/project/{}", report_id) + } else { + "#".to_string() + }, + vec![], + ), + NotificationBody::LegacyMarkdown { + name, + text, + link, + actions, + .. + } => ( + name.clone(), + text.clone(), + link.clone(), + actions.clone().into_iter().map(Into::into).collect(), + ), + NotificationBody::Unknown => { + ("".to_string(), "".to_string(), "#".to_string(), vec![]) + } + } + }; + + Self { + id: notif.id.into(), + user_id: notif.user_id.into(), + body: notif.body, + read: notif.read, + created: notif.created, + + name, + text, + link, + actions, + } + } +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct NotificationAction { + pub name: String, + /// The route to call when this notification action is called. Formatted HTTP Method, route + pub action_route: (String, String), +} + +impl From for NotificationAction { + fn from(act: DBNotificationAction) -> Self { + Self { + name: act.name, + action_route: (act.action_route_method, act.action_route), + } + } +} diff --git a/apps/labrinth/src/models/v3/oauth_clients.rs b/apps/labrinth/src/models/v3/oauth_clients.rs new file mode 100644 index 000000000..73f1ae861 --- /dev/null +++ b/apps/labrinth/src/models/v3/oauth_clients.rs @@ -0,0 +1,129 @@ +use super::{ + ids::{Base62Id, UserId}, + pats::Scopes, +}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use serde_with::serde_as; + +use crate::database::models::oauth_client_authorization_item::OAuthClientAuthorization as DBOAuthClientAuthorization; +use crate::database::models::oauth_client_item::OAuthClient as DBOAuthClient; +use crate::database::models::oauth_client_item::OAuthRedirectUri as DBOAuthRedirectUri; + +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct OAuthClientId(pub u64); + +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct OAuthClientAuthorizationId(pub u64); + +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct OAuthRedirectUriId(pub u64); + +#[derive(Deserialize, Serialize)] +pub struct OAuthRedirectUri { + pub id: OAuthRedirectUriId, + pub client_id: OAuthClientId, + pub uri: String, +} + +#[derive(Serialize, Deserialize)] +pub struct OAuthClientCreationResult { + #[serde(flatten)] + pub client: OAuthClient, + + pub client_secret: String, +} + +#[derive(Deserialize, Serialize)] +pub struct OAuthClient { + pub id: OAuthClientId, + pub name: String, + pub icon_url: Option, + + // The maximum scopes the client can request for OAuth + pub max_scopes: Scopes, + + // The valid URIs that can be redirected to during an authorization request + pub redirect_uris: Vec, + + // The user that created (and thus controls) this client + pub created_by: UserId, + + // When this client was created + pub created: DateTime, + + // (optional) Metadata about the client + pub url: Option, + pub description: Option, +} + +#[derive(Deserialize, Serialize)] +pub struct OAuthClientAuthorization { + pub id: OAuthClientAuthorizationId, + pub app_id: OAuthClientId, + pub user_id: UserId, + pub scopes: Scopes, + pub created: DateTime, +} + +#[serde_as] +#[derive(Deserialize, Serialize)] +pub struct GetOAuthClientsRequest { + #[serde_as( + as = "serde_with::StringWithSeparator::" + )] + pub ids: Vec, +} + +#[derive(Deserialize, Serialize)] +pub struct DeleteOAuthClientQueryParam { + pub client_id: OAuthClientId, +} + +impl From for OAuthClient { + fn from(value: DBOAuthClient) -> Self { + Self { + id: value.id.into(), + name: value.name, + icon_url: value.icon_url, + max_scopes: value.max_scopes, + redirect_uris: value + .redirect_uris + .into_iter() + .map(|r| r.into()) + .collect(), + created_by: value.created_by.into(), + created: value.created, + url: value.url, + description: value.description, + } + } +} + +impl From for OAuthRedirectUri { + fn from(value: DBOAuthRedirectUri) -> Self { + Self { + id: value.id.into(), + client_id: value.client_id.into(), + uri: value.uri, + } + } +} + +impl From for OAuthClientAuthorization { + fn from(value: DBOAuthClientAuthorization) -> Self { + Self { + id: value.id.into(), + app_id: value.client_id.into(), + user_id: value.user_id.into(), + scopes: value.scopes, + created: value.created, + } + } +} diff --git a/apps/labrinth/src/models/v3/organizations.rs b/apps/labrinth/src/models/v3/organizations.rs new file mode 100644 index 000000000..f2817e36d --- /dev/null +++ b/apps/labrinth/src/models/v3/organizations.rs @@ -0,0 +1,52 @@ +use super::{ + ids::{Base62Id, TeamId}, + teams::TeamMember, +}; +use serde::{Deserialize, Serialize}; + +/// The ID of a team +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, Debug)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct OrganizationId(pub u64); + +/// An organization of users who control a project +#[derive(Serialize, Deserialize)] +pub struct Organization { + /// The id of the organization + pub id: OrganizationId, + /// The slug of the organization + pub slug: String, + /// The title of the organization + pub name: String, + /// The associated team of the organization + pub team_id: TeamId, + /// The description of the organization + pub description: String, + + /// The icon url of the organization + pub icon_url: Option, + /// The color of the organization (picked from the icon) + pub color: Option, + + /// A list of the members of the organization + pub members: Vec, +} + +impl Organization { + pub fn from( + data: crate::database::models::organization_item::Organization, + team_members: Vec, + ) -> Self { + Self { + id: data.id.into(), + slug: data.slug, + name: data.name, + team_id: data.team_id.into(), + description: data.description, + members: team_members, + icon_url: data.icon_url, + color: data.color, + } + } +} diff --git a/apps/labrinth/src/models/v3/pack.rs b/apps/labrinth/src/models/v3/pack.rs new file mode 100644 index 000000000..045721845 --- /dev/null +++ b/apps/labrinth/src/models/v3/pack.rs @@ -0,0 +1,114 @@ +use crate::{ + models::v2::projects::LegacySideType, util::env::parse_strings_from_var, +}; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +#[derive(Serialize, Deserialize, Validate, Eq, PartialEq, Debug)] +#[serde(rename_all = "camelCase")] +pub struct PackFormat { + pub game: String, + pub format_version: i32, + #[validate(length(min = 1, max = 512))] + pub version_id: String, + #[validate(length(min = 1, max = 512))] + pub name: String, + #[validate(length(max = 2048))] + pub summary: Option, + #[validate] + pub files: Vec, + pub dependencies: std::collections::HashMap, +} + +#[derive(Serialize, Deserialize, Validate, Eq, PartialEq, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub struct PackFile { + pub path: String, + pub hashes: std::collections::HashMap, + pub env: Option>, // TODO: Should this use LegacySideType? Will probably require a overhaul of mrpack format to change this + #[validate(custom(function = "validate_download_url"))] + pub downloads: Vec, + pub file_size: u32, +} + +fn validate_download_url( + values: &[String], +) -> Result<(), validator::ValidationError> { + for value in values { + let url = url::Url::parse(value) + .ok() + .ok_or_else(|| validator::ValidationError::new("invalid URL"))?; + + if url.as_str() != value { + return Err(validator::ValidationError::new("invalid URL")); + } + + let domains = parse_strings_from_var("WHITELISTED_MODPACK_DOMAINS") + .unwrap_or_default(); + if !domains.contains( + &url.domain() + .ok_or_else(|| validator::ValidationError::new("invalid URL"))? + .to_string(), + ) { + return Err(validator::ValidationError::new( + "File download source is not from allowed sources", + )); + } + } + + Ok(()) +} + +#[derive(Serialize, Deserialize, Eq, PartialEq, Hash, Debug, Clone)] +#[serde(rename_all = "camelCase", from = "String")] +pub enum PackFileHash { + Sha1, + Sha512, + Unknown(String), +} + +impl From for PackFileHash { + fn from(s: String) -> Self { + return match s.as_str() { + "sha1" => PackFileHash::Sha1, + "sha512" => PackFileHash::Sha512, + _ => PackFileHash::Unknown(s), + }; + } +} + +#[derive(Serialize, Deserialize, Eq, PartialEq, Hash, Debug, Clone)] +#[serde(rename_all = "camelCase")] +pub enum EnvType { + Client, + Server, +} + +#[derive(Serialize, Deserialize, Clone, Hash, PartialEq, Eq, Debug)] +#[serde(rename_all = "kebab-case")] +pub enum PackDependency { + Forge, + Neoforge, + FabricLoader, + QuiltLoader, + Minecraft, +} + +impl std::fmt::Display for PackDependency { + fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { + fmt.write_str(self.as_str()) + } +} + +impl PackDependency { + // These are constant, so this can remove unnecessary allocations (`to_string`) + pub fn as_str(&self) -> &'static str { + match self { + PackDependency::Forge => "forge", + PackDependency::Neoforge => "neoforge", + PackDependency::FabricLoader => "fabric-loader", + PackDependency::Minecraft => "minecraft", + PackDependency::QuiltLoader => "quilt-loader", + } + } +} diff --git a/apps/labrinth/src/models/v3/pats.rs b/apps/labrinth/src/models/v3/pats.rs new file mode 100644 index 000000000..118db66b2 --- /dev/null +++ b/apps/labrinth/src/models/v3/pats.rs @@ -0,0 +1,246 @@ +use super::ids::Base62Id; +use crate::bitflags_serde_impl; +use crate::models::ids::UserId; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +/// The ID of a team +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, Debug)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct PatId(pub u64); + +bitflags::bitflags! { + #[derive(Copy, Clone, Debug)] + pub struct Scopes: u64 { + // read a user's email + const USER_READ_EMAIL = 1 << 0; + // read a user's data + const USER_READ = 1 << 1; + // write to a user's profile (edit username, email, avatar, follows, etc) + const USER_WRITE = 1 << 2; + // delete a user + const USER_DELETE = 1 << 3; + // modify a user's authentication data + const USER_AUTH_WRITE = 1 << 4; + + // read a user's notifications + const NOTIFICATION_READ = 1 << 5; + // delete or read a notification + const NOTIFICATION_WRITE = 1 << 6; + + // read a user's payouts data + const PAYOUTS_READ = 1 << 7; + // withdraw money from a user's account + const PAYOUTS_WRITE = 1<< 8; + // access user analytics (payout analytics at the moment) + const ANALYTICS = 1 << 9; + + // create a project + const PROJECT_CREATE = 1 << 10; + // read a user's projects (including private) + const PROJECT_READ = 1 << 11; + // write to a project's data (metadata, title, team members, etc) + const PROJECT_WRITE = 1 << 12; + // delete a project + const PROJECT_DELETE = 1 << 13; + + // create a version + const VERSION_CREATE = 1 << 14; + // read a user's versions (including private) + const VERSION_READ = 1 << 15; + // write to a version's data (metadata, files, etc) + const VERSION_WRITE = 1 << 16; + // delete a version + const VERSION_DELETE = 1 << 17; + + // create a report + const REPORT_CREATE = 1 << 18; + // read a user's reports + const REPORT_READ = 1 << 19; + // edit a report + const REPORT_WRITE = 1 << 20; + // delete a report + const REPORT_DELETE = 1 << 21; + + // read a thread + const THREAD_READ = 1 << 22; + // write to a thread (send a message, delete a message) + const THREAD_WRITE = 1 << 23; + + // create a pat + const PAT_CREATE = 1 << 24; + // read a user's pats + const PAT_READ = 1 << 25; + // edit a pat + const PAT_WRITE = 1 << 26; + // delete a pat + const PAT_DELETE = 1 << 27; + + // read a user's sessions + const SESSION_READ = 1 << 28; + // delete a session + const SESSION_DELETE = 1 << 29; + + // perform analytics action + const PERFORM_ANALYTICS = 1 << 30; + + // create a collection + const COLLECTION_CREATE = 1 << 31; + // read a user's collections + const COLLECTION_READ = 1 << 32; + // write to a collection + const COLLECTION_WRITE = 1 << 33; + // delete a collection + const COLLECTION_DELETE = 1 << 34; + + // create an organization + const ORGANIZATION_CREATE = 1 << 35; + // read a user's organizations + const ORGANIZATION_READ = 1 << 36; + // write to an organization + const ORGANIZATION_WRITE = 1 << 37; + // delete an organization + const ORGANIZATION_DELETE = 1 << 38; + + // only accessible by modrinth-issued sessions + const SESSION_ACCESS = 1 << 39; + + const NONE = 0b0; + } +} + +bitflags_serde_impl!(Scopes, u64); + +impl Scopes { + // these scopes cannot be specified in a personal access token + pub fn restricted() -> Scopes { + Scopes::PAT_CREATE + | Scopes::PAT_READ + | Scopes::PAT_WRITE + | Scopes::PAT_DELETE + | Scopes::SESSION_READ + | Scopes::SESSION_DELETE + | Scopes::SESSION_ACCESS + | Scopes::USER_AUTH_WRITE + | Scopes::USER_DELETE + | Scopes::PERFORM_ANALYTICS + } + + pub fn is_restricted(&self) -> bool { + self.intersects(Self::restricted()) + } + + pub fn parse_from_oauth_scopes( + scopes: &str, + ) -> Result { + let scopes = scopes.replace(['+', ' '], "|").replace("%20", "|"); + bitflags::parser::from_str(&scopes) + } + + pub fn to_postgres(&self) -> i64 { + self.bits() as i64 + } + + pub fn from_postgres(value: i64) -> Self { + Self::from_bits(value as u64).unwrap_or(Scopes::NONE) + } +} + +#[derive(Serialize, Deserialize)] +pub struct PersonalAccessToken { + pub id: PatId, + pub name: String, + pub access_token: Option, + pub scopes: Scopes, + pub user_id: UserId, + pub created: DateTime, + pub expires: DateTime, + pub last_used: Option>, +} + +impl PersonalAccessToken { + pub fn from( + data: crate::database::models::pat_item::PersonalAccessToken, + include_token: bool, + ) -> Self { + Self { + id: data.id.into(), + name: data.name, + access_token: if include_token { + Some(data.access_token) + } else { + None + }, + scopes: data.scopes, + user_id: data.user_id.into(), + created: data.created, + expires: data.expires, + last_used: data.last_used, + } + } +} + +#[cfg(test)] +mod test { + use super::*; + use itertools::Itertools; + + #[test] + fn test_parse_from_oauth_scopes_well_formed() { + let raw = "USER_READ_EMAIL SESSION_READ ORGANIZATION_CREATE"; + let expected = Scopes::USER_READ_EMAIL + | Scopes::SESSION_READ + | Scopes::ORGANIZATION_CREATE; + + let parsed = Scopes::parse_from_oauth_scopes(raw).unwrap(); + + assert_same_flags(expected, parsed); + } + + #[test] + fn test_parse_from_oauth_scopes_empty() { + let raw = ""; + let expected = Scopes::empty(); + + let parsed = Scopes::parse_from_oauth_scopes(raw).unwrap(); + + assert_same_flags(expected, parsed); + } + + #[test] + fn test_parse_from_oauth_scopes_invalid_scopes() { + let raw = "notascope"; + + let parsed = Scopes::parse_from_oauth_scopes(raw); + + assert!(parsed.is_err()); + } + + #[test] + fn test_parse_from_oauth_scopes_invalid_separator() { + let raw = "USER_READ_EMAIL & SESSION_READ"; + + let parsed = Scopes::parse_from_oauth_scopes(raw); + + assert!(parsed.is_err()); + } + + #[test] + fn test_parse_from_oauth_scopes_url_encoded() { + let raw = + urlencoding::encode("PAT_WRITE COLLECTION_DELETE").to_string(); + let expected = Scopes::PAT_WRITE | Scopes::COLLECTION_DELETE; + + let parsed = Scopes::parse_from_oauth_scopes(&raw).unwrap(); + + assert_same_flags(expected, parsed); + } + + fn assert_same_flags(expected: Scopes, actual: Scopes) { + assert_eq!( + expected.iter_names().map(|(name, _)| name).collect_vec(), + actual.iter_names().map(|(name, _)| name).collect_vec() + ); + } +} diff --git a/apps/labrinth/src/models/v3/payouts.rs b/apps/labrinth/src/models/v3/payouts.rs new file mode 100644 index 000000000..ba4b6310f --- /dev/null +++ b/apps/labrinth/src/models/v3/payouts.rs @@ -0,0 +1,176 @@ +use crate::models::ids::{Base62Id, UserId}; +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct PayoutId(pub u64); + +#[derive(Serialize, Deserialize, Clone)] +pub struct Payout { + pub id: PayoutId, + pub user_id: UserId, + pub status: PayoutStatus, + pub created: DateTime, + #[serde(with = "rust_decimal::serde::float")] + pub amount: Decimal, + + #[serde(with = "rust_decimal::serde::float_option")] + pub fee: Option, + pub method: Option, + /// the address this payout was sent to: ex: email, paypal email, venmo handle + pub method_address: Option, + pub platform_id: Option, +} + +impl Payout { + pub fn from(data: crate::database::models::payout_item::Payout) -> Self { + Self { + id: data.id.into(), + user_id: data.user_id.into(), + status: data.status, + created: data.created, + amount: data.amount, + fee: data.fee, + method: data.method, + method_address: data.method_address, + platform_id: data.platform_id, + } + } +} + +#[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug)] +#[serde(rename_all = "lowercase")] +pub enum PayoutMethodType { + Venmo, + PayPal, + Tremendous, + Unknown, +} + +impl std::fmt::Display for PayoutMethodType { + fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(fmt, "{}", self.as_str()) + } +} + +impl PayoutMethodType { + pub fn as_str(&self) -> &'static str { + match self { + PayoutMethodType::Venmo => "venmo", + PayoutMethodType::PayPal => "paypal", + PayoutMethodType::Tremendous => "tremendous", + PayoutMethodType::Unknown => "unknown", + } + } + + pub fn from_string(string: &str) -> PayoutMethodType { + match string { + "venmo" => PayoutMethodType::Venmo, + "paypal" => PayoutMethodType::PayPal, + "tremendous" => PayoutMethodType::Tremendous, + _ => PayoutMethodType::Unknown, + } + } +} + +#[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug)] +#[serde(rename_all = "kebab-case")] +pub enum PayoutStatus { + Success, + InTransit, + Cancelled, + Cancelling, + Failed, + Unknown, +} + +impl std::fmt::Display for PayoutStatus { + fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(fmt, "{}", self.as_str()) + } +} + +impl PayoutStatus { + pub fn as_str(&self) -> &'static str { + match self { + PayoutStatus::Success => "success", + PayoutStatus::InTransit => "in-transit", + PayoutStatus::Cancelled => "cancelled", + PayoutStatus::Cancelling => "cancelling", + PayoutStatus::Failed => "failed", + PayoutStatus::Unknown => "unknown", + } + } + + pub fn from_string(string: &str) -> PayoutStatus { + match string { + "success" => PayoutStatus::Success, + "in-transit" => PayoutStatus::InTransit, + "cancelled" => PayoutStatus::Cancelled, + "cancelling" => PayoutStatus::Cancelling, + "failed" => PayoutStatus::Failed, + _ => PayoutStatus::Unknown, + } + } +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct PayoutMethod { + pub id: String, + #[serde(rename = "type")] + pub type_: PayoutMethodType, + pub name: String, + pub supported_countries: Vec, + pub image_url: Option, + pub interval: PayoutInterval, + pub fee: PayoutMethodFee, +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct PayoutMethodFee { + #[serde(with = "rust_decimal::serde::float")] + pub percentage: Decimal, + #[serde(with = "rust_decimal::serde::float")] + pub min: Decimal, + #[serde(with = "rust_decimal::serde::float_option")] + pub max: Option, +} + +#[derive(Clone)] +pub struct PayoutDecimal(pub Decimal); + +impl Serialize for PayoutDecimal { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + rust_decimal::serde::float::serialize(&self.0, serializer) + } +} + +impl<'de> Deserialize<'de> for PayoutDecimal { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let decimal = rust_decimal::serde::float::deserialize(deserializer)?; + Ok(PayoutDecimal(decimal)) + } +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "snake_case")] +pub enum PayoutInterval { + Standard { + #[serde(with = "rust_decimal::serde::float")] + min: Decimal, + #[serde(with = "rust_decimal::serde::float")] + max: Decimal, + }, + Fixed { + values: Vec, + }, +} diff --git a/apps/labrinth/src/models/v3/projects.rs b/apps/labrinth/src/models/v3/projects.rs new file mode 100644 index 000000000..a6f3c8a2f --- /dev/null +++ b/apps/labrinth/src/models/v3/projects.rs @@ -0,0 +1,970 @@ +use std::collections::{HashMap, HashSet}; + +use super::ids::{Base62Id, OrganizationId}; +use super::teams::TeamId; +use super::users::UserId; +use crate::database::models::loader_fields::VersionField; +use crate::database::models::project_item::{LinkUrl, QueryProject}; +use crate::database::models::version_item::QueryVersion; +use crate::models::threads::ThreadId; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use validator::Validate; + +/// The ID of a specific project, encoded as base62 for usage in the API +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug, Hash)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct ProjectId(pub u64); + +/// The ID of a specific version of a project +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, Debug)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct VersionId(pub u64); + +/// A project returned from the API +#[derive(Serialize, Deserialize, Clone)] +pub struct Project { + /// The ID of the project, encoded as a base62 string. + pub id: ProjectId, + /// The slug of a project, used for vanity URLs + pub slug: Option, + /// The aggregated project typs of the versions of this project + pub project_types: Vec, + /// The aggregated games of the versions of this project + pub games: Vec, + /// The team of people that has ownership of this project. + pub team_id: TeamId, + /// The optional organization of people that have ownership of this project. + pub organization: Option, + /// The title or name of the project. + pub name: String, + /// A short description of the project. + pub summary: String, + /// A long form description of the project. + pub description: String, + + /// The date at which the project was first published. + pub published: DateTime, + + /// The date at which the project was first published. + pub updated: DateTime, + + /// The date at which the project was first approved. + //pub approved: Option>, + pub approved: Option>, + /// The date at which the project entered the moderation queue + pub queued: Option>, + + /// The status of the project + pub status: ProjectStatus, + /// The requested status of this projct + pub requested_status: Option, + + /// DEPRECATED: moved to threads system + /// The rejection data of the project + pub moderator_message: Option, + + /// The license of this project + pub license: License, + + /// The total number of downloads the project has had. + pub downloads: u32, + /// The total number of followers this project has accumulated + pub followers: u32, + + /// A list of the categories that the project is in. + pub categories: Vec, + + /// A list of the categories that the project is in. + pub additional_categories: Vec, + /// A list of loaders this project supports + pub loaders: Vec, + + /// A list of ids for versions of the project. + pub versions: Vec, + /// The URL of the icon of the project + pub icon_url: Option, + + /// A collection of links to the project's various pages. + pub link_urls: HashMap, + + /// A string of URLs to visual content featuring the project + pub gallery: Vec, + + /// The color of the project (picked from icon) + pub color: Option, + + /// The thread of the moderation messages of the project + pub thread_id: ThreadId, + + /// The monetization status of this project + pub monetization_status: MonetizationStatus, + + /// Aggregated loader-fields across its myriad of versions + #[serde(flatten)] + pub fields: HashMap>, +} + +fn remove_duplicates(values: Vec) -> Vec { + let mut seen = HashSet::new(); + values + .into_iter() + .filter(|value| { + // Convert the JSON value to a string for comparison + let as_string = value.to_string(); + // Check if the string is already in the set + seen.insert(as_string) + }) + .collect() +} + +// This is a helper function to convert a list of VersionFields into a HashMap of field name to vecs of values +// This allows for removal of duplicates +pub fn from_duplicate_version_fields( + version_fields: Vec, +) -> HashMap> { + let mut fields: HashMap> = HashMap::new(); + for vf in version_fields { + // We use a string directly, so we can remove duplicates + let serialized = if let Some(inner_array) = + vf.value.serialize_internal().as_array() + { + inner_array.clone() + } else { + vec![vf.value.serialize_internal()] + }; + + // Create array if doesnt exist, otherwise push, or if json is an array, extend + if let Some(arr) = fields.get_mut(&vf.field_name) { + arr.extend(serialized); + } else { + fields.insert(vf.field_name, serialized); + } + } + + // Remove duplicates by converting to string and back + for (_, v) in fields.iter_mut() { + *v = remove_duplicates(v.clone()); + } + fields +} + +impl From for Project { + fn from(data: QueryProject) -> Self { + let fields = + from_duplicate_version_fields(data.aggregate_version_fields); + let m = data.inner; + Self { + id: m.id.into(), + slug: m.slug, + project_types: data.project_types, + games: data.games, + team_id: m.team_id.into(), + organization: m.organization_id.map(|i| i.into()), + name: m.name, + summary: m.summary, + description: m.description, + published: m.published, + updated: m.updated, + approved: m.approved, + queued: m.queued, + status: m.status, + requested_status: m.requested_status, + moderator_message: if let Some(message) = m.moderation_message { + Some(ModeratorMessage { + message, + body: m.moderation_message_body, + }) + } else { + None + }, + license: License { + id: m.license.clone(), + name: match spdx::Expression::parse(&m.license) { + Ok(spdx_expr) => { + let mut vec: Vec<&str> = Vec::new(); + for node in spdx_expr.iter() { + if let spdx::expression::ExprNode::Req(req) = node { + if let Some(id) = req.req.license.id() { + vec.push(id.full_name); + } + } + } + // spdx crate returns AND/OR operations in postfix order + // and it would be a lot more effort to make it actually in order + // so let's just ignore that and make them comma-separated + vec.join(", ") + } + Err(_) => "".to_string(), + }, + url: m.license_url, + }, + downloads: m.downloads as u32, + followers: m.follows as u32, + categories: data.categories, + additional_categories: data.additional_categories, + loaders: m.loaders, + versions: data.versions.into_iter().map(|v| v.into()).collect(), + icon_url: m.icon_url, + link_urls: data + .urls + .into_iter() + .map(|d| (d.platform_name.clone(), Link::from(d))) + .collect(), + gallery: data + .gallery_items + .into_iter() + .map(|x| GalleryItem { + url: x.image_url, + raw_url: x.raw_image_url, + featured: x.featured, + name: x.name, + description: x.description, + created: x.created, + ordering: x.ordering, + }) + .collect(), + color: m.color, + thread_id: data.thread_id.into(), + monetization_status: m.monetization_status, + fields, + } + } +} + +impl Project { + // Matches the from QueryProject, but with a ResultSearchProject + // pub fn from_search(m: ResultSearchProject) -> Option { + // let project_id = ProjectId(parse_base62(&m.project_id).ok()?); + // let team_id = TeamId(parse_base62(&m.team_id).ok()?); + // let organization_id = m + // .organization_id + // .and_then(|id| Some(OrganizationId(parse_base62(&id).ok()?))); + // let thread_id = ThreadId(parse_base62(&m.thread_id).ok()?); + // let versions = m + // .versions + // .iter() + // .filter_map(|id| Some(VersionId(parse_base62(id).ok()?))) + // .collect(); + // + // let approved = DateTime::parse_from_rfc3339(&m.date_created).ok()?; + // let published = DateTime::parse_from_rfc3339(&m.date_published).ok()?.into(); + // let approved = if approved == published { + // None + // } else { + // Some(approved.into()) + // }; + // + // let updated = DateTime::parse_from_rfc3339(&m.date_modified).ok()?.into(); + // let queued = m + // .date_queued + // .and_then(|dq| DateTime::parse_from_rfc3339(&dq).ok()) + // .map(|d| d.into()); + // + // let status = ProjectStatus::from_string(&m.status); + // let requested_status = m + // .requested_status + // .map(|mrs| ProjectStatus::from_string(&mrs)); + // + // let license_url = m.license_url; + // let icon_url = m.icon_url; + // + // // Loaders + // let mut loaders = m.loaders; + // let mrpack_loaders_strings = + // m.project_loader_fields + // .get("mrpack_loaders") + // .cloned() + // .map(|v| { + // v.into_iter() + // .filter_map(|v| v.as_str().map(String::from)) + // .collect_vec() + // }); + // + // // If the project has a mrpack loader, keep only 'loaders' that are not in the mrpack_loaders + // if let Some(ref mrpack_loaders) = mrpack_loaders_strings { + // loaders.retain(|l| !mrpack_loaders.contains(l)); + // } + // + // // Categories + // let mut categories = m.display_categories.clone(); + // categories.retain(|c| !loaders.contains(c)); + // if let Some(ref mrpack_loaders) = mrpack_loaders_strings { + // categories.retain(|l| !mrpack_loaders.contains(l)); + // } + // + // // Additional categories + // let mut additional_categories = m.categories.clone(); + // additional_categories.retain(|c| !categories.contains(c)); + // additional_categories.retain(|c| !loaders.contains(c)); + // if let Some(ref mrpack_loaders) = mrpack_loaders_strings { + // additional_categories.retain(|l| !mrpack_loaders.contains(l)); + // } + // + // let games = m.games; + // + // let monetization_status = m + // .monetization_status + // .as_deref() + // .map(MonetizationStatus::from_string) + // .unwrap_or(MonetizationStatus::Monetized); + // + // let link_urls = m + // .links + // .into_iter() + // .map(|d| (d.platform_name.clone(), Link::from(d))) + // .collect(); + // + // let gallery = m + // .gallery_items + // .into_iter() + // .map(|x| GalleryItem { + // url: x.image_url, + // featured: x.featured, + // name: x.name, + // description: x.description, + // created: x.created, + // ordering: x.ordering, + // }) + // .collect(); + // + // Some(Self { + // id: project_id, + // slug: m.slug, + // project_types: m.project_types, + // games, + // team_id, + // organization: organization_id, + // name: m.name, + // summary: m.summary, + // description: "".to_string(), // Body is potentially huge, do not store in search + // published, + // updated, + // approved, + // queued, + // status, + // requested_status, + // moderator_message: None, // Deprecated + // license: License { + // id: m.license.clone(), + // name: match spdx::Expression::parse(&m.license) { + // Ok(spdx_expr) => { + // let mut vec: Vec<&str> = Vec::new(); + // for node in spdx_expr.iter() { + // if let spdx::expression::ExprNode::Req(req) = node { + // if let Some(id) = req.req.license.id() { + // vec.push(id.full_name); + // } + // } + // } + // // spdx crate returns AND/OR operations in postfix order + // // and it would be a lot more effort to make it actually in order + // // so let's just ignore that and make them comma-separated + // vec.join(", ") + // } + // Err(_) => "".to_string(), + // }, + // url: license_url, + // }, + // downloads: m.downloads as u32, + // followers: m.follows as u32, + // categories, + // additional_categories, + // loaders, + // versions, + // icon_url, + // link_urls, + // gallery, + // color: m.color, + // thread_id, + // monetization_status, + // fields: m + // .project_loader_fields + // .into_iter() + // .map(|(k, v)| (k, v.into_iter().collect())) + // .collect(), + // }) + // } +} +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct GalleryItem { + pub url: String, + pub raw_url: String, + pub featured: bool, + pub name: Option, + pub description: Option, + pub created: DateTime, + pub ordering: i64, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct ModeratorMessage { + pub message: String, + pub body: Option, +} + +pub const DEFAULT_LICENSE_ID: &str = "LicenseRef-All-Rights-Reserved"; + +#[derive(Serialize, Deserialize, Clone)] +pub struct License { + pub id: String, + pub name: String, + pub url: Option, +} + +#[derive(Serialize, Deserialize, Validate, Clone, Eq, PartialEq)] +pub struct Link { + pub platform: String, + pub donation: bool, + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + pub url: String, +} +impl From for Link { + fn from(data: LinkUrl) -> Self { + Self { + platform: data.platform_name, + donation: data.donation, + url: data.url, + } + } +} + +/// A status decides the visibility of a project in search, URLs, and the whole site itself. +/// Approved - Project is displayed on search, and accessible by URL +/// Rejected - Project is not displayed on search, and not accessible by URL (Temporary state, project can reapply) +/// Draft - Project is not displayed on search, and not accessible by URL +/// Unlisted - Project is not displayed on search, but accessible by URL +/// Withheld - Same as unlisted, but set by a moderator. Cannot be switched to another type without moderator approval +/// Processing - Project is not displayed on search, and not accessible by URL (Temporary state, project under review) +/// Scheduled - Project is scheduled to be released in the future +/// Private - Project is approved, but is not viewable to the public +#[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug)] +#[serde(rename_all = "lowercase")] +pub enum ProjectStatus { + Approved, + Archived, + Rejected, + Draft, + Unlisted, + Processing, + Withheld, + Scheduled, + Private, + Unknown, +} + +impl std::fmt::Display for ProjectStatus { + fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(fmt, "{}", self.as_str()) + } +} + +impl ProjectStatus { + pub fn from_string(string: &str) -> ProjectStatus { + match string { + "processing" => ProjectStatus::Processing, + "rejected" => ProjectStatus::Rejected, + "approved" => ProjectStatus::Approved, + "draft" => ProjectStatus::Draft, + "unlisted" => ProjectStatus::Unlisted, + "archived" => ProjectStatus::Archived, + "withheld" => ProjectStatus::Withheld, + "private" => ProjectStatus::Private, + _ => ProjectStatus::Unknown, + } + } + pub fn as_str(&self) -> &'static str { + match self { + ProjectStatus::Approved => "approved", + ProjectStatus::Rejected => "rejected", + ProjectStatus::Draft => "draft", + ProjectStatus::Unlisted => "unlisted", + ProjectStatus::Processing => "processing", + ProjectStatus::Unknown => "unknown", + ProjectStatus::Archived => "archived", + ProjectStatus::Withheld => "withheld", + ProjectStatus::Scheduled => "scheduled", + ProjectStatus::Private => "private", + } + } + pub fn as_friendly_str(&self) -> &'static str { + match self { + ProjectStatus::Approved => "Listed", + ProjectStatus::Rejected => "Rejected", + ProjectStatus::Draft => "Draft", + ProjectStatus::Unlisted => "Unlisted", + ProjectStatus::Processing => "Under review", + ProjectStatus::Unknown => "Unknown", + ProjectStatus::Archived => "Archived", + ProjectStatus::Withheld => "Withheld", + ProjectStatus::Scheduled => "Scheduled", + ProjectStatus::Private => "Private", + } + } + + pub fn iterator() -> impl Iterator { + [ + ProjectStatus::Approved, + ProjectStatus::Archived, + ProjectStatus::Rejected, + ProjectStatus::Draft, + ProjectStatus::Unlisted, + ProjectStatus::Processing, + ProjectStatus::Withheld, + ProjectStatus::Scheduled, + ProjectStatus::Private, + ProjectStatus::Unknown, + ] + .iter() + .copied() + } + + // Project pages + info cannot be viewed + pub fn is_hidden(&self) -> bool { + match self { + ProjectStatus::Rejected => true, + ProjectStatus::Draft => true, + ProjectStatus::Processing => true, + ProjectStatus::Unknown => true, + ProjectStatus::Scheduled => true, + ProjectStatus::Private => true, + + ProjectStatus::Approved => false, + ProjectStatus::Unlisted => false, + ProjectStatus::Archived => false, + ProjectStatus::Withheld => false, + } + } + + // Project can be displayed in search + pub fn is_searchable(&self) -> bool { + matches!(self, ProjectStatus::Approved | ProjectStatus::Archived) + } + + // Project is "Approved" by moderators + pub fn is_approved(&self) -> bool { + matches!( + self, + ProjectStatus::Approved + | ProjectStatus::Archived + | ProjectStatus::Unlisted + | ProjectStatus::Private + ) + } + + // Project status can be requested after moderator approval + pub fn can_be_requested(&self) -> bool { + match self { + ProjectStatus::Approved => true, + ProjectStatus::Archived => true, + ProjectStatus::Unlisted => true, + ProjectStatus::Private => true, + ProjectStatus::Draft => true, + + ProjectStatus::Rejected => false, + ProjectStatus::Processing => false, + ProjectStatus::Unknown => false, + ProjectStatus::Withheld => false, + ProjectStatus::Scheduled => false, + } + } +} + +#[derive(Serialize, Deserialize, Copy, Clone, Debug, Eq, PartialEq)] +#[serde(rename_all = "kebab-case")] +pub enum MonetizationStatus { + ForceDemonetized, + Demonetized, + Monetized, +} + +impl std::fmt::Display for MonetizationStatus { + fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { + fmt.write_str(self.as_str()) + } +} + +impl MonetizationStatus { + pub fn from_string(string: &str) -> MonetizationStatus { + match string { + "force-demonetized" => MonetizationStatus::ForceDemonetized, + "demonetized" => MonetizationStatus::Demonetized, + "monetized" => MonetizationStatus::Monetized, + _ => MonetizationStatus::Monetized, + } + } + // These are constant, so this can remove unnecessary allocations (`to_string`) + pub fn as_str(&self) -> &'static str { + match self { + MonetizationStatus::ForceDemonetized => "force-demonetized", + MonetizationStatus::Demonetized => "demonetized", + MonetizationStatus::Monetized => "monetized", + } + } +} + +/// A specific version of a project +#[derive(Serialize, Deserialize, Clone)] +pub struct Version { + /// The ID of the version, encoded as a base62 string. + pub id: VersionId, + /// The ID of the project this version is for. + pub project_id: ProjectId, + /// The ID of the author who published this version + pub author_id: UserId, + /// Whether the version is featured or not + pub featured: bool, + /// The name of this version + pub name: String, + /// The version number. Ideally will follow semantic versioning + pub version_number: String, + /// Project types for which this version is compatible with, extracted from Loader + pub project_types: Vec, + /// Games for which this version is compatible with, extracted from Loader/Project types + pub games: Vec, + /// The changelog for this version of the project. + pub changelog: String, + + /// The date that this version was published. + pub date_published: DateTime, + /// The number of downloads this specific version has had. + pub downloads: u32, + /// The type of the release - `Alpha`, `Beta`, or `Release`. + pub version_type: VersionType, + /// The status of tne version + pub status: VersionStatus, + /// The requested status of the version (used for scheduling) + pub requested_status: Option, + + /// A list of files available for download for this version. + pub files: Vec, + /// A list of projects that this version depends on. + pub dependencies: Vec, + + /// The loaders that this version works on + pub loaders: Vec, + /// Ordering override, lower is returned first + pub ordering: Option, + + // All other fields are loader-specific VersionFields + // These are flattened during serialization + #[serde(deserialize_with = "skip_nulls")] + #[serde(flatten)] + pub fields: HashMap, +} + +pub fn skip_nulls<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + let mut map = HashMap::deserialize(deserializer)?; + map.retain(|_, v: &mut serde_json::Value| !v.is_null()); + Ok(map) +} + +impl From for Version { + fn from(data: QueryVersion) -> Version { + let v = data.inner; + Version { + id: v.id.into(), + project_id: v.project_id.into(), + author_id: v.author_id.into(), + featured: v.featured, + name: v.name, + version_number: v.version_number, + project_types: data.project_types, + games: data.games, + changelog: v.changelog, + date_published: v.date_published, + downloads: v.downloads as u32, + version_type: match v.version_type.as_str() { + "release" => VersionType::Release, + "beta" => VersionType::Beta, + "alpha" => VersionType::Alpha, + _ => VersionType::Release, + }, + ordering: v.ordering, + + status: v.status, + requested_status: v.requested_status, + files: data + .files + .into_iter() + .map(|f| VersionFile { + url: f.url, + filename: f.filename, + hashes: f.hashes, + primary: f.primary, + size: f.size, + file_type: f.file_type, + }) + .collect(), + dependencies: data + .dependencies + .into_iter() + .map(|d| Dependency { + version_id: d.version_id.map(|i| VersionId(i.0 as u64)), + project_id: d.project_id.map(|i| ProjectId(i.0 as u64)), + file_name: d.file_name, + dependency_type: DependencyType::from_string( + d.dependency_type.as_str(), + ), + }) + .collect(), + loaders: data.loaders.into_iter().map(Loader).collect(), + // Only add the internal component of the field for display + // "ie": "game_versions",["1.2.3"] instead of "game_versions",ArrayEnum(...) + fields: data + .version_fields + .into_iter() + .map(|vf| (vf.field_name, vf.value.serialize_internal())) + .collect(), + } + } +} + +/// A status decides the visibility of a project in search, URLs, and the whole site itself. +/// Listed - Version is displayed on project, and accessible by URL +/// Archived - Identical to listed but has a message displayed stating version is unsupported +/// Draft - Version is not displayed on project, and not accessible by URL +/// Unlisted - Version is not displayed on project, and accessible by URL +/// Scheduled - Version is scheduled to be released in the future +#[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug)] +#[serde(rename_all = "lowercase")] +pub enum VersionStatus { + Listed, + Archived, + Draft, + Unlisted, + Scheduled, + Unknown, +} + +impl std::fmt::Display for VersionStatus { + fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(fmt, "{}", self.as_str()) + } +} + +impl VersionStatus { + pub fn from_string(string: &str) -> VersionStatus { + match string { + "listed" => VersionStatus::Listed, + "draft" => VersionStatus::Draft, + "unlisted" => VersionStatus::Unlisted, + "scheduled" => VersionStatus::Scheduled, + _ => VersionStatus::Unknown, + } + } + pub fn as_str(&self) -> &'static str { + match self { + VersionStatus::Listed => "listed", + VersionStatus::Archived => "archived", + VersionStatus::Draft => "draft", + VersionStatus::Unlisted => "unlisted", + VersionStatus::Unknown => "unknown", + VersionStatus::Scheduled => "scheduled", + } + } + + pub fn iterator() -> impl Iterator { + [ + VersionStatus::Listed, + VersionStatus::Archived, + VersionStatus::Draft, + VersionStatus::Unlisted, + VersionStatus::Scheduled, + VersionStatus::Unknown, + ] + .iter() + .copied() + } + + // Version pages + info cannot be viewed + pub fn is_hidden(&self) -> bool { + match self { + VersionStatus::Listed => false, + VersionStatus::Archived => false, + VersionStatus::Unlisted => false, + + VersionStatus::Draft => true, + VersionStatus::Scheduled => true, + VersionStatus::Unknown => true, + } + } + + // Whether version is listed on project / returned in aggregate routes + pub fn is_listed(&self) -> bool { + matches!(self, VersionStatus::Listed | VersionStatus::Archived) + } + + // Whether a version status can be requested + pub fn can_be_requested(&self) -> bool { + match self { + VersionStatus::Listed => true, + VersionStatus::Archived => true, + VersionStatus::Draft => true, + VersionStatus::Unlisted => true, + VersionStatus::Scheduled => false, + + VersionStatus::Unknown => false, + } + } +} + +/// A single project file, with a url for the file and the file's hash +#[derive(Serialize, Deserialize, Clone)] +pub struct VersionFile { + /// 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, + /// The filename of the file. + pub filename: String, + /// Whether the file is the primary file of a version + pub primary: bool, + /// The size in bytes of the file + pub size: u32, + /// The type of the file + pub file_type: Option, +} + +/// A dendency which describes what versions are required, break support, or are optional to the +/// version's functionality +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +pub struct Dependency { + /// The specific version id that the dependency uses + pub version_id: Option, + /// The project ID that the dependency is synced with and auto-updated + pub project_id: Option, + /// The filename of the dependency. Used exclusively for external mods on modpacks + pub file_name: Option, + /// The type of the dependency + pub dependency_type: DependencyType, +} + +#[derive(Serialize, Deserialize, Copy, Clone, Eq, PartialEq, Debug)] +#[serde(rename_all = "lowercase")] +pub enum VersionType { + Release, + Beta, + Alpha, +} + +impl std::fmt::Display for VersionType { + fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { + fmt.write_str(self.as_str()) + } +} + +impl VersionType { + // These are constant, so this can remove unneccessary allocations (`to_string`) + pub fn as_str(&self) -> &'static str { + match self { + VersionType::Release => "release", + VersionType::Beta => "beta", + VersionType::Alpha => "alpha", + } + } +} + +#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum DependencyType { + Required, + Optional, + Incompatible, + Embedded, +} + +impl std::fmt::Display for DependencyType { + fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { + fmt.write_str(self.as_str()) + } +} + +impl DependencyType { + // These are constant, so this can remove unneccessary allocations (`to_string`) + pub fn as_str(&self) -> &'static str { + match self { + DependencyType::Required => "required", + DependencyType::Optional => "optional", + DependencyType::Incompatible => "incompatible", + DependencyType::Embedded => "embedded", + } + } + + pub fn from_string(string: &str) -> DependencyType { + match string { + "required" => DependencyType::Required, + "optional" => DependencyType::Optional, + "incompatible" => DependencyType::Incompatible, + "embedded" => DependencyType::Embedded, + _ => DependencyType::Required, + } + } +} + +#[derive(Serialize, Deserialize, Copy, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum FileType { + RequiredResourcePack, + OptionalResourcePack, + Unknown, +} + +impl std::fmt::Display for FileType { + fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { + fmt.write_str(self.as_str()) + } +} + +impl FileType { + // These are constant, so this can remove unnecessary allocations (`to_string`) + pub fn as_str(&self) -> &'static str { + match self { + FileType::RequiredResourcePack => "required-resource-pack", + FileType::OptionalResourcePack => "optional-resource-pack", + FileType::Unknown => "unknown", + } + } + + pub fn from_string(string: &str) -> FileType { + match string { + "required-resource-pack" => FileType::RequiredResourcePack, + "optional-resource-pack" => FileType::OptionalResourcePack, + "unknown" => FileType::Unknown, + _ => FileType::Unknown, + } + } +} + +/// A project loader +#[derive(Serialize, Deserialize, Clone, Debug, PartialEq, Eq)] +#[serde(transparent)] +pub struct Loader(pub String); + +// These fields must always succeed parsing; deserialize errors aren't +// processed correctly (don't return JSON errors) +#[derive(Serialize, Deserialize, Debug)] +pub struct SearchRequest { + pub query: Option, + pub offset: Option, + pub index: Option, + pub limit: Option, + + pub new_filters: Option, + + // TODO: Deprecated values below. WILL BE REMOVED V3! + pub facets: Option, + pub filters: Option, + pub version: Option, +} diff --git a/apps/labrinth/src/models/v3/reports.rs b/apps/labrinth/src/models/v3/reports.rs new file mode 100644 index 000000000..f620bc133 --- /dev/null +++ b/apps/labrinth/src/models/v3/reports.rs @@ -0,0 +1,73 @@ +use super::ids::Base62Id; +use crate::database::models::report_item::QueryReport as DBReport; +use crate::models::ids::{ProjectId, ThreadId, UserId, VersionId}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct ReportId(pub u64); + +#[derive(Serialize, Deserialize)] +pub struct Report { + pub id: ReportId, + pub report_type: String, + pub item_id: String, + pub item_type: ItemType, + pub reporter: UserId, + pub body: String, + pub created: DateTime, + pub closed: bool, + pub thread_id: ThreadId, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(rename_all = "kebab-case")] +pub enum ItemType { + Project, + Version, + User, + Unknown, +} + +impl ItemType { + pub fn as_str(&self) -> &'static str { + match self { + ItemType::Project => "project", + ItemType::Version => "version", + ItemType::User => "user", + ItemType::Unknown => "unknown", + } + } +} + +impl From for Report { + fn from(x: DBReport) -> Self { + let mut item_id = "".to_string(); + let mut item_type = ItemType::Unknown; + + if let Some(project_id) = x.project_id { + item_id = ProjectId::from(project_id).to_string(); + item_type = ItemType::Project; + } else if let Some(version_id) = x.version_id { + item_id = VersionId::from(version_id).to_string(); + item_type = ItemType::Version; + } else if let Some(user_id) = x.user_id { + item_id = UserId::from(user_id).to_string(); + item_type = ItemType::User; + } + + Report { + id: x.id.into(), + report_type: x.report_type, + item_id, + item_type, + reporter: x.reporter.into(), + body: x.body, + created: x.created, + closed: x.closed, + thread_id: x.thread_id.into(), + } + } +} diff --git a/apps/labrinth/src/models/v3/sessions.rs b/apps/labrinth/src/models/v3/sessions.rs new file mode 100644 index 000000000..46a8a69ac --- /dev/null +++ b/apps/labrinth/src/models/v3/sessions.rs @@ -0,0 +1,60 @@ +use super::ids::Base62Id; +use crate::models::users::UserId; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, Debug)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct SessionId(pub u64); + +#[derive(Serialize, Deserialize, Clone)] +pub struct Session { + pub id: SessionId, + pub session: Option, + pub user_id: UserId, + + pub created: DateTime, + pub last_login: DateTime, + pub expires: DateTime, + pub refresh_expires: DateTime, + + pub os: Option, + pub platform: Option, + pub user_agent: String, + + pub city: Option, + pub country: Option, + pub ip: String, + + pub current: bool, +} + +impl Session { + pub fn from( + data: crate::database::models::session_item::Session, + include_session: bool, + current_session: Option<&str>, + ) -> Self { + Session { + id: data.id.into(), + current: Some(&*data.session) == current_session, + session: if include_session { + Some(data.session) + } else { + None + }, + user_id: data.user_id.into(), + created: data.created, + last_login: data.last_login, + expires: data.expires, + refresh_expires: data.refresh_expires, + os: data.os, + platform: data.platform, + user_agent: data.user_agent, + city: data.city, + country: data.country, + ip: data.ip, + } + } +} diff --git a/apps/labrinth/src/models/v3/teams.rs b/apps/labrinth/src/models/v3/teams.rs new file mode 100644 index 000000000..f9f6ef917 --- /dev/null +++ b/apps/labrinth/src/models/v3/teams.rs @@ -0,0 +1,203 @@ +use super::ids::Base62Id; +use crate::bitflags_serde_impl; +use crate::models::users::User; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; + +/// The ID of a team +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct TeamId(pub u64); + +pub const DEFAULT_ROLE: &str = "Member"; + +/// A team of users who control a project +#[derive(Serialize, Deserialize)] +pub struct Team { + /// The id of the team + pub id: TeamId, + /// A list of the members of the team + pub members: Vec, +} + +bitflags::bitflags! { + #[derive(Copy, Clone, Debug, PartialEq, Eq)] + pub struct ProjectPermissions: u64 { + const UPLOAD_VERSION = 1 << 0; + const DELETE_VERSION = 1 << 1; + const EDIT_DETAILS = 1 << 2; + const EDIT_BODY = 1 << 3; + const MANAGE_INVITES = 1 << 4; + const REMOVE_MEMBER = 1 << 5; + const EDIT_MEMBER = 1 << 6; + const DELETE_PROJECT = 1 << 7; + const VIEW_ANALYTICS = 1 << 8; + const VIEW_PAYOUTS = 1 << 9; + } +} + +bitflags_serde_impl!(ProjectPermissions, u64); + +impl Default for ProjectPermissions { + fn default() -> ProjectPermissions { + ProjectPermissions::empty() + } +} + +impl ProjectPermissions { + pub fn get_permissions_by_role( + role: &crate::models::users::Role, + project_team_member: &Option, // team member of the user in the project + organization_team_member: &Option, // team member of the user in the organization + ) -> Option { + if role.is_admin() { + return Some(ProjectPermissions::all()); + } + + if let Some(member) = project_team_member { + if member.accepted { + return Some(member.permissions); + } + } + + if let Some(member) = organization_team_member { + if member.accepted { + return Some(member.permissions); + } + } + + if role.is_mod() { + Some( + ProjectPermissions::EDIT_DETAILS + | ProjectPermissions::EDIT_BODY + | ProjectPermissions::UPLOAD_VERSION, + ) + } else { + None + } + } +} + +bitflags::bitflags! { + #[derive(Copy, Clone, Debug, PartialEq, Eq)] + pub struct OrganizationPermissions: u64 { + const EDIT_DETAILS = 1 << 0; + const MANAGE_INVITES = 1 << 1; + const REMOVE_MEMBER = 1 << 2; + const EDIT_MEMBER = 1 << 3; + const ADD_PROJECT = 1 << 4; + const REMOVE_PROJECT = 1 << 5; + const DELETE_ORGANIZATION = 1 << 6; + const EDIT_MEMBER_DEFAULT_PERMISSIONS = 1 << 7; // Separate from EDIT_MEMBER + const NONE = 0b0; + } +} + +bitflags_serde_impl!(OrganizationPermissions, u64); + +impl Default for OrganizationPermissions { + fn default() -> OrganizationPermissions { + OrganizationPermissions::NONE + } +} + +impl OrganizationPermissions { + pub fn get_permissions_by_role( + role: &crate::models::users::Role, + team_member: &Option, + ) -> Option { + if role.is_admin() { + return Some(OrganizationPermissions::all()); + } + + if let Some(member) = team_member { + if member.accepted { + return member.organization_permissions; + } + } + if role.is_mod() { + return Some( + OrganizationPermissions::EDIT_DETAILS + | OrganizationPermissions::ADD_PROJECT, + ); + } + None + } +} + +/// A member of a team +#[derive(Serialize, Deserialize, Clone)] +pub struct TeamMember { + /// The ID of the team this team member is a member of + pub team_id: TeamId, + /// The user associated with the member + pub user: User, + /// The role of the user in the team + pub role: String, + /// Is the user the owner of the team? + pub is_owner: bool, + /// A bitset containing the user's permissions in this team. + /// In an organization-controlled project, these are the unique overriding permissions for the user's role for any project in the organization, if they exist. + /// In an organization, these are the default project permissions for any project in the organization. + /// Not optional- only None if they are being hidden from the user. + pub permissions: Option, + + /// A bitset containing the user's permissions in this organization. + /// In a project team, this is None. + pub organization_permissions: Option, + + /// Whether the user has joined the team or is just invited to it + pub accepted: bool, + + #[serde(with = "rust_decimal::serde::float_option")] + /// Payouts split. This is a weighted average. For example. if a team has two members with this + /// value set to 25.0 for both members, they split revenue 50/50 + pub payouts_split: Option, + /// Ordering of the member in the list + pub ordering: i64, +} + +impl TeamMember { + pub fn from( + data: crate::database::models::team_item::TeamMember, + user: crate::database::models::User, + override_permissions: bool, + ) -> Self { + let user: User = user.into(); + Self::from_model(data, user, override_permissions) + } + + // Use the User model directly instead of the database model, + // if already available. + // (Avoids a db query in some cases) + pub fn from_model( + data: crate::database::models::team_item::TeamMember, + user: crate::models::users::User, + override_permissions: bool, + ) -> Self { + Self { + team_id: data.team_id.into(), + user, + role: data.role, + is_owner: data.is_owner, + permissions: if override_permissions { + None + } else { + Some(data.permissions) + }, + organization_permissions: if override_permissions { + None + } else { + data.organization_permissions + }, + accepted: data.accepted, + payouts_split: if override_permissions { + None + } else { + Some(data.payouts_split) + }, + ordering: data.ordering, + } + } +} diff --git a/apps/labrinth/src/models/v3/threads.rs b/apps/labrinth/src/models/v3/threads.rs new file mode 100644 index 000000000..1c1883175 --- /dev/null +++ b/apps/labrinth/src/models/v3/threads.rs @@ -0,0 +1,145 @@ +use super::ids::{Base62Id, ImageId}; +use crate::models::ids::{ProjectId, ReportId}; +use crate::models::projects::ProjectStatus; +use crate::models::users::{User, UserId}; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct ThreadId(pub u64); + +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct ThreadMessageId(pub u64); + +#[derive(Serialize, Deserialize)] +pub struct Thread { + pub id: ThreadId, + #[serde(rename = "type")] + pub type_: ThreadType, + pub project_id: Option, + pub report_id: Option, + pub messages: Vec, + pub members: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct ThreadMessage { + pub id: ThreadMessageId, + pub author_id: Option, + pub body: MessageBody, + pub created: DateTime, + pub hide_identity: bool, +} + +#[derive(Serialize, Deserialize, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum MessageBody { + Text { + body: String, + #[serde(default)] + private: bool, + replying_to: Option, + #[serde(default)] + associated_images: Vec, + }, + StatusChange { + new_status: ProjectStatus, + old_status: ProjectStatus, + }, + ThreadClosure, + ThreadReopen, + Deleted { + #[serde(default)] + private: bool, + }, +} + +#[derive(Serialize, Deserialize, Eq, PartialEq, Copy, Clone)] +#[serde(rename_all = "snake_case")] +pub enum ThreadType { + Report, + Project, + DirectMessage, +} + +impl std::fmt::Display for ThreadType { + fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { + write!(fmt, "{}", self.as_str()) + } +} + +impl ThreadType { + // These are constant, so this can remove unneccessary allocations (`to_string`) + pub fn as_str(&self) -> &'static str { + match self { + ThreadType::Report => "report", + ThreadType::Project => "project", + ThreadType::DirectMessage => "direct_message", + } + } + + pub fn from_string(string: &str) -> ThreadType { + match string { + "report" => ThreadType::Report, + "project" => ThreadType::Project, + "direct_message" => ThreadType::DirectMessage, + _ => ThreadType::DirectMessage, + } + } +} + +impl Thread { + pub fn from( + data: crate::database::models::Thread, + users: Vec, + user: &User, + ) -> Self { + let thread_type = data.type_; + + Thread { + id: data.id.into(), + type_: thread_type, + project_id: data.project_id.map(|x| x.into()), + report_id: data.report_id.map(|x| x.into()), + messages: data + .messages + .into_iter() + .filter(|x| { + if let MessageBody::Text { private, .. } = x.body { + !private || user.role.is_mod() + } else if let MessageBody::Deleted { private, .. } = x.body + { + !private || user.role.is_mod() + } else { + true + } + }) + .map(|x| ThreadMessage::from(x, user)) + .collect(), + members: users, + } + } +} + +impl ThreadMessage { + pub fn from( + data: crate::database::models::ThreadMessage, + user: &User, + ) -> Self { + Self { + id: data.id.into(), + author_id: if data.hide_identity && !user.role.is_mod() { + None + } else { + data.author_id.map(|x| x.into()) + }, + body: data.body, + created: data.created, + hide_identity: data.hide_identity, + } + } +} diff --git a/apps/labrinth/src/models/v3/users.rs b/apps/labrinth/src/models/v3/users.rs new file mode 100644 index 000000000..a69ee9d9c --- /dev/null +++ b/apps/labrinth/src/models/v3/users.rs @@ -0,0 +1,187 @@ +use super::ids::Base62Id; +use crate::{auth::AuthProvider, bitflags_serde_impl}; +use chrono::{DateTime, Utc}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; + +#[derive(Copy, Clone, PartialEq, Eq, Serialize, Deserialize, Debug, Hash)] +#[serde(from = "Base62Id")] +#[serde(into = "Base62Id")] +pub struct UserId(pub u64); + +pub const DELETED_USER: UserId = UserId(127155982985829); + +bitflags::bitflags! { + #[derive(Copy, Clone, Debug)] + pub struct Badges: u64 { + const MIDAS = 1 << 0; + const EARLY_MODPACK_ADOPTER = 1 << 1; + const EARLY_RESPACK_ADOPTER = 1 << 2; + const EARLY_PLUGIN_ADOPTER = 1 << 3; + const ALPHA_TESTER = 1 << 4; + const CONTRIBUTOR = 1 << 5; + const TRANSLATOR = 1 << 6; + + const ALL = 0b1111111; + const NONE = 0b0; + } +} + +bitflags_serde_impl!(Badges, u64); + +impl Default for Badges { + fn default() -> Badges { + Badges::NONE + } +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct User { + pub id: UserId, + pub username: String, + pub avatar_url: Option, + pub bio: Option, + pub created: DateTime, + pub role: Role, + pub badges: Badges, + + pub auth_providers: Option>, + pub email: Option, + pub email_verified: Option, + pub has_password: Option, + pub has_totp: Option, + pub payout_data: Option, + pub stripe_customer_id: Option, + + // DEPRECATED. Always returns None + pub github_id: Option, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct UserPayoutData { + pub paypal_address: Option, + pub paypal_country: Option, + pub venmo_handle: Option, + #[serde(with = "rust_decimal::serde::float")] + pub balance: Decimal, +} + +use crate::database::models::user_item::User as DBUser; +impl From for User { + fn from(data: DBUser) -> Self { + Self { + id: data.id.into(), + username: data.username, + email: None, + email_verified: None, + avatar_url: data.avatar_url, + bio: data.bio, + created: data.created, + role: Role::from_string(&data.role), + badges: data.badges, + payout_data: None, + auth_providers: None, + has_password: None, + has_totp: None, + github_id: None, + stripe_customer_id: None, + } + } +} + +impl User { + pub fn from_full(db_user: DBUser) -> Self { + let mut auth_providers = Vec::new(); + + if db_user.github_id.is_some() { + auth_providers.push(AuthProvider::GitHub) + } + if db_user.gitlab_id.is_some() { + auth_providers.push(AuthProvider::GitLab) + } + if db_user.discord_id.is_some() { + auth_providers.push(AuthProvider::Discord) + } + if db_user.google_id.is_some() { + auth_providers.push(AuthProvider::Google) + } + if db_user.microsoft_id.is_some() { + auth_providers.push(AuthProvider::Microsoft) + } + if db_user.steam_id.is_some() { + auth_providers.push(AuthProvider::Steam) + } + if db_user.paypal_id.is_some() { + auth_providers.push(AuthProvider::PayPal) + } + + Self { + id: UserId::from(db_user.id), + username: db_user.username, + email: db_user.email, + email_verified: Some(db_user.email_verified), + avatar_url: db_user.avatar_url, + bio: db_user.bio, + created: db_user.created, + role: Role::from_string(&db_user.role), + badges: db_user.badges, + auth_providers: Some(auth_providers), + has_password: Some(db_user.password.is_some()), + has_totp: Some(db_user.totp_secret.is_some()), + github_id: None, + payout_data: Some(UserPayoutData { + paypal_address: db_user.paypal_email, + paypal_country: db_user.paypal_country, + venmo_handle: db_user.venmo_handle, + balance: Decimal::ZERO, + }), + stripe_customer_id: db_user.stripe_customer_id, + } + } +} + +#[derive(Serialize, Deserialize, PartialEq, Eq, Clone, Debug)] +#[serde(rename_all = "lowercase")] +pub enum Role { + Developer, + Moderator, + Admin, +} + +impl std::fmt::Display for Role { + fn fmt(&self, fmt: &mut std::fmt::Formatter) -> std::fmt::Result { + fmt.write_str(self.as_str()) + } +} + +impl Role { + pub fn from_string(string: &str) -> Role { + match string { + "admin" => Role::Admin, + "moderator" => Role::Moderator, + _ => Role::Developer, + } + } + + pub fn as_str(&self) -> &'static str { + match self { + Role::Developer => "developer", + Role::Moderator => "moderator", + Role::Admin => "admin", + } + } + + pub fn is_mod(&self) -> bool { + match self { + Role::Developer => false, + Role::Moderator | Role::Admin => true, + } + } + + pub fn is_admin(&self) -> bool { + match self { + Role::Developer | Role::Moderator => false, + Role::Admin => true, + } + } +} diff --git a/apps/labrinth/src/queue/analytics.rs b/apps/labrinth/src/queue/analytics.rs new file mode 100644 index 000000000..117a51fa2 --- /dev/null +++ b/apps/labrinth/src/queue/analytics.rs @@ -0,0 +1,267 @@ +use crate::database::models::DatabaseError; +use crate::database::redis::RedisPool; +use crate::models::analytics::{Download, PageView, Playtime}; +use crate::routes::ApiError; +use dashmap::{DashMap, DashSet}; +use redis::cmd; +use sqlx::PgPool; +use std::collections::HashMap; +use std::net::Ipv6Addr; + +const DOWNLOADS_NAMESPACE: &str = "downloads"; +const VIEWS_NAMESPACE: &str = "views"; + +pub struct AnalyticsQueue { + views_queue: DashMap<(u64, u64), Vec>, + downloads_queue: DashMap<(u64, u64), Download>, + playtime_queue: DashSet, +} + +impl Default for AnalyticsQueue { + fn default() -> Self { + Self::new() + } +} + +// Batches analytics data points + transactions every few minutes +impl AnalyticsQueue { + pub fn new() -> Self { + AnalyticsQueue { + views_queue: DashMap::with_capacity(1000), + downloads_queue: DashMap::with_capacity(1000), + playtime_queue: DashSet::with_capacity(1000), + } + } + + fn strip_ip(ip: Ipv6Addr) -> u64 { + if let Some(ip) = ip.to_ipv4_mapped() { + let octets = ip.octets(); + u64::from_be_bytes([ + octets[0], octets[1], octets[2], octets[3], 0, 0, 0, 0, + ]) + } else { + let octets = ip.octets(); + u64::from_be_bytes([ + octets[0], octets[1], octets[2], octets[3], octets[4], + octets[5], octets[6], octets[7], + ]) + } + } + + pub fn add_view(&self, page_view: PageView) { + let ip_stripped = Self::strip_ip(page_view.ip); + + self.views_queue + .entry((ip_stripped, page_view.project_id)) + .or_default() + .push(page_view); + } + pub fn add_download(&self, download: Download) { + let ip_stripped = Self::strip_ip(download.ip); + self.downloads_queue + .insert((ip_stripped, download.project_id), download); + } + + pub fn add_playtime(&self, playtime: Playtime) { + self.playtime_queue.insert(playtime); + } + + pub async fn index( + &self, + client: clickhouse::Client, + redis: &RedisPool, + pool: &PgPool, + ) -> Result<(), ApiError> { + let views_queue = self.views_queue.clone(); + self.views_queue.clear(); + + let downloads_queue = self.downloads_queue.clone(); + self.downloads_queue.clear(); + + let playtime_queue = self.playtime_queue.clone(); + self.playtime_queue.clear(); + + if !playtime_queue.is_empty() { + let mut playtimes = client.insert("playtime")?; + + for playtime in playtime_queue { + playtimes.write(&playtime).await?; + } + + playtimes.end().await?; + } + + if !views_queue.is_empty() { + let mut views_keys = Vec::new(); + let mut raw_views = Vec::new(); + + for (key, views) in views_queue { + views_keys.push(key); + raw_views.push((views, true)); + } + + let mut redis = + redis.pool.get().await.map_err(DatabaseError::RedisPool)?; + + let results = cmd("MGET") + .arg( + views_keys + .iter() + .map(|x| format!("{}:{}-{}", VIEWS_NAMESPACE, x.0, x.1)) + .collect::>(), + ) + .query_async::>>(&mut redis) + .await + .map_err(DatabaseError::CacheError)?; + + let mut pipe = redis::pipe(); + for (idx, count) in results.into_iter().enumerate() { + let key = &views_keys[idx]; + + let new_count = + if let Some((views, monetized)) = raw_views.get_mut(idx) { + if let Some(count) = count { + if count > 3 { + *monetized = false; + continue; + } + + if (count + views.len() as u32) > 3 { + *monetized = false; + } + + count + (views.len() as u32) + } else { + views.len() as u32 + } + } else { + 1 + }; + + pipe.atomic().set_ex( + format!("{}:{}-{}", VIEWS_NAMESPACE, key.0, key.1), + new_count, + 6 * 60 * 60, + ); + } + pipe.query_async::<()>(&mut *redis) + .await + .map_err(DatabaseError::CacheError)?; + + let mut views = client.insert("views")?; + + for (all_views, monetized) in raw_views { + for (idx, mut view) in all_views.into_iter().enumerate() { + if idx != 0 || !monetized { + view.monetized = false; + } + + views.write(&view).await?; + } + } + + views.end().await?; + } + + if !downloads_queue.is_empty() { + let mut downloads_keys = Vec::new(); + let raw_downloads = DashMap::new(); + + for (index, (key, download)) in + downloads_queue.into_iter().enumerate() + { + downloads_keys.push(key); + raw_downloads.insert(index, download); + } + + let mut redis = + redis.pool.get().await.map_err(DatabaseError::RedisPool)?; + + let results = cmd("MGET") + .arg( + downloads_keys + .iter() + .map(|x| { + format!("{}:{}-{}", DOWNLOADS_NAMESPACE, x.0, x.1) + }) + .collect::>(), + ) + .query_async::>>(&mut redis) + .await + .map_err(DatabaseError::CacheError)?; + + let mut pipe = redis::pipe(); + for (idx, count) in results.into_iter().enumerate() { + let key = &downloads_keys[idx]; + + let new_count = if let Some(count) = count { + if count > 5 { + raw_downloads.remove(&idx); + continue; + } + + count + 1 + } else { + 1 + }; + + pipe.atomic().set_ex( + format!("{}:{}-{}", DOWNLOADS_NAMESPACE, key.0, key.1), + new_count, + 6 * 60 * 60, + ); + } + pipe.query_async::<()>(&mut *redis) + .await + .map_err(DatabaseError::CacheError)?; + + let mut transaction = pool.begin().await?; + let mut downloads = client.insert("downloads")?; + + let mut version_downloads: HashMap = HashMap::new(); + let mut project_downloads: HashMap = HashMap::new(); + + for (_, download) in raw_downloads { + *version_downloads + .entry(download.version_id as i64) + .or_default() += 1; + *project_downloads + .entry(download.project_id as i64) + .or_default() += 1; + + downloads.write(&download).await?; + } + + sqlx::query( + " + UPDATE versions v + SET downloads = v.downloads + x.amount + FROM unnest($1::BIGINT[], $2::int[]) AS x(id, amount) + WHERE v.id = x.id + ", + ) + .bind(version_downloads.keys().copied().collect::>()) + .bind(version_downloads.values().copied().collect::>()) + .execute(&mut *transaction) + .await?; + + sqlx::query( + " + UPDATE mods m + SET downloads = m.downloads + x.amount + FROM unnest($1::BIGINT[], $2::int[]) AS x(id, amount) + WHERE m.id = x.id + ", + ) + .bind(project_downloads.keys().copied().collect::>()) + .bind(project_downloads.values().copied().collect::>()) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + downloads.end().await?; + } + + Ok(()) + } +} diff --git a/apps/labrinth/src/queue/maxmind.rs b/apps/labrinth/src/queue/maxmind.rs new file mode 100644 index 000000000..e551a8ca4 --- /dev/null +++ b/apps/labrinth/src/queue/maxmind.rs @@ -0,0 +1,83 @@ +use flate2::read::GzDecoder; +use log::warn; +use maxminddb::geoip2::Country; +use std::io::{Cursor, Read}; +use std::net::Ipv6Addr; +use tar::Archive; +use tokio::sync::RwLock; + +pub struct MaxMindIndexer { + pub reader: RwLock>>>, +} + +impl MaxMindIndexer { + pub async fn new() -> Result { + let reader = MaxMindIndexer::inner_index(false).await.ok().flatten(); + + Ok(MaxMindIndexer { + reader: RwLock::new(reader), + }) + } + + pub async fn index(&self) -> Result<(), reqwest::Error> { + let reader = MaxMindIndexer::inner_index(false).await?; + + if let Some(reader) = reader { + let mut reader_new = self.reader.write().await; + *reader_new = Some(reader); + } + + Ok(()) + } + + async fn inner_index( + should_panic: bool, + ) -> Result>>, reqwest::Error> { + let response = reqwest::get( + format!( + "https://download.maxmind.com/app/geoip_download?edition_id=GeoLite2-Country&license_key={}&suffix=tar.gz", + dotenvy::var("MAXMIND_LICENSE_KEY").unwrap() + ) + ).await?.bytes().await.unwrap().to_vec(); + + let tarfile = GzDecoder::new(Cursor::new(response)); + let mut archive = Archive::new(tarfile); + + if let Ok(entries) = archive.entries() { + for mut file in entries.flatten() { + if let Ok(path) = file.header().path() { + if path.extension().and_then(|x| x.to_str()) == Some("mmdb") + { + let mut buf = Vec::new(); + file.read_to_end(&mut buf).unwrap(); + + let reader = + maxminddb::Reader::from_source(buf).unwrap(); + + return Ok(Some(reader)); + } + } + } + } + + if should_panic { + panic!("Unable to download maxmind database- did you get a license key?") + } else { + warn!("Unable to download maxmind database."); + + Ok(None) + } + } + + pub async fn query(&self, ip: Ipv6Addr) -> Option { + let maxmind = self.reader.read().await; + + if let Some(ref maxmind) = *maxmind { + maxmind.lookup::(ip.into()).ok().and_then(|x| { + x.country.and_then(|x| x.iso_code.map(|x| x.to_string())) + }) + } else { + None + } + } +} diff --git a/apps/labrinth/src/queue/mod.rs b/apps/labrinth/src/queue/mod.rs new file mode 100644 index 000000000..7ccf81c01 --- /dev/null +++ b/apps/labrinth/src/queue/mod.rs @@ -0,0 +1,6 @@ +pub mod analytics; +pub mod maxmind; +pub mod moderation; +pub mod payouts; +pub mod session; +pub mod socket; diff --git a/apps/labrinth/src/queue/moderation.rs b/apps/labrinth/src/queue/moderation.rs new file mode 100644 index 000000000..d31a2ebda --- /dev/null +++ b/apps/labrinth/src/queue/moderation.rs @@ -0,0 +1,893 @@ +use crate::auth::checks::filter_visible_versions; +use crate::database; +use crate::database::models::notification_item::NotificationBuilder; +use crate::database::models::thread_item::ThreadMessageBuilder; +use crate::database::redis::RedisPool; +use crate::models::ids::ProjectId; +use crate::models::notifications::NotificationBody; +use crate::models::pack::{PackFile, PackFileHash, PackFormat}; +use crate::models::projects::ProjectStatus; +use crate::models::threads::MessageBody; +use crate::routes::ApiError; +use dashmap::DashSet; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use std::collections::HashMap; +use std::io::{Cursor, Read}; +use std::time::Duration; +use zip::ZipArchive; + +const AUTOMOD_ID: i64 = 0; + +pub struct ModerationMessages { + pub messages: Vec, + pub version_specific: HashMap>, +} + +impl ModerationMessages { + pub fn is_empty(&self) -> bool { + self.messages.is_empty() && self.version_specific.is_empty() + } + + pub fn markdown(&self, auto_mod: bool) -> String { + let mut str = "".to_string(); + + for message in &self.messages { + str.push_str(&format!("## {}\n", message.header())); + str.push_str(&format!("{}\n", message.body())); + str.push('\n'); + } + + for (version_num, messages) in &self.version_specific { + for message in messages { + str.push_str(&format!( + "## Version {}: {}\n", + version_num, + message.header() + )); + str.push_str(&format!("{}\n", message.body())); + str.push('\n'); + } + } + + if auto_mod { + str.push_str("
\n\n"); + str.push_str("🤖 This is an automated message generated by AutoMod (BETA). If you are facing issues, please [contact support](https://support.modrinth.com)."); + } + + str + } + + pub fn should_reject(&self, first_time: bool) -> bool { + self.messages.iter().any(|x| x.rejectable(first_time)) + || self + .version_specific + .values() + .any(|x| x.iter().any(|x| x.rejectable(first_time))) + } + + pub fn approvable(&self) -> bool { + self.messages.iter().all(|x| x.approvable()) + && self + .version_specific + .values() + .all(|x| x.iter().all(|x| x.approvable())) + } +} + +pub enum ModerationMessage { + MissingGalleryImage, + NoPrimaryFile, + NoSideTypes, + PackFilesNotAllowed { + files: HashMap, + incomplete: bool, + }, + MissingLicense, + MissingCustomLicenseUrl { + license: String, + }, +} + +impl ModerationMessage { + pub fn rejectable(&self, first_time: bool) -> bool { + match self { + ModerationMessage::NoPrimaryFile => true, + ModerationMessage::PackFilesNotAllowed { files, incomplete } => { + (!incomplete || first_time) + && files.values().any(|x| match x.status { + ApprovalType::Yes => false, + ApprovalType::WithAttributionAndSource => false, + ApprovalType::WithAttribution => false, + ApprovalType::No => first_time, + ApprovalType::PermanentNo => true, + ApprovalType::Unidentified => first_time, + }) + } + ModerationMessage::MissingGalleryImage => true, + ModerationMessage::MissingLicense => true, + ModerationMessage::MissingCustomLicenseUrl { .. } => true, + ModerationMessage::NoSideTypes => true, + } + } + + pub fn approvable(&self) -> bool { + match self { + ModerationMessage::NoPrimaryFile => false, + ModerationMessage::PackFilesNotAllowed { files, .. } => { + files.values().all(|x| x.status.approved()) + } + ModerationMessage::MissingGalleryImage => false, + ModerationMessage::MissingLicense => false, + ModerationMessage::MissingCustomLicenseUrl { .. } => false, + ModerationMessage::NoSideTypes => false, + } + } + + pub fn header(&self) -> &'static str { + match self { + ModerationMessage::NoPrimaryFile => "No primary files", + ModerationMessage::PackFilesNotAllowed { .. } => { + "Copyrighted Content" + } + ModerationMessage::MissingGalleryImage => "Missing Gallery Images", + ModerationMessage::MissingLicense => "Missing License", + ModerationMessage::MissingCustomLicenseUrl { .. } => { + "Missing License URL" + } + ModerationMessage::NoSideTypes => "Missing Environment Information", + } + } + + pub fn body(&self) -> String { + match self { + ModerationMessage::NoPrimaryFile => "Please attach a file to this version. All files on Modrinth must have files associated with their versions.\n".to_string(), + ModerationMessage::PackFilesNotAllowed { files, .. } => { + let mut str = "".to_string(); + str.push_str("This pack redistributes copyrighted material. Please refer to [Modrinth's guide on obtaining modpack permissions](https://docs.modrinth.com/modpacks/permissions) for more information.\n\n"); + + let mut attribute_mods = Vec::new(); + let mut no_mods = Vec::new(); + let mut permanent_no_mods = Vec::new(); + let mut unidentified_mods = Vec::new(); + for (_, approval) in files.iter() { + match approval.status { + ApprovalType::Yes | ApprovalType::WithAttributionAndSource => {} + ApprovalType::WithAttribution => attribute_mods.push(&approval.file_name), + ApprovalType::No => no_mods.push(&approval.file_name), + ApprovalType::PermanentNo => permanent_no_mods.push(&approval.file_name), + ApprovalType::Unidentified => unidentified_mods.push(&approval.file_name), + } + } + + fn print_mods(projects: Vec<&String>, headline: &str, val: &mut String) { + if projects.is_empty() { return } + + val.push_str(&format!("{headline}\n\n")); + + for project in &projects { + let additional_text = if project.contains("ftb-quests") { + Some("Heracles") + } else if project.contains("ftb-ranks") || project.contains("ftb-essentials") { + Some("Prometheus") + } else if project.contains("ftb-teams") { + Some("Argonauts") + } else if project.contains("ftb-chunks") { + Some("Cadmus") + } else { + None + }; + + val.push_str(&if let Some(additional_text) = additional_text { + format!("- {project}(consider using [{additional_text}](https://modrinth.com/mod/{}) instead)\n", additional_text.to_lowercase()) + } else { + format!("- {project}\n") + }) + } + + if !projects.is_empty() { + val.push('\n'); + } + } + + print_mods(attribute_mods, "The following content has attribution requirements, meaning that you must link back to the page where you originally found this content in your modpack description or version changelog (e.g. linking a mod's CurseForge page if you got it from CurseForge):", &mut str); + print_mods(no_mods, "The following content is not allowed in Modrinth modpacks due to licensing restrictions. Please contact the author(s) directly for permission or remove the content from your modpack:", &mut str); + print_mods(permanent_no_mods, "The following content is not allowed in Modrinth modpacks, regardless of permission obtained. This may be because it breaks Modrinth's content rules or because the authors, upon being contacted for permission, have declined. Please remove the content from your modpack:", &mut str); + print_mods(unidentified_mods, "The following content could not be identified. Please provide proof of its origin along with proof that you have permission to include it:", &mut str); + + str + }, + ModerationMessage::MissingGalleryImage => "We ask that resource packs like yours show off their content using images in the Gallery, or optionally in the Description, in order to effectively and clearly inform users of the content in your pack per section 2.1 of [Modrinth's content rules](https://modrinth.com/legal/rules#general-expectations).\n +Keep in mind that you should:\n +- Set a featured image that best represents your pack. +- Ensure all your images have titles that accurately label the image, and optionally, details on the contents of the image in the images Description. +- Upload any relevant images in your Description to your Gallery tab for best results.".to_string(), + ModerationMessage::MissingLicense => "You must select a License before your project can be published publicly, having a License associated with your project is important to protecting your rights and allowing others to use your content as you intend. For more information, you can see our [Guide to Licensing Mods]().".to_string(), + ModerationMessage::MissingCustomLicenseUrl { license } => format!("It looks like you've selected the License \"{license}\" without providing a valid License link. When using a custom License you must provide a link directly to the License in the License Link field."), + ModerationMessage::NoSideTypes => "Your project's side types are currently set to Unknown on both sides. Please set accurate side types!".to_string(), + } + } +} + +pub struct AutomatedModerationQueue { + pub projects: DashSet, +} + +impl Default for AutomatedModerationQueue { + fn default() -> Self { + Self { + projects: DashSet::new(), + } + } +} + +impl AutomatedModerationQueue { + pub async fn task(&self, pool: PgPool, redis: RedisPool) { + loop { + let projects = self.projects.clone(); + self.projects.clear(); + + for project in projects { + async { + let project = + database::Project::get_id((project).into(), &pool, &redis).await?; + + if let Some(project) = project { + let res = async { + let mut mod_messages = ModerationMessages { + messages: vec![], + version_specific: HashMap::new(), + }; + + if project.project_types.iter().any(|x| ["mod", "modpack"].contains(&&**x)) && !project.aggregate_version_fields.iter().any(|x| ["server_only", "client_only", "client_and_server", "singleplayer"].contains(&&*x.field_name)) { + mod_messages.messages.push(ModerationMessage::NoSideTypes); + } + + if project.inner.license == "LicenseRef-Unknown" || project.inner.license == "LicenseRef-" { + mod_messages.messages.push(ModerationMessage::MissingLicense); + } else if project.inner.license.starts_with("LicenseRef-") && project.inner.license != "LicenseRef-All-Rights-Reserved" && project.inner.license_url.is_none() { + mod_messages.messages.push(ModerationMessage::MissingCustomLicenseUrl { license: project.inner.license.clone() }); + } + + if (project.project_types.contains(&"resourcepack".to_string()) || project.project_types.contains(&"shader".to_string())) && + project.gallery_items.is_empty() && + !project.categories.contains(&"audio".to_string()) && + !project.categories.contains(&"locale".to_string()) + { + mod_messages.messages.push(ModerationMessage::MissingGalleryImage); + } + + let versions = + database::Version::get_many(&project.versions, &pool, &redis) + .await? + .into_iter() + // we only support modpacks at this time + .filter(|x| x.project_types.contains(&"modpack".to_string())) + .collect::>(); + + for version in versions { + let primary_file = version.files.iter().find_or_first(|x| x.primary); + + if let Some(primary_file) = primary_file { + let data = reqwest::get(&primary_file.url).await?.bytes().await?; + + let reader = Cursor::new(data); + let mut zip = ZipArchive::new(reader)?; + + let pack: PackFormat = { + let mut file = + if let Ok(file) = zip.by_name("modrinth.index.json") { + file + } else { + continue; + }; + + let mut contents = String::new(); + file.read_to_string(&mut contents)?; + + serde_json::from_str(&contents)? + }; + + // sha1, pack file, file path, murmur + let mut hashes: Vec<( + String, + Option, + String, + Option, + )> = pack + .files + .clone() + .into_iter() + .flat_map(|x| { + let hash = x.hashes.get(&PackFileHash::Sha1); + + if let Some(hash) = hash { + let path = x.path.clone(); + Some((hash.clone(), Some(x), path, None)) + } else { + None + } + }) + .collect(); + + for i in 0..zip.len() { + let mut file = zip.by_index(i)?; + + if file.name().starts_with("overrides/mods") + || file.name().starts_with("client-overrides/mods") + || file.name().starts_with("server-overrides/mods") + || file.name().starts_with("overrides/shaderpacks") + || file.name().starts_with("client-overrides/shaderpacks") + || file.name().starts_with("overrides/resourcepacks") + || file.name().starts_with("client-overrides/resourcepacks") + { + if file.name().matches('/').count() > 2 || file.name().ends_with(".txt") { + continue; + } + + let mut contents = Vec::new(); + file.read_to_end(&mut contents)?; + + let hash = sha1::Sha1::from(&contents).hexdigest(); + let murmur = hash_flame_murmur32(contents); + + hashes.push(( + hash, + None, + file.name().to_string(), + Some(murmur), + )); + } + } + + let files = database::models::Version::get_files_from_hash( + "sha1".to_string(), + &hashes.iter().map(|x| x.0.clone()).collect::>(), + &pool, + &redis, + ) + .await?; + + let version_ids = + files.iter().map(|x| x.version_id).collect::>(); + let versions_data = filter_visible_versions( + database::models::Version::get_many( + &version_ids, + &pool, + &redis, + ) + .await?, + &None, + &pool, + &redis, + ) + .await?; + + let mut final_hashes = HashMap::new(); + + for version in versions_data { + for file in + files.iter().filter(|x| x.version_id == version.id.into()) + { + if let Some(hash) = file.hashes.get("sha1") { + if let Some((index, (sha1, _, file_name, _))) = hashes + .iter() + .enumerate() + .find(|(_, (value, _, _, _))| value == hash) + { + final_hashes + .insert(sha1.clone(), IdentifiedFile { status: ApprovalType::Yes, file_name: file_name.clone() }); + + hashes.remove(index); + } + } + } + } + + // All files are on Modrinth, so we don't send any messages + if hashes.is_empty() { + sqlx::query!( + " + UPDATE files + SET metadata = $1 + WHERE id = $2 + ", + serde_json::to_value(&MissingMetadata { + identified: final_hashes, + flame_files: Default::default(), + unknown_files: Default::default(), + })?, + primary_file.id.0 + ) + .execute(&pool) + .await?; + + continue; + } + + let rows = sqlx::query!( + " + SELECT encode(mef.sha1, 'escape') sha1, mel.status status + FROM moderation_external_files mef + INNER JOIN moderation_external_licenses mel ON mef.external_license_id = mel.id + WHERE mef.sha1 = ANY($1) + ", + &hashes.iter().map(|x| x.0.as_bytes().to_vec()).collect::>() + ) + .fetch_all(&pool) + .await?; + + for row in rows { + if let Some(sha1) = row.sha1 { + if let Some((index, (sha1, _, file_name, _))) = hashes.iter().enumerate().find(|(_, (value, _, _, _))| value == &sha1) { + final_hashes.insert(sha1.clone(), IdentifiedFile { file_name: file_name.clone(), status: ApprovalType::from_string(&row.status).unwrap_or(ApprovalType::Unidentified) }); + hashes.remove(index); + } + } + } + + if hashes.is_empty() { + let metadata = MissingMetadata { + identified: final_hashes, + flame_files: Default::default(), + unknown_files: Default::default(), + }; + + sqlx::query!( + " + UPDATE files + SET metadata = $1 + WHERE id = $2 + ", + serde_json::to_value(&metadata)?, + primary_file.id.0 + ) + .execute(&pool) + .await?; + + if metadata.identified.values().any(|x| x.status != ApprovalType::Yes && x.status != ApprovalType::WithAttributionAndSource) { + let val = mod_messages.version_specific.entry(version.inner.version_number).or_default(); + val.push(ModerationMessage::PackFilesNotAllowed {files: metadata.identified, incomplete: false }); + } + continue; + } + + let client = reqwest::Client::new(); + let res = client + .post(format!("{}/v1/fingerprints", dotenvy::var("FLAME_ANVIL_URL")?)) + .json(&serde_json::json!({ + "fingerprints": hashes.iter().filter_map(|x| x.3).collect::>() + })) + .send() + .await?.text() + .await?; + + let flame_hashes = serde_json::from_str::>(&res)? + .data + .exact_matches + .into_iter() + .map(|x| x.file) + .collect::>(); + + let mut flame_files = Vec::new(); + + for file in flame_hashes { + let hash = file + .hashes + .iter() + .find(|x| x.algo == 1) + .map(|x| x.value.clone()); + + if let Some(hash) = hash { + flame_files.push((hash, file.mod_id)) + } + } + + let rows = sqlx::query!( + " + SELECT mel.id, mel.flame_project_id, mel.status status + FROM moderation_external_licenses mel + WHERE mel.flame_project_id = ANY($1) + ", + &flame_files.iter().map(|x| x.1 as i32).collect::>() + ) + .fetch_all(&pool).await?; + + let mut insert_hashes = Vec::new(); + let mut insert_ids = Vec::new(); + + for row in rows { + if let Some((curse_index, (hash, _flame_id))) = flame_files.iter().enumerate().find(|(_, x)| Some(x.1 as i32) == row.flame_project_id) { + if let Some((index, (sha1, _, file_name, _))) = hashes.iter().enumerate().find(|(_, (value, _, _, _))| value == hash) { + final_hashes.insert(sha1.clone(), IdentifiedFile { + file_name: file_name.clone(), + status: ApprovalType::from_string(&row.status).unwrap_or(ApprovalType::Unidentified), + }); + + insert_hashes.push(hash.clone().as_bytes().to_vec()); + insert_ids.push(row.id); + + hashes.remove(index); + flame_files.remove(curse_index); + } + } + } + + if !insert_ids.is_empty() && !insert_hashes.is_empty() { + sqlx::query!( + " + INSERT INTO moderation_external_files (sha1, external_license_id) + SELECT * FROM UNNEST ($1::bytea[], $2::bigint[]) + ON CONFLICT (sha1) DO NOTHING + ", + &insert_hashes[..], + &insert_ids[..] + ) + .execute(&pool) + .await?; + } + + if hashes.is_empty() { + let metadata = MissingMetadata { + identified: final_hashes, + flame_files: Default::default(), + unknown_files: Default::default(), + }; + + sqlx::query!( + " + UPDATE files + SET metadata = $1 + WHERE id = $2 + ", + serde_json::to_value(&metadata)?, + primary_file.id.0 + ) + .execute(&pool) + .await?; + + if metadata.identified.values().any(|x| x.status != ApprovalType::Yes && x.status != ApprovalType::WithAttributionAndSource) { + let val = mod_messages.version_specific.entry(version.inner.version_number).or_default(); + val.push(ModerationMessage::PackFilesNotAllowed {files: metadata.identified, incomplete: false }); + } + + continue; + } + + let flame_projects = if flame_files.is_empty() { + Vec::new() + } else { + let res = client + .post(format!("{}v1/mods", dotenvy::var("FLAME_ANVIL_URL")?)) + .json(&serde_json::json!({ + "modIds": flame_files.iter().map(|x| x.1).collect::>() + })) + .send() + .await? + .text() + .await?; + + serde_json::from_str::>>(&res)?.data + }; + + let mut missing_metadata = MissingMetadata { + identified: final_hashes, + flame_files: HashMap::new(), + unknown_files: HashMap::new(), + }; + + for (sha1, _pack_file, file_name, _mumur2) in hashes { + let flame_file = flame_files.iter().find(|x| x.0 == sha1); + + if let Some((_, flame_project_id)) = flame_file { + if let Some(project) = flame_projects.iter().find(|x| &x.id == flame_project_id) { + missing_metadata.flame_files.insert(sha1, MissingMetadataFlame { + title: project.name.clone(), + file_name, + url: project.links.website_url.clone(), + id: *flame_project_id, + }); + + continue; + } + } + + missing_metadata.unknown_files.insert(sha1, file_name); + } + + sqlx::query!( + " + UPDATE files + SET metadata = $1 + WHERE id = $2 + ", + serde_json::to_value(&missing_metadata)?, + primary_file.id.0 + ) + .execute(&pool) + .await?; + + if missing_metadata.identified.values().any(|x| x.status != ApprovalType::Yes && x.status != ApprovalType::WithAttributionAndSource) { + let val = mod_messages.version_specific.entry(version.inner.version_number).or_default(); + val.push(ModerationMessage::PackFilesNotAllowed {files: missing_metadata.identified, incomplete: true }); + } + } else { + let val = mod_messages.version_specific.entry(version.inner.version_number).or_default(); + val.push(ModerationMessage::NoPrimaryFile); + } + } + + if !mod_messages.is_empty() { + let first_time = database::models::Thread::get(project.thread_id, &pool).await? + .map(|x| x.messages.iter().all(|x| x.author_id == Some(database::models::UserId(AUTOMOD_ID)) || x.hide_identity)) + .unwrap_or(true); + + let mut transaction = pool.begin().await?; + let id = ThreadMessageBuilder { + author_id: Some(database::models::UserId(AUTOMOD_ID)), + body: MessageBody::Text { + body: mod_messages.markdown(true), + private: false, + replying_to: None, + associated_images: vec![], + }, + thread_id: project.thread_id, + hide_identity: false, + } + .insert(&mut transaction) + .await?; + + let members = database::models::TeamMember::get_from_team_full( + project.inner.team_id, + &pool, + &redis, + ) + .await?; + + if mod_messages.should_reject(first_time) { + ThreadMessageBuilder { + author_id: Some(database::models::UserId(AUTOMOD_ID)), + body: MessageBody::StatusChange { + new_status: ProjectStatus::Rejected, + old_status: project.inner.status, + }, + thread_id: project.thread_id, + hide_identity: false, + } + .insert(&mut transaction) + .await?; + + NotificationBuilder { + body: NotificationBody::StatusChange { + project_id: project.inner.id.into(), + old_status: project.inner.status, + new_status: ProjectStatus::Rejected, + }, + } + .insert_many(members.into_iter().map(|x| x.user_id).collect(), &mut transaction, &redis) + .await?; + + if let Ok(webhook_url) = dotenvy::var("MODERATION_SLACK_WEBHOOK") { + crate::util::webhook::send_slack_webhook( + project.inner.id.into(), + &pool, + &redis, + webhook_url, + Some( + format!( + "*<{}/user/AutoMod|AutoMod>* changed project status from *{}* to *Rejected*", + dotenvy::var("SITE_URL")?, + &project.inner.status.as_friendly_str(), + ) + .to_string(), + ), + ) + .await + .ok(); + } + + sqlx::query!( + " + UPDATE mods + SET status = 'rejected' + WHERE id = $1 + ", + project.inner.id.0 + ) + .execute(&pool) + .await?; + + database::models::Project::clear_cache( + project.inner.id, + project.inner.slug.clone(), + None, + &redis, + ) + .await?; + } else { + NotificationBuilder { + body: NotificationBody::ModeratorMessage { + thread_id: project.thread_id.into(), + message_id: id.into(), + project_id: Some(project.inner.id.into()), + report_id: None, + }, + } + .insert_many( + members.into_iter().map(|x| x.user_id).collect(), + &mut transaction, + &redis, + ) + .await?; + } + + transaction.commit().await?; + } + + Ok::<(), ApiError>(()) + }.await; + + if let Err(err) = res { + let err = err.as_api_error(); + + let mut str = String::new(); + str.push_str("## Internal AutoMod Error\n\n"); + str.push_str(&format!("Error code: {}\n\n", err.error)); + str.push_str(&format!("Error description: {}\n\n", err.description)); + + let mut transaction = pool.begin().await?; + ThreadMessageBuilder { + author_id: Some(database::models::UserId(AUTOMOD_ID)), + body: MessageBody::Text { + body: str, + private: true, + replying_to: None, + associated_images: vec![], + }, + thread_id: project.thread_id, + hide_identity: false, + } + .insert(&mut transaction) + .await?; + transaction.commit().await?; + } + } + + Ok::<(), ApiError>(()) + }.await.ok(); + } + + tokio::time::sleep(Duration::from_secs(5)).await + } + } +} + +#[derive(Serialize, Deserialize)] +pub struct MissingMetadata { + pub identified: HashMap, + pub flame_files: HashMap, + pub unknown_files: HashMap, +} + +#[derive(Serialize, Deserialize)] +pub struct IdentifiedFile { + pub file_name: String, + pub status: ApprovalType, +} + +#[derive(Serialize, Deserialize)] +pub struct MissingMetadataFlame { + pub title: String, + pub file_name: String, + pub url: String, + pub id: u32, +} + +#[derive(Deserialize, Serialize, Copy, Clone, PartialEq, Eq, Debug)] +#[serde(rename_all = "kebab-case")] +pub enum ApprovalType { + Yes, + WithAttributionAndSource, + WithAttribution, + No, + PermanentNo, + Unidentified, +} + +impl ApprovalType { + fn approved(&self) -> bool { + match self { + ApprovalType::Yes => true, + ApprovalType::WithAttributionAndSource => true, + ApprovalType::WithAttribution => true, + ApprovalType::No => false, + ApprovalType::PermanentNo => false, + ApprovalType::Unidentified => false, + } + } + + pub fn from_string(string: &str) -> Option { + match string { + "yes" => Some(ApprovalType::Yes), + "with-attribution-and-source" => { + Some(ApprovalType::WithAttributionAndSource) + } + "with-attribution" => Some(ApprovalType::WithAttribution), + "no" => Some(ApprovalType::No), + "permanent-no" => Some(ApprovalType::PermanentNo), + "unidentified" => Some(ApprovalType::Unidentified), + _ => None, + } + } + + pub(crate) fn as_str(&self) -> &'static str { + match self { + ApprovalType::Yes => "yes", + ApprovalType::WithAttributionAndSource => { + "with-attribution-and-source" + } + ApprovalType::WithAttribution => "with-attribution", + ApprovalType::No => "no", + ApprovalType::PermanentNo => "permanent-no", + ApprovalType::Unidentified => "unidentified", + } + } +} + +#[derive(Deserialize, Serialize)] +pub struct FlameResponse { + pub data: T, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FingerprintResponse { + pub exact_matches: Vec, +} + +#[derive(Deserialize, Serialize)] +pub struct FingerprintMatch { + pub id: u32, + pub file: FlameFile, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FlameFile { + pub id: u32, + pub mod_id: u32, + pub hashes: Vec, + pub file_fingerprint: u32, +} + +#[derive(Deserialize, Serialize, Debug)] +pub struct FlameFileHash { + pub value: String, + pub algo: u32, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FlameProject { + pub id: u32, + pub name: String, + pub slug: String, + pub links: FlameLinks, +} + +#[derive(Deserialize, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct FlameLinks { + pub website_url: String, +} + +fn hash_flame_murmur32(input: Vec) -> u32 { + murmur2::murmur2( + &input + .into_iter() + .filter(|x| *x != 9 && *x != 10 && *x != 13 && *x != 32) + .collect::>(), + 1, + ) +} diff --git a/apps/labrinth/src/queue/payouts.rs b/apps/labrinth/src/queue/payouts.rs new file mode 100644 index 000000000..46345045e --- /dev/null +++ b/apps/labrinth/src/queue/payouts.rs @@ -0,0 +1,920 @@ +use crate::models::payouts::{ + PayoutDecimal, PayoutInterval, PayoutMethod, PayoutMethodFee, + PayoutMethodType, +}; +use crate::models::projects::MonetizationStatus; +use crate::routes::ApiError; +use base64::Engine; +use chrono::{DateTime, Datelike, Duration, TimeZone, Utc}; +use dashmap::DashMap; +use futures::TryStreamExt; +use reqwest::Method; +use rust_decimal::Decimal; +use serde::de::DeserializeOwned; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use sqlx::postgres::PgQueryResult; +use sqlx::PgPool; +use std::collections::HashMap; +use tokio::sync::RwLock; + +pub struct PayoutsQueue { + credential: RwLock>, + payout_options: RwLock>, +} + +#[derive(Clone)] +struct PayPalCredentials { + access_token: String, + token_type: String, + expires: DateTime, +} + +#[derive(Clone)] +struct PayoutMethods { + options: Vec, + expires: DateTime, +} + +impl Default for PayoutsQueue { + fn default() -> Self { + Self::new() + } +} +// Batches payouts and handles token refresh +impl PayoutsQueue { + pub fn new() -> Self { + PayoutsQueue { + credential: RwLock::new(None), + payout_options: RwLock::new(None), + } + } + + async fn refresh_token(&self) -> Result { + let mut creds = self.credential.write().await; + let client = reqwest::Client::new(); + + let combined_key = format!( + "{}:{}", + dotenvy::var("PAYPAL_CLIENT_ID")?, + dotenvy::var("PAYPAL_CLIENT_SECRET")? + ); + let formatted_key = format!( + "Basic {}", + base64::engine::general_purpose::STANDARD.encode(combined_key) + ); + + let mut form = HashMap::new(); + form.insert("grant_type", "client_credentials"); + + #[derive(Deserialize)] + struct PaypalCredential { + access_token: String, + token_type: String, + expires_in: i64, + } + + let credential: PaypalCredential = client + .post(format!("{}oauth2/token", dotenvy::var("PAYPAL_API_URL")?)) + .header("Accept", "application/json") + .header("Accept-Language", "en_US") + .header("Authorization", formatted_key) + .form(&form) + .send() + .await + .map_err(|_| { + ApiError::Payments( + "Error while authenticating with PayPal".to_string(), + ) + })? + .json() + .await + .map_err(|_| { + ApiError::Payments( + "Error while authenticating with PayPal (deser error)" + .to_string(), + ) + })?; + + let new_creds = PayPalCredentials { + access_token: credential.access_token, + token_type: credential.token_type, + expires: Utc::now() + Duration::seconds(credential.expires_in), + }; + + *creds = Some(new_creds.clone()); + + Ok(new_creds) + } + + pub async fn make_paypal_request( + &self, + method: Method, + path: &str, + body: Option, + raw_text: Option, + no_api_prefix: Option, + ) -> Result { + let read = self.credential.read().await; + let credentials = if let Some(credentials) = read.as_ref() { + if credentials.expires < Utc::now() { + drop(read); + self.refresh_token().await.map_err(|_| { + ApiError::Payments( + "Error while authenticating with PayPal".to_string(), + ) + })? + } else { + credentials.clone() + } + } else { + drop(read); + self.refresh_token().await.map_err(|_| { + ApiError::Payments( + "Error while authenticating with PayPal".to_string(), + ) + })? + }; + + let client = reqwest::Client::new(); + let mut request = client + .request( + method, + if no_api_prefix.unwrap_or(false) { + path.to_string() + } else { + format!("{}{path}", dotenvy::var("PAYPAL_API_URL")?) + }, + ) + .header( + "Authorization", + format!( + "{} {}", + credentials.token_type, credentials.access_token + ), + ); + + if let Some(body) = body { + request = request.json(&body); + } else if let Some(body) = raw_text { + request = request + .header(reqwest::header::CONTENT_TYPE, "application/json") + .body(body); + } + + let resp = request.send().await.map_err(|_| { + ApiError::Payments("could not communicate with PayPal".to_string()) + })?; + + let status = resp.status(); + + let value = resp.json::().await.map_err(|_| { + ApiError::Payments( + "could not retrieve PayPal response body".to_string(), + ) + })?; + + if !status.is_success() { + #[derive(Deserialize)] + struct PayPalError { + pub name: String, + pub message: String, + } + + #[derive(Deserialize)] + struct PayPalIdentityError { + pub error: String, + pub error_description: String, + } + + if let Ok(error) = + serde_json::from_value::(value.clone()) + { + return Err(ApiError::Payments(format!( + "error name: {}, message: {}", + error.name, error.message + ))); + } + + if let Ok(error) = + serde_json::from_value::(value) + { + return Err(ApiError::Payments(format!( + "error name: {}, message: {}", + error.error, error.error_description + ))); + } + + return Err(ApiError::Payments( + "could not retrieve PayPal error body".to_string(), + )); + } + + Ok(serde_json::from_value(value)?) + } + + pub async fn make_tremendous_request( + &self, + method: Method, + path: &str, + body: Option, + ) -> Result { + let client = reqwest::Client::new(); + let mut request = client + .request( + method, + format!("{}{path}", dotenvy::var("TREMENDOUS_API_URL")?), + ) + .header( + "Authorization", + format!("Bearer {}", dotenvy::var("TREMENDOUS_API_KEY")?), + ); + + if let Some(body) = body { + request = request.json(&body); + } + + let resp = request.send().await.map_err(|_| { + ApiError::Payments( + "could not communicate with Tremendous".to_string(), + ) + })?; + + let status = resp.status(); + + let value = resp.json::().await.map_err(|_| { + ApiError::Payments( + "could not retrieve Tremendous response body".to_string(), + ) + })?; + + if !status.is_success() { + if let Some(obj) = value.as_object() { + if let Some(array) = obj.get("errors") { + #[derive(Deserialize)] + struct TremendousError { + message: String, + } + + let err = serde_json::from_value::( + array.clone(), + ) + .map_err(|_| { + ApiError::Payments( + "could not retrieve Tremendous error json body" + .to_string(), + ) + })?; + + return Err(ApiError::Payments(err.message)); + } + + return Err(ApiError::Payments( + "could not retrieve Tremendous error body".to_string(), + )); + } + } + + Ok(serde_json::from_value(value)?) + } + + pub async fn get_payout_methods( + &self, + ) -> Result, ApiError> { + async fn refresh_payout_methods( + queue: &PayoutsQueue, + ) -> Result { + let mut options = queue.payout_options.write().await; + + let mut methods = Vec::new(); + + #[derive(Deserialize)] + pub struct Sku { + pub min: Decimal, + pub max: Decimal, + } + + #[derive(Deserialize, Eq, PartialEq)] + #[serde(rename_all = "snake_case")] + pub enum ProductImageType { + Card, + Logo, + } + + #[derive(Deserialize)] + pub struct ProductImage { + pub src: String, + #[serde(rename = "type")] + pub type_: ProductImageType, + } + + #[derive(Deserialize)] + pub struct ProductCountry { + pub abbr: String, + } + + #[derive(Deserialize)] + pub struct Product { + pub id: String, + pub category: String, + pub name: String, + // pub description: String, + // pub disclosure: String, + pub skus: Vec, + pub currency_codes: Vec, + pub countries: Vec, + pub images: Vec, + } + + #[derive(Deserialize)] + pub struct TremendousResponse { + pub products: Vec, + } + + let response = queue + .make_tremendous_request::<(), TremendousResponse>( + Method::GET, + "products", + None, + ) + .await?; + + for product in response.products { + const BLACKLISTED_IDS: &[&str] = &[ + // physical visa + "A2J05SWPI2QG", + // crypto + "1UOOSHUUYTAM", + "5EVJN47HPDFT", + "NI9M4EVAVGFJ", + "VLY29QHTMNGT", + "7XU98H109Y3A", + "0CGEDFP2UIKV", + "PDYLQU0K073Y", + "HCS5Z7O2NV5G", + "IY1VMST1MOXS", + "VRPZLJ7HCA8X", + // bitcard (crypto) + "GWQQS5RM8IZS", + "896MYD4SGOGZ", + "PWLEN1VZGMZA", + "A2VRM96J5K5W", + "HV9ICIM3JT7P", + "K2KLSPVWC2Q4", + "HRBRQLLTDF95", + "UUBYLZVK7QAB", + "BH8W3XEDEOJN", + "7WGE043X1RYQ", + "2B13MHUZZVTF", + "JN6R44P86EYX", + "DA8H43GU84SO", + "QK2XAQHSDEH4", + "J7K1IQFS76DK", + "NL4JQ2G7UPRZ", + "OEFTMSBA5ELH", + "A3CQK6UHNV27", + ]; + const SUPPORTED_METHODS: &[&str] = &[ + "merchant_cards", + "merchant_card", + "visa", + "bank", + "ach", + "visa_card", + ]; + + if !SUPPORTED_METHODS.contains(&&*product.category) + || BLACKLISTED_IDS.contains(&&*product.id) + { + continue; + }; + + let method = PayoutMethod { + id: product.id, + type_: PayoutMethodType::Tremendous, + name: product.name.clone(), + supported_countries: product + .countries + .into_iter() + .map(|x| x.abbr) + .collect(), + image_url: product + .images + .into_iter() + .find(|x| x.type_ == ProductImageType::Card) + .map(|x| x.src), + interval: if product.skus.len() > 1 { + let mut values = product + .skus + .into_iter() + .map(|x| PayoutDecimal(x.min)) + .collect::>(); + values.sort_by(|a, b| a.0.cmp(&b.0)); + + PayoutInterval::Fixed { values } + } else if let Some(first) = product.skus.first() { + PayoutInterval::Standard { + min: first.min, + max: first.max, + } + } else { + PayoutInterval::Standard { + min: Decimal::ZERO, + max: Decimal::from(5_000), + } + }, + fee: if product.category == "ach" { + PayoutMethodFee { + percentage: Decimal::from(4) / Decimal::from(100), + min: Decimal::from(1) / Decimal::from(4), + max: None, + } + } else { + PayoutMethodFee { + percentage: Default::default(), + min: Default::default(), + max: None, + } + }, + }; + + // we do not support interval gift cards with non US based currencies since we cannot do currency conversions properly + if let PayoutInterval::Fixed { .. } = method.interval { + if !product.currency_codes.contains(&"USD".to_string()) { + continue; + } + } + + methods.push(method); + } + + const UPRANK_IDS: &[&str] = + &["ET0ZVETV5ILN", "Q24BD9EZ332JT", "UIL1ZYJU5MKN"]; + const DOWNRANK_IDS: &[&str] = &["EIPF8Q00EMM1", "OU2MWXYWPNWQ"]; + + methods.sort_by(|a, b| { + let a_top = UPRANK_IDS.contains(&&*a.id); + let a_bottom = DOWNRANK_IDS.contains(&&*a.id); + let b_top = UPRANK_IDS.contains(&&*b.id); + let b_bottom = DOWNRANK_IDS.contains(&&*b.id); + + match (a_top, a_bottom, b_top, b_bottom) { + (true, _, true, _) => a.name.cmp(&b.name), // Both in top_priority: sort alphabetically + (_, true, _, true) => a.name.cmp(&b.name), // Both in bottom_priority: sort alphabetically + (true, _, _, _) => std::cmp::Ordering::Less, // a in top_priority: a comes first + (_, _, true, _) => std::cmp::Ordering::Greater, // b in top_priority: b comes first + (_, true, _, _) => std::cmp::Ordering::Greater, // a in bottom_priority: b comes first + (_, _, _, true) => std::cmp::Ordering::Less, // b in bottom_priority: a comes first + (_, _, _, _) => a.name.cmp(&b.name), // Neither in priority: sort alphabetically + } + }); + + { + let paypal_us = PayoutMethod { + id: "paypal_us".to_string(), + type_: PayoutMethodType::PayPal, + name: "PayPal".to_string(), + supported_countries: vec!["US".to_string()], + image_url: None, + interval: PayoutInterval::Standard { + min: Decimal::from(1) / Decimal::from(4), + max: Decimal::from(100_000), + }, + fee: PayoutMethodFee { + percentage: Decimal::from(2) / Decimal::from(100), + min: Decimal::from(1) / Decimal::from(4), + max: Some(Decimal::from(1)), + }, + }; + + let mut venmo = paypal_us.clone(); + venmo.id = "venmo".to_string(); + venmo.name = "Venmo".to_string(); + venmo.type_ = PayoutMethodType::Venmo; + + methods.insert(0, paypal_us); + methods.insert(1, venmo) + } + + methods.insert( + 2, + PayoutMethod { + id: "paypal_in".to_string(), + type_: PayoutMethodType::PayPal, + name: "PayPal".to_string(), + supported_countries: rust_iso3166::ALL + .iter() + .filter(|x| x.alpha2 != "US") + .map(|x| x.alpha2.to_string()) + .collect(), + image_url: None, + interval: PayoutInterval::Standard { + min: Decimal::from(1) / Decimal::from(4), + max: Decimal::from(100_000), + }, + fee: PayoutMethodFee { + percentage: Decimal::from(2) / Decimal::from(100), + min: Decimal::ZERO, + max: Some(Decimal::from(20)), + }, + }, + ); + + let new_options = PayoutMethods { + options: methods, + expires: Utc::now() + Duration::hours(6), + }; + + *options = Some(new_options.clone()); + + Ok(new_options) + } + + let read = self.payout_options.read().await; + let options = if let Some(options) = read.as_ref() { + if options.expires < Utc::now() { + drop(read); + refresh_payout_methods(self).await? + } else { + options.clone() + } + } else { + drop(read); + refresh_payout_methods(self).await? + }; + + Ok(options.options) + } +} + +#[derive(Deserialize)] +pub struct AditudePoints { + #[serde(rename = "pointsList")] + pub points_list: Vec, +} + +#[derive(Deserialize)] +pub struct AditudePoint { + pub metric: AditudeMetric, + pub time: AditudeTime, +} + +#[derive(Deserialize)] +pub struct AditudeMetric { + pub revenue: Option, + pub impressions: Option, + pub cpm: Option, +} + +#[derive(Deserialize)] +pub struct AditudeTime { + pub seconds: u64, +} + +pub async fn make_aditude_request( + metrics: &[&str], + range: &str, + interval: &str, +) -> Result, ApiError> { + let request = reqwest::Client::new() + .post("https://cloud.aditude.io/api/public/insights/metrics") + .bearer_auth(&dotenvy::var("ADITUDE_API_KEY")?) + .json(&serde_json::json!({ + "metrics": metrics, + "range": range, + "interval": interval + })) + .send() + .await? + .error_for_status()?; + + let text = request.text().await?; + + let json: Vec = serde_json::from_str(&text)?; + + Ok(json) +} + +pub async fn process_payout( + pool: &PgPool, + client: &clickhouse::Client, +) -> Result<(), ApiError> { + let start: DateTime = DateTime::from_naive_utc_and_offset( + (Utc::now() - Duration::days(1)) + .date_naive() + .and_hms_nano_opt(0, 0, 0, 0) + .unwrap_or_default(), + Utc, + ); + + let results = sqlx::query!( + "SELECT EXISTS(SELECT 1 FROM payouts_values WHERE created = $1)", + start, + ) + .fetch_one(pool) + .await?; + + if results.exists.unwrap_or(false) { + return Ok(()); + } + + let end = start + Duration::days(1); + #[derive(Deserialize, clickhouse::Row)] + struct ProjectMultiplier { + pub page_views: u64, + pub project_id: u64, + } + + let (views_values, views_sum, downloads_values, downloads_sum) = futures::future::try_join4( + client + .query( + r#" + SELECT COUNT(1) page_views, project_id + FROM views + WHERE (recorded BETWEEN ? AND ?) AND (project_id != 0) AND (monetized = TRUE) + GROUP BY project_id + ORDER BY page_views DESC + "#, + ) + .bind(start.timestamp()) + .bind(end.timestamp()) + .fetch_all::(), + client + .query("SELECT COUNT(1) FROM views WHERE (recorded BETWEEN ? AND ?) AND (project_id != 0) AND (monetized = TRUE)") + .bind(start.timestamp()) + .bind(end.timestamp()) + .fetch_one::(), + client + .query( + r#" + SELECT COUNT(1) page_views, project_id + FROM downloads + WHERE (recorded BETWEEN ? AND ?) AND (user_id != 0) + GROUP BY project_id + ORDER BY page_views DESC + "#, + ) + .bind(start.timestamp()) + .bind(end.timestamp()) + .fetch_all::(), + client + .query("SELECT COUNT(1) FROM downloads WHERE (recorded BETWEEN ? AND ?) AND (user_id != 0)") + .bind(start.timestamp()) + .bind(end.timestamp()) + .fetch_one::(), + ) + .await?; + + let mut transaction = pool.begin().await?; + + struct PayoutMultipliers { + sum: u64, + values: HashMap, + } + + let mut views_values = views_values + .into_iter() + .map(|x| (x.project_id, x.page_views)) + .collect::>(); + let downloads_values = downloads_values + .into_iter() + .map(|x| (x.project_id, x.page_views)) + .collect::>(); + + for (key, value) in downloads_values.iter() { + let counter = views_values.entry(*key).or_insert(0); + *counter += *value; + } + + let multipliers: PayoutMultipliers = PayoutMultipliers { + sum: downloads_sum + views_sum, + values: views_values, + }; + + struct Project { + // user_id, payouts_split + team_members: Vec<(i64, Decimal)>, + } + + let mut projects_map: HashMap = HashMap::new(); + + let project_ids = multipliers + .values + .keys() + .map(|x| *x as i64) + .collect::>(); + + let project_org_members = sqlx::query!( + " + SELECT m.id id, tm.user_id user_id, tm.payouts_split payouts_split + FROM mods m + INNER JOIN organizations o ON m.organization_id = o.id + INNER JOIN team_members tm on o.team_id = tm.team_id AND tm.accepted = TRUE + WHERE m.id = ANY($1) AND m.monetization_status = $2 AND m.status = ANY($3) AND m.organization_id IS NOT NULL + ", + &project_ids, + MonetizationStatus::Monetized.as_str(), + &*crate::models::projects::ProjectStatus::iterator() + .filter(|x| !x.is_hidden()) + .map(|x| x.to_string()) + .collect::>(), + ) + .fetch(&mut *transaction) + .try_fold(DashMap::new(), |acc: DashMap>, r| { + acc.entry(r.id) + .or_default() + .insert(r.user_id, r.payouts_split); + async move { Ok(acc) } + }) + .await?; + + let project_team_members = sqlx::query!( + " + SELECT m.id id, tm.user_id user_id, tm.payouts_split payouts_split + FROM mods m + INNER JOIN team_members tm on m.team_id = tm.team_id AND tm.accepted = TRUE + WHERE m.id = ANY($1) AND m.monetization_status = $2 AND m.status = ANY($3) + ", + &project_ids, + MonetizationStatus::Monetized.as_str(), + &*crate::models::projects::ProjectStatus::iterator() + .filter(|x| !x.is_hidden()) + .map(|x| x.to_string()) + .collect::>(), + ) + .fetch(&mut *transaction) + .try_fold( + DashMap::new(), + |acc: DashMap>, r| { + acc.entry(r.id) + .or_default() + .insert(r.user_id, r.payouts_split); + async move { Ok(acc) } + }, + ) + .await?; + + for project_id in project_ids { + let team_members: HashMap = project_team_members + .remove(&project_id) + .unwrap_or((0, HashMap::new())) + .1; + let org_team_members: HashMap = project_org_members + .remove(&project_id) + .unwrap_or((0, HashMap::new())) + .1; + + let mut all_team_members = vec![]; + + for (user_id, payouts_split) in org_team_members { + if !team_members.contains_key(&user_id) { + all_team_members.push((user_id, payouts_split)); + } + } + for (user_id, payouts_split) in team_members { + all_team_members.push((user_id, payouts_split)); + } + + // if all team members are set to zero, we treat as an equal revenue distribution + if all_team_members.iter().all(|x| x.1 == Decimal::ZERO) { + all_team_members + .iter_mut() + .for_each(|x| x.1 = Decimal::from(1)); + } + + projects_map.insert( + project_id, + Project { + team_members: all_team_members, + }, + ); + } + + let aditude_res = make_aditude_request( + &["METRIC_IMPRESSIONS", "METRIC_REVENUE"], + "Yesterday", + "1d", + ) + .await?; + + let aditude_amount: Decimal = aditude_res + .iter() + .map(|x| { + x.points_list + .iter() + .filter_map(|x| x.metric.revenue) + .sum::() + }) + .sum(); + let aditude_impressions: u128 = aditude_res + .iter() + .map(|x| { + x.points_list + .iter() + .filter_map(|x| x.metric.impressions) + .sum::() + }) + .sum(); + + // Modrinth's share of ad revenue + let modrinth_cut = Decimal::from(1) / Decimal::from(4); + // Clean.io fee (ad antimalware). Per 1000 impressions. + let clean_io_fee = Decimal::from(8) / Decimal::from(1000); + + let net_revenue = aditude_amount + - (clean_io_fee * Decimal::from(aditude_impressions) + / Decimal::from(1000)); + + let payout = net_revenue * (Decimal::from(1) - modrinth_cut); + + // Ad payouts are Net 60 from the end of the month + let available = { + let now = Utc::now().date_naive(); + + let year = now.year(); + let month = now.month(); + + // Get the first day of the next month + let last_day_of_month = if month == 12 { + Utc.with_ymd_and_hms(year + 1, 1, 1, 0, 0, 0).unwrap() + } else { + Utc.with_ymd_and_hms(year, month + 1, 1, 0, 0, 0).unwrap() + }; + + last_day_of_month + Duration::days(59) + }; + + let ( + mut insert_user_ids, + mut insert_project_ids, + mut insert_payouts, + mut insert_starts, + mut insert_availables, + ) = (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new()); + for (id, project) in projects_map { + if let Some(value) = &multipliers.values.get(&(id as u64)) { + let project_multiplier: Decimal = + Decimal::from(**value) / Decimal::from(multipliers.sum); + + let sum_splits: Decimal = + project.team_members.iter().map(|x| x.1).sum(); + + if sum_splits > Decimal::ZERO { + for (user_id, split) in project.team_members { + let payout: Decimal = + payout * project_multiplier * (split / sum_splits); + + if payout > Decimal::ZERO { + insert_user_ids.push(user_id); + insert_project_ids.push(id); + insert_payouts.push(payout); + insert_starts.push(start); + insert_availables.push(available); + } + } + } + } + } + + sqlx::query!( + " + INSERT INTO payouts_values (user_id, mod_id, amount, created, date_available) + SELECT * FROM UNNEST ($1::bigint[], $2::bigint[], $3::numeric[], $4::timestamptz[], $5::timestamptz[]) + ", + &insert_user_ids[..], + &insert_project_ids[..], + &insert_payouts[..], + &insert_starts[..], + &insert_availables[..] + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + Ok(()) +} + +// Used for testing, should be the same as the above function +pub async fn insert_payouts( + insert_user_ids: Vec, + insert_project_ids: Vec, + insert_payouts: Vec, + insert_starts: Vec>, + insert_availables: Vec>, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> sqlx::Result { + sqlx::query!( + " + INSERT INTO payouts_values (user_id, mod_id, amount, created, date_available) + SELECT * FROM UNNEST ($1::bigint[], $2::bigint[], $3::numeric[], $4::timestamptz[], $5::timestamptz[]) + ", + &insert_user_ids[..], + &insert_project_ids[..], + &insert_payouts[..], + &insert_starts[..], + &insert_availables[..], + ) + .execute(&mut **transaction) + .await +} diff --git a/apps/labrinth/src/queue/session.rs b/apps/labrinth/src/queue/session.rs new file mode 100644 index 000000000..1c3ce7bee --- /dev/null +++ b/apps/labrinth/src/queue/session.rs @@ -0,0 +1,177 @@ +use crate::database::models::pat_item::PersonalAccessToken; +use crate::database::models::session_item::Session; +use crate::database::models::{ + DatabaseError, OAuthAccessTokenId, PatId, SessionId, UserId, +}; +use crate::database::redis::RedisPool; +use crate::routes::internal::session::SessionMetadata; +use chrono::Utc; +use itertools::Itertools; +use sqlx::PgPool; +use std::collections::{HashMap, HashSet}; +use tokio::sync::Mutex; + +pub struct AuthQueue { + session_queue: Mutex>, + pat_queue: Mutex>, + oauth_access_token_queue: Mutex>, +} + +impl Default for AuthQueue { + fn default() -> Self { + Self::new() + } +} + +// Batches session accessing transactions every 30 seconds +impl AuthQueue { + pub fn new() -> Self { + AuthQueue { + session_queue: Mutex::new(HashMap::with_capacity(1000)), + pat_queue: Mutex::new(HashSet::with_capacity(1000)), + oauth_access_token_queue: Mutex::new(HashSet::with_capacity(1000)), + } + } + pub async fn add_session(&self, id: SessionId, metadata: SessionMetadata) { + self.session_queue.lock().await.insert(id, metadata); + } + + pub async fn add_pat(&self, id: PatId) { + self.pat_queue.lock().await.insert(id); + } + + pub async fn add_oauth_access_token( + &self, + id: crate::database::models::OAuthAccessTokenId, + ) { + self.oauth_access_token_queue.lock().await.insert(id); + } + + pub async fn take_sessions(&self) -> HashMap { + let mut queue = self.session_queue.lock().await; + let len = queue.len(); + + std::mem::replace(&mut queue, HashMap::with_capacity(len)) + } + + pub async fn take_hashset(queue: &Mutex>) -> HashSet { + let mut queue = queue.lock().await; + let len = queue.len(); + + std::mem::replace(&mut queue, HashSet::with_capacity(len)) + } + + pub async fn index( + &self, + pool: &PgPool, + redis: &RedisPool, + ) -> Result<(), DatabaseError> { + let session_queue = self.take_sessions().await; + let pat_queue = Self::take_hashset(&self.pat_queue).await; + let oauth_access_token_queue = + Self::take_hashset(&self.oauth_access_token_queue).await; + + if !session_queue.is_empty() + || !pat_queue.is_empty() + || !oauth_access_token_queue.is_empty() + { + let mut transaction = pool.begin().await?; + let mut clear_cache_sessions = Vec::new(); + + for (id, metadata) in session_queue { + clear_cache_sessions.push((Some(id), None, None)); + + sqlx::query!( + " + UPDATE sessions + SET last_login = $2, city = $3, country = $4, ip = $5, os = $6, platform = $7, user_agent = $8 + WHERE (id = $1) + ", + id as SessionId, + Utc::now(), + metadata.city, + metadata.country, + metadata.ip, + metadata.os, + metadata.platform, + metadata.user_agent, + ) + .execute(&mut *transaction) + .await?; + } + + use futures::TryStreamExt; + let expired_ids = sqlx::query!( + " + SELECT id, session, user_id + FROM sessions + WHERE refresh_expires <= NOW() + " + ) + .fetch(&mut *transaction) + .map_ok(|x| (SessionId(x.id), x.session, UserId(x.user_id))) + .try_collect::>() + .await?; + + for (id, session, user_id) in expired_ids { + clear_cache_sessions.push(( + Some(id), + Some(session), + Some(user_id), + )); + Session::remove(id, &mut transaction).await?; + } + + Session::clear_cache(clear_cache_sessions, redis).await?; + + let ids = pat_queue.iter().map(|id| id.0).collect_vec(); + let clear_cache_pats = pat_queue + .into_iter() + .map(|id| (Some(id), None, None)) + .collect_vec(); + sqlx::query!( + " + UPDATE pats + SET last_used = $2 + WHERE id IN + (SELECT * FROM UNNEST($1::bigint[])) + ", + &ids[..], + Utc::now(), + ) + .execute(&mut *transaction) + .await?; + + update_oauth_access_token_last_used( + oauth_access_token_queue, + &mut transaction, + ) + .await?; + + transaction.commit().await?; + PersonalAccessToken::clear_cache(clear_cache_pats, redis).await?; + } + + Ok(()) + } +} + +async fn update_oauth_access_token_last_used( + oauth_access_token_queue: HashSet, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result<(), DatabaseError> { + let ids = oauth_access_token_queue.iter().map(|id| id.0).collect_vec(); + sqlx::query!( + " + UPDATE oauth_access_tokens + SET last_used = $2 + WHERE id IN + (SELECT * FROM UNNEST($1::bigint[])) + ", + &ids[..], + Utc::now() + ) + .execute(&mut **transaction) + .await?; + Ok(()) +} diff --git a/apps/labrinth/src/queue/socket.rs b/apps/labrinth/src/queue/socket.rs new file mode 100644 index 000000000..5105cda3c --- /dev/null +++ b/apps/labrinth/src/queue/socket.rs @@ -0,0 +1,15 @@ +//! "Database" for Hydra +use actix_ws::Session; +use dashmap::DashMap; + +pub struct ActiveSockets { + pub auth_sockets: DashMap, +} + +impl Default for ActiveSockets { + fn default() -> Self { + Self { + auth_sockets: DashMap::new(), + } + } +} diff --git a/apps/labrinth/src/routes/analytics.rs b/apps/labrinth/src/routes/analytics.rs new file mode 100644 index 000000000..36fe6febc --- /dev/null +++ b/apps/labrinth/src/routes/analytics.rs @@ -0,0 +1,230 @@ +use crate::auth::get_user_from_headers; +use crate::database::redis::RedisPool; +use crate::models::analytics::{PageView, Playtime}; +use crate::models::pats::Scopes; +use crate::queue::analytics::AnalyticsQueue; +use crate::queue::maxmind::MaxMindIndexer; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use crate::util::date::get_current_tenths_of_ms; +use crate::util::env::parse_strings_from_var; +use actix_web::{post, web}; +use actix_web::{HttpRequest, HttpResponse}; +use serde::Deserialize; +use sqlx::PgPool; +use std::collections::HashMap; +use std::net::{AddrParseError, IpAddr, Ipv4Addr, Ipv6Addr}; +use std::sync::Arc; +use url::Url; + +pub const FILTERED_HEADERS: &[&str] = &[ + "authorization", + "cookie", + "modrinth-admin", + // we already retrieve/use these elsewhere- so they are unneeded + "user-agent", + "cf-connecting-ip", + "cf-ipcountry", + "x-forwarded-for", + "x-real-ip", + // We don't need the information vercel provides from its headers + "x-vercel-ip-city", + "x-vercel-ip-timezone", + "x-vercel-ip-longitude", + "x-vercel-proxy-signature", + "x-vercel-ip-country-region", + "x-vercel-forwarded-for", + "x-vercel-proxied-for", + "x-vercel-proxy-signature-ts", + "x-vercel-ip-latitude", + "x-vercel-ip-country", +]; + +pub fn convert_to_ip_v6(src: &str) -> Result { + let ip_addr: IpAddr = src.parse()?; + + Ok(match ip_addr { + IpAddr::V4(x) => x.to_ipv6_mapped(), + IpAddr::V6(x) => x, + }) +} + +#[derive(Deserialize)] +pub struct UrlInput { + url: String, +} + +//this route should be behind the cloudflare WAF to prevent non-browsers from calling it +#[post("view")] +pub async fn page_view_ingest( + req: HttpRequest, + maxmind: web::Data>, + analytics_queue: web::Data>, + session_queue: web::Data, + url_input: web::Json, + pool: web::Data, + redis: web::Data, +) -> Result { + let user = + get_user_from_headers(&req, &**pool, &redis, &session_queue, None) + .await + .ok(); + let conn_info = req.connection_info().peer_addr().map(|x| x.to_string()); + + let url = Url::parse(&url_input.url).map_err(|_| { + ApiError::InvalidInput("invalid page view URL specified!".to_string()) + })?; + + let domain = url.host_str().ok_or_else(|| { + ApiError::InvalidInput("invalid page view URL specified!".to_string()) + })?; + + let allowed_origins = + parse_strings_from_var("CORS_ALLOWED_ORIGINS").unwrap_or_default(); + if !(domain.ends_with(".modrinth.com") + || domain == "modrinth.com" + || allowed_origins.contains(&"*".to_string())) + { + return Err(ApiError::InvalidInput( + "invalid page view URL specified!".to_string(), + )); + } + + let headers = req + .headers() + .into_iter() + .map(|(key, val)| { + ( + key.to_string().to_lowercase(), + val.to_str().unwrap_or_default().to_string(), + ) + }) + .collect::>(); + + let ip = convert_to_ip_v6( + if let Some(header) = headers.get("cf-connecting-ip") { + header + } else { + conn_info.as_deref().unwrap_or_default() + }, + ) + .unwrap_or_else(|_| Ipv4Addr::new(127, 0, 0, 1).to_ipv6_mapped()); + + let mut view = PageView { + recorded: get_current_tenths_of_ms(), + domain: domain.to_string(), + site_path: url.path().to_string(), + user_id: 0, + project_id: 0, + ip, + country: maxmind.query(ip).await.unwrap_or_default(), + user_agent: headers.get("user-agent").cloned().unwrap_or_default(), + headers: headers + .into_iter() + .filter(|x| !FILTERED_HEADERS.contains(&&*x.0)) + .collect(), + monetized: true, + }; + + if let Some(segments) = url.path_segments() { + let segments_vec = segments.collect::>(); + + if segments_vec.len() >= 2 { + const PROJECT_TYPES: &[&str] = &[ + "mod", + "modpack", + "plugin", + "resourcepack", + "shader", + "datapack", + ]; + + if PROJECT_TYPES.contains(&segments_vec[0]) { + let project = crate::database::models::Project::get( + segments_vec[1], + &**pool, + &redis, + ) + .await?; + + if let Some(project) = project { + view.project_id = project.inner.id.0 as u64; + } + } + } + } + + if let Some((_, user)) = user { + view.user_id = user.id.0; + } + + analytics_queue.add_view(view); + + Ok(HttpResponse::NoContent().body("")) +} + +#[derive(Deserialize, Debug)] +pub struct PlaytimeInput { + seconds: u16, + loader: String, + game_version: String, + parent: Option, +} + +#[post("playtime")] +pub async fn playtime_ingest( + req: HttpRequest, + analytics_queue: web::Data>, + session_queue: web::Data, + playtime_input: web::Json< + HashMap, + >, + pool: web::Data, + redis: web::Data, +) -> Result { + let (_, user) = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PERFORM_ANALYTICS]), + ) + .await?; + + let playtimes = playtime_input.0; + + if playtimes.len() > 2000 { + return Err(ApiError::InvalidInput( + "Too much playtime entered for version!".to_string(), + )); + } + + let versions = crate::database::models::Version::get_many( + &playtimes.iter().map(|x| (*x.0).into()).collect::>(), + &**pool, + &redis, + ) + .await?; + + for (id, playtime) in playtimes { + if playtime.seconds > 300 { + continue; + } + + if let Some(version) = versions.iter().find(|x| id == x.inner.id.into()) + { + analytics_queue.add_playtime(Playtime { + recorded: get_current_tenths_of_ms(), + seconds: playtime.seconds as u64, + user_id: user.id.0, + project_id: version.inner.project_id.0 as u64, + version_id: version.inner.id.0 as u64, + loader: playtime.loader, + game_version: playtime.game_version, + parent: playtime.parent.map(|x| x.0).unwrap_or(0), + }); + } + } + + Ok(HttpResponse::NoContent().finish()) +} diff --git a/apps/labrinth/src/routes/index.rs b/apps/labrinth/src/routes/index.rs new file mode 100644 index 000000000..8e332fe33 --- /dev/null +++ b/apps/labrinth/src/routes/index.rs @@ -0,0 +1,14 @@ +use actix_web::{get, HttpResponse}; +use serde_json::json; + +#[get("/")] +pub async fn index_get() -> HttpResponse { + let data = json!({ + "name": "modrinth-labrinth", + "version": env!("CARGO_PKG_VERSION"), + "documentation": "https://docs.modrinth.com", + "about": "Welcome traveler!" + }); + + HttpResponse::Ok().json(data) +} diff --git a/apps/labrinth/src/routes/internal/admin.rs b/apps/labrinth/src/routes/internal/admin.rs new file mode 100644 index 000000000..411abb12d --- /dev/null +++ b/apps/labrinth/src/routes/internal/admin.rs @@ -0,0 +1,160 @@ +use crate::auth::validate::get_user_record_from_bearer_token; +use crate::database::redis::RedisPool; +use crate::models::analytics::Download; +use crate::models::ids::ProjectId; +use crate::models::pats::Scopes; +use crate::queue::analytics::AnalyticsQueue; +use crate::queue::maxmind::MaxMindIndexer; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use crate::search::SearchConfig; +use crate::util::date::get_current_tenths_of_ms; +use crate::util::guards::admin_key_guard; +use actix_web::{patch, post, web, HttpRequest, HttpResponse}; +use serde::Deserialize; +use sqlx::PgPool; +use std::collections::HashMap; +use std::net::Ipv4Addr; +use std::sync::Arc; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("admin") + .service(count_download) + .service(force_reindex), + ); +} + +#[derive(Deserialize)] +pub struct DownloadBody { + pub url: String, + pub project_id: ProjectId, + pub version_name: String, + + pub ip: String, + pub headers: HashMap, +} + +// This is an internal route, cannot be used without key +#[patch("/_count-download", guard = "admin_key_guard")] +#[allow(clippy::too_many_arguments)] +pub async fn count_download( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + maxmind: web::Data>, + analytics_queue: web::Data>, + session_queue: web::Data, + download_body: web::Json, +) -> Result { + let token = download_body + .headers + .iter() + .find(|x| x.0.to_lowercase() == "authorization") + .map(|x| &**x.1); + + let user = get_user_record_from_bearer_token( + &req, + token, + &**pool, + &redis, + &session_queue, + ) + .await + .ok() + .flatten(); + + let project_id: crate::database::models::ids::ProjectId = + download_body.project_id.into(); + + let id_option = crate::models::ids::base62_impl::parse_base62( + &download_body.version_name, + ) + .ok() + .map(|x| x as i64); + + let (version_id, project_id) = if let Some(version) = sqlx::query!( + " + SELECT v.id id, v.mod_id mod_id FROM files f + INNER JOIN versions v ON v.id = f.version_id + WHERE f.url = $1 + ", + download_body.url, + ) + .fetch_optional(pool.as_ref()) + .await? + { + (version.id, version.mod_id) + } else if let Some(version) = sqlx::query!( + " + SELECT id, mod_id FROM versions + WHERE ((version_number = $1 OR id = $3) AND mod_id = $2) + ", + download_body.version_name, + project_id as crate::database::models::ids::ProjectId, + id_option + ) + .fetch_optional(pool.as_ref()) + .await? + { + (version.id, version.mod_id) + } else { + return Err(ApiError::InvalidInput( + "Specified version does not exist!".to_string(), + )); + }; + + let url = url::Url::parse(&download_body.url).map_err(|_| { + ApiError::InvalidInput("invalid download URL specified!".to_string()) + })?; + + let ip = crate::routes::analytics::convert_to_ip_v6(&download_body.ip) + .unwrap_or_else(|_| Ipv4Addr::new(127, 0, 0, 1).to_ipv6_mapped()); + + analytics_queue.add_download(Download { + recorded: get_current_tenths_of_ms(), + domain: url.host_str().unwrap_or_default().to_string(), + site_path: url.path().to_string(), + user_id: user + .and_then(|(scopes, x)| { + if scopes.contains(Scopes::PERFORM_ANALYTICS) { + Some(x.id.0 as u64) + } else { + None + } + }) + .unwrap_or(0), + project_id: project_id as u64, + version_id: version_id as u64, + ip, + country: maxmind.query(ip).await.unwrap_or_default(), + user_agent: download_body + .headers + .get("user-agent") + .cloned() + .unwrap_or_default(), + headers: download_body + .headers + .clone() + .into_iter() + .filter(|x| { + !crate::routes::analytics::FILTERED_HEADERS + .contains(&&*x.0.to_lowercase()) + }) + .collect(), + }); + + Ok(HttpResponse::NoContent().body("")) +} + +#[post("/_force_reindex", guard = "admin_key_guard")] +pub async fn force_reindex( + pool: web::Data, + redis: web::Data, + config: web::Data, +) -> Result { + use crate::search::indexing::index_projects; + let redis = redis.get_ref(); + index_projects(pool.as_ref().clone(), redis.clone(), &config).await?; + Ok(HttpResponse::NoContent().finish()) +} diff --git a/apps/labrinth/src/routes/internal/billing.rs b/apps/labrinth/src/routes/internal/billing.rs new file mode 100644 index 000000000..188d30812 --- /dev/null +++ b/apps/labrinth/src/routes/internal/billing.rs @@ -0,0 +1,2030 @@ +use crate::auth::{get_user_from_headers, send_email}; +use crate::database::models::charge_item::ChargeItem; +use crate::database::models::{ + generate_charge_id, generate_user_subscription_id, product_item, + user_subscription_item, +}; +use crate::database::redis::RedisPool; +use crate::models::billing::{ + Charge, ChargeStatus, ChargeType, Price, PriceDuration, Product, + ProductMetadata, ProductPrice, SubscriptionMetadata, SubscriptionStatus, + UserSubscription, +}; +use crate::models::ids::base62_impl::{parse_base62, to_base62}; +use crate::models::pats::Scopes; +use crate::models::users::Badges; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; +use chrono::Utc; +use log::{info, warn}; +use rust_decimal::prelude::ToPrimitive; +use rust_decimal::Decimal; +use serde::Serialize; +use serde_with::serde_derive::Deserialize; +use sqlx::{PgPool, Postgres, Transaction}; +use std::collections::{HashMap, HashSet}; +use std::str::FromStr; +use stripe::{ + CreateCustomer, CreatePaymentIntent, CreateSetupIntent, + CreateSetupIntentAutomaticPaymentMethods, + CreateSetupIntentAutomaticPaymentMethodsAllowRedirects, Currency, + CustomerId, CustomerInvoiceSettings, CustomerPaymentMethodRetrieval, + EventObject, EventType, PaymentIntentOffSession, + PaymentIntentSetupFutureUsage, PaymentMethodId, SetupIntent, + UpdateCustomer, Webhook, +}; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("billing") + .service(products) + .service(subscriptions) + .service(user_customer) + .service(edit_subscription) + .service(payment_methods) + .service(add_payment_method_flow) + .service(edit_payment_method) + .service(remove_payment_method) + .service(charges) + .service(initiate_payment) + .service(stripe_webhook), + ); +} + +#[get("products")] +pub async fn products( + pool: web::Data, + redis: web::Data, +) -> Result { + let products = product_item::QueryProduct::list(&**pool, &redis).await?; + + let products = products + .into_iter() + .map(|x| Product { + id: x.id.into(), + metadata: x.metadata, + prices: x + .prices + .into_iter() + .map(|x| ProductPrice { + id: x.id.into(), + product_id: x.product_id.into(), + currency_code: x.currency_code, + prices: x.prices, + }) + .collect(), + unitary: x.unitary, + }) + .collect::>(); + + Ok(HttpResponse::Ok().json(products)) +} + +#[get("subscriptions")] +pub async fn subscriptions( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::SESSION_ACCESS]), + ) + .await? + .1; + + let subscriptions = + user_subscription_item::UserSubscriptionItem::get_all_user( + user.id.into(), + &**pool, + ) + .await? + .into_iter() + .map(UserSubscription::from) + .collect::>(); + + Ok(HttpResponse::Ok().json(subscriptions)) +} + +#[derive(Deserialize)] +pub struct SubscriptionEdit { + pub interval: Option, + pub payment_method: Option, + pub cancelled: Option, + pub product: Option, +} + +#[patch("subscription/{id}")] +pub async fn edit_subscription( + req: HttpRequest, + info: web::Path<(crate::models::ids::UserSubscriptionId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + edit_subscription: web::Json, + stripe_client: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::SESSION_ACCESS]), + ) + .await? + .1; + + let (id,) = info.into_inner(); + + if let Some(subscription) = + user_subscription_item::UserSubscriptionItem::get(id.into(), &**pool) + .await? + { + if subscription.user_id != user.id.into() && !user.role.is_admin() { + return Err(ApiError::NotFound); + } + + let mut transaction = pool.begin().await?; + + let mut open_charge = + crate::database::models::charge_item::ChargeItem::get_open_subscription( + subscription.id, + &mut *transaction, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "Could not find open charge for this subscription".to_string(), + ) + })?; + + let current_price = product_item::ProductPriceItem::get( + subscription.price_id, + &mut *transaction, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "Could not find current product price".to_string(), + ) + })?; + + if let Some(cancelled) = &edit_subscription.cancelled { + if open_charge.status != ChargeStatus::Open + && open_charge.status != ChargeStatus::Cancelled + { + return Err(ApiError::InvalidInput( + "You may not change the status of this subscription!" + .to_string(), + )); + } + + if *cancelled { + open_charge.status = ChargeStatus::Cancelled; + } else { + open_charge.status = ChargeStatus::Open; + } + } + + if let Some(interval) = &edit_subscription.interval { + if let Price::Recurring { intervals } = ¤t_price.prices { + if let Some(price) = intervals.get(interval) { + open_charge.subscription_interval = Some(*interval); + open_charge.amount = *price as i64; + } else { + return Err(ApiError::InvalidInput( + "Interval is not valid for this subscription!" + .to_string(), + )); + } + } + } + + let intent = if let Some(product_id) = &edit_subscription.product { + let product_price = + product_item::ProductPriceItem::get_all_product_prices( + (*product_id).into(), + &mut *transaction, + ) + .await? + .into_iter() + .find(|x| x.currency_code == current_price.currency_code) + .ok_or_else(|| { + ApiError::InvalidInput( + "Could not find a valid price for your currency code!" + .to_string(), + ) + })?; + + if product_price.id == current_price.id { + return Err(ApiError::InvalidInput( + "You may not change the price of this subscription!" + .to_string(), + )); + } + + let interval = open_charge.due - Utc::now(); + let duration = PriceDuration::iterator() + .min_by_key(|x| { + (x.duration().num_seconds() - interval.num_seconds()).abs() + }) + .unwrap_or(PriceDuration::Monthly); + + let current_amount = match ¤t_price.prices { + Price::OneTime { price } => *price, + Price::Recurring { intervals } => *intervals.get(&duration).ok_or_else(|| { + ApiError::InvalidInput( + "Could not find a valid price for the user's duration".to_string(), + ) + })?, + }; + + let amount = match &product_price.prices { + Price::OneTime { price } => *price, + Price::Recurring { intervals } => *intervals.get(&duration).ok_or_else(|| { + ApiError::InvalidInput( + "Could not find a valid price for the user's duration".to_string(), + ) + })?, + }; + + let complete = Decimal::from(interval.num_seconds()) + / Decimal::from(duration.duration().num_seconds()); + let proration = (Decimal::from(amount - current_amount) * complete) + .floor() + .to_i32() + .ok_or_else(|| { + ApiError::InvalidInput( + "Could not convert proration to i32".to_string(), + ) + })?; + + // TODO: Add downgrading plans + if proration <= 0 { + return Err(ApiError::InvalidInput( + "You may not downgrade plans!".to_string(), + )); + } + + let charge_id = generate_charge_id(&mut transaction).await?; + let charge = ChargeItem { + id: charge_id, + user_id: user.id.into(), + price_id: product_price.id, + amount: proration as i64, + currency_code: current_price.currency_code.clone(), + status: ChargeStatus::Processing, + due: Utc::now(), + last_attempt: None, + type_: ChargeType::Proration, + subscription_id: Some(subscription.id), + subscription_interval: Some(duration), + }; + + let customer_id = get_or_create_customer( + user.id, + user.stripe_customer_id.as_deref(), + user.email.as_deref(), + &stripe_client, + &pool, + &redis, + ) + .await?; + + let currency = + Currency::from_str(¤t_price.currency_code.to_lowercase()) + .map_err(|_| { + ApiError::InvalidInput( + "Invalid currency code".to_string(), + ) + })?; + + let mut intent = + CreatePaymentIntent::new(proration as i64, currency); + + let mut metadata = HashMap::new(); + metadata + .insert("modrinth_user_id".to_string(), to_base62(user.id.0)); + + intent.customer = Some(customer_id); + intent.metadata = Some(metadata); + intent.receipt_email = user.email.as_deref(); + intent.setup_future_usage = + Some(PaymentIntentSetupFutureUsage::OffSession); + + if let Some(payment_method) = &edit_subscription.payment_method { + let payment_method_id = + if let Ok(id) = PaymentMethodId::from_str(payment_method) { + id + } else { + return Err(ApiError::InvalidInput( + "Invalid payment method id".to_string(), + )); + }; + intent.payment_method = Some(payment_method_id); + } + + charge.upsert(&mut transaction).await?; + + Some(( + proration, + 0, + stripe::PaymentIntent::create(&stripe_client, intent).await?, + )) + } else { + None + }; + + open_charge.upsert(&mut transaction).await?; + + transaction.commit().await?; + + if let Some((amount, tax, payment_intent)) = intent { + Ok(HttpResponse::Ok().json(serde_json::json!({ + "payment_intent_id": payment_intent.id, + "client_secret": payment_intent.client_secret, + "tax": tax, + "total": amount + }))) + } else { + Ok(HttpResponse::NoContent().body("")) + } + } else { + Err(ApiError::NotFound) + } +} + +#[get("customer")] +pub async fn user_customer( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + stripe_client: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::SESSION_ACCESS]), + ) + .await? + .1; + + let customer_id = get_or_create_customer( + user.id, + user.stripe_customer_id.as_deref(), + user.email.as_deref(), + &stripe_client, + &pool, + &redis, + ) + .await?; + let customer = + stripe::Customer::retrieve(&stripe_client, &customer_id, &[]).await?; + + Ok(HttpResponse::Ok().json(customer)) +} + +#[get("payments")] +pub async fn charges( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::SESSION_ACCESS]), + ) + .await? + .1; + + let charges = + crate::database::models::charge_item::ChargeItem::get_from_user( + user.id.into(), + &**pool, + ) + .await?; + + Ok(HttpResponse::Ok().json( + charges + .into_iter() + .map(|x| Charge { + id: x.id.into(), + user_id: x.user_id.into(), + price_id: x.price_id.into(), + amount: x.amount, + currency_code: x.currency_code, + status: x.status, + due: x.due, + last_attempt: x.last_attempt, + type_: x.type_, + subscription_id: x.subscription_id.map(|x| x.into()), + subscription_interval: x.subscription_interval, + }) + .collect::>(), + )) +} + +#[post("payment_method")] +pub async fn add_payment_method_flow( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + stripe_client: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::SESSION_ACCESS]), + ) + .await? + .1; + + let customer = get_or_create_customer( + user.id, + user.stripe_customer_id.as_deref(), + user.email.as_deref(), + &stripe_client, + &pool, + &redis, + ) + .await?; + + let intent = SetupIntent::create( + &stripe_client, + CreateSetupIntent { + customer: Some(customer), + automatic_payment_methods: Some(CreateSetupIntentAutomaticPaymentMethods { + allow_redirects: Some( + CreateSetupIntentAutomaticPaymentMethodsAllowRedirects::Never, + ), + enabled: true, + }), + ..Default::default() + }, + ) + .await?; + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "client_secret": intent.client_secret + }))) +} + +#[derive(Deserialize)] +pub struct EditPaymentMethod { + pub primary: bool, +} + +#[patch("payment_method/{id}")] +pub async fn edit_payment_method( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + stripe_client: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::SESSION_ACCESS]), + ) + .await? + .1; + + let (id,) = info.into_inner(); + + let payment_method_id = if let Ok(id) = PaymentMethodId::from_str(&id) { + id + } else { + return Err(ApiError::NotFound); + }; + + let customer = get_or_create_customer( + user.id, + user.stripe_customer_id.as_deref(), + user.email.as_deref(), + &stripe_client, + &pool, + &redis, + ) + .await?; + + let payment_method = stripe::PaymentMethod::retrieve( + &stripe_client, + &payment_method_id, + &[], + ) + .await?; + + if payment_method + .customer + .map(|x| x.id() == customer) + .unwrap_or(false) + || user.role.is_admin() + { + stripe::Customer::update( + &stripe_client, + &customer, + UpdateCustomer { + invoice_settings: Some(CustomerInvoiceSettings { + default_payment_method: Some(payment_method.id.to_string()), + ..Default::default() + }), + ..Default::default() + }, + ) + .await?; + + Ok(HttpResponse::NoContent().finish()) + } else { + Err(ApiError::NotFound) + } +} + +#[delete("payment_method/{id}")] +pub async fn remove_payment_method( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + stripe_client: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::SESSION_ACCESS]), + ) + .await? + .1; + + let (id,) = info.into_inner(); + + let payment_method_id = if let Ok(id) = PaymentMethodId::from_str(&id) { + id + } else { + return Err(ApiError::NotFound); + }; + + let customer = get_or_create_customer( + user.id, + user.stripe_customer_id.as_deref(), + user.email.as_deref(), + &stripe_client, + &pool, + &redis, + ) + .await?; + + let payment_method = stripe::PaymentMethod::retrieve( + &stripe_client, + &payment_method_id, + &[], + ) + .await?; + + let user_subscriptions = + user_subscription_item::UserSubscriptionItem::get_all_user( + user.id.into(), + &**pool, + ) + .await?; + + if user_subscriptions + .iter() + .any(|x| x.status != SubscriptionStatus::Unprovisioned) + { + let customer = + stripe::Customer::retrieve(&stripe_client, &customer, &[]).await?; + + if customer + .invoice_settings + .and_then(|x| { + x.default_payment_method + .map(|x| x.id() == payment_method_id) + }) + .unwrap_or(false) + { + return Err(ApiError::InvalidInput( + "You may not remove the default payment method if you have active subscriptions!" + .to_string(), + )); + } + } + + if payment_method + .customer + .map(|x| x.id() == customer) + .unwrap_or(false) + || user.role.is_admin() + { + stripe::PaymentMethod::detach(&stripe_client, &payment_method_id) + .await?; + + Ok(HttpResponse::NoContent().finish()) + } else { + Err(ApiError::NotFound) + } +} + +#[get("payment_methods")] +pub async fn payment_methods( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + stripe_client: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::SESSION_ACCESS]), + ) + .await? + .1; + + if let Some(customer_id) = user + .stripe_customer_id + .as_ref() + .and_then(|x| stripe::CustomerId::from_str(x).ok()) + { + let methods = stripe::Customer::retrieve_payment_methods( + &stripe_client, + &customer_id, + CustomerPaymentMethodRetrieval { + limit: Some(100), + ..Default::default() + }, + ) + .await?; + + Ok(HttpResponse::Ok().json(methods.data)) + } else { + Ok(HttpResponse::NoContent().finish()) + } +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PaymentRequestType { + PaymentMethod { id: String }, + ConfirmationToken { token: String }, +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ChargeRequestType { + Existing { + id: crate::models::ids::ChargeId, + }, + New { + product_id: crate::models::ids::ProductId, + interval: Option, + }, +} + +#[derive(Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum PaymentRequestMetadata { + Pyro { + server_name: Option, + source: serde_json::Value, + }, +} + +#[derive(Deserialize)] +pub struct PaymentRequest { + #[serde(flatten)] + pub type_: PaymentRequestType, + pub charge: ChargeRequestType, + pub existing_payment_intent: Option, + pub metadata: Option, +} + +fn infer_currency_code(country: &str) -> String { + match country { + "US" => "USD", + "GB" => "GBP", + "EU" => "EUR", + "AT" => "EUR", + "BE" => "EUR", + "CY" => "EUR", + "EE" => "EUR", + "FI" => "EUR", + "FR" => "EUR", + "DE" => "EUR", + "GR" => "EUR", + "IE" => "EUR", + "IT" => "EUR", + "LV" => "EUR", + "LT" => "EUR", + "LU" => "EUR", + "MT" => "EUR", + "NL" => "EUR", + "PT" => "EUR", + "SK" => "EUR", + "SI" => "EUR", + "RU" => "RUB", + "BR" => "BRL", + "JP" => "JPY", + "ID" => "IDR", + "MY" => "MYR", + "PH" => "PHP", + "TH" => "THB", + "VN" => "VND", + "KR" => "KRW", + "TR" => "TRY", + "UA" => "UAH", + "MX" => "MXN", + "CA" => "CAD", + "NZ" => "NZD", + "NO" => "NOK", + "PL" => "PLN", + "CH" => "CHF", + "LI" => "CHF", + "IN" => "INR", + "CL" => "CLP", + "PE" => "PEN", + "CO" => "COP", + "ZA" => "ZAR", + "HK" => "HKD", + "AR" => "ARS", + "KZ" => "KZT", + "UY" => "UYU", + "CN" => "CNY", + "AU" => "AUD", + "TW" => "TWD", + "SA" => "SAR", + "QA" => "QAR", + _ => "USD", + } + .to_string() +} + +#[post("payment")] +pub async fn initiate_payment( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + stripe_client: web::Data, + payment_request: web::Json, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::SESSION_ACCESS]), + ) + .await? + .1; + + let (user_country, payment_method) = match &payment_request.type_ { + PaymentRequestType::PaymentMethod { id } => { + let payment_method_id = stripe::PaymentMethodId::from_str(id) + .map_err(|_| { + ApiError::InvalidInput( + "Invalid payment method id".to_string(), + ) + })?; + + let payment_method = stripe::PaymentMethod::retrieve( + &stripe_client, + &payment_method_id, + &[], + ) + .await?; + + let country = payment_method + .billing_details + .address + .as_ref() + .and_then(|x| x.country.clone()); + + (country, payment_method) + } + PaymentRequestType::ConfirmationToken { token } => { + #[derive(Deserialize)] + struct ConfirmationToken { + payment_method_preview: Option, + } + + let mut confirmation: serde_json::Value = stripe_client + .get(&format!("confirmation_tokens/{token}")) + .await?; + + // We patch the JSONs to support the PaymentMethod struct + let p: json_patch::Patch = serde_json::from_value(serde_json::json!([ + { "op": "add", "path": "/payment_method_preview/id", "value": "pm_1PirTdJygY5LJFfKmPIaM1N1" }, + { "op": "add", "path": "/payment_method_preview/created", "value": 1723183475 }, + { "op": "add", "path": "/payment_method_preview/livemode", "value": false } + ])).unwrap(); + json_patch::patch(&mut confirmation, &p).unwrap(); + + let confirmation: ConfirmationToken = + serde_json::from_value(confirmation)?; + + let payment_method = + confirmation.payment_method_preview.ok_or_else(|| { + ApiError::InvalidInput( + "Confirmation token is missing payment method!" + .to_string(), + ) + })?; + + let country = payment_method + .billing_details + .address + .as_ref() + .and_then(|x| x.country.clone()); + + (country, payment_method) + } + }; + + let country = user_country.as_deref().unwrap_or("US"); + let recommended_currency_code = infer_currency_code(country); + + let (price, currency_code, interval, price_id, charge_id) = + match payment_request.charge { + ChargeRequestType::Existing { id } => { + let charge = + crate::database::models::charge_item::ChargeItem::get( + id.into(), + &**pool, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "Specified charge could not be found!".to_string(), + ) + })?; + + ( + charge.amount, + charge.currency_code, + charge.subscription_interval, + charge.price_id, + Some(id), + ) + } + ChargeRequestType::New { + product_id, + interval, + } => { + let product = + product_item::ProductItem::get(product_id.into(), &**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "Specified product could not be found!" + .to_string(), + ) + })?; + + let mut product_prices = + product_item::ProductPriceItem::get_all_product_prices( + product.id, &**pool, + ) + .await?; + + let price_item = if let Some(pos) = product_prices + .iter() + .position(|x| x.currency_code == recommended_currency_code) + { + product_prices.remove(pos) + } else if let Some(pos) = + product_prices.iter().position(|x| x.currency_code == "USD") + { + product_prices.remove(pos) + } else { + return Err(ApiError::InvalidInput( + "Could not find a valid price for the user's country" + .to_string(), + )); + }; + + let price = match price_item.prices { + Price::OneTime { price } => price, + Price::Recurring { ref intervals } => { + let interval = interval.ok_or_else(|| { + ApiError::InvalidInput( + "Could not find a valid interval for the user's country".to_string(), + ) + })?; + + *intervals.get(&interval).ok_or_else(|| { + ApiError::InvalidInput( + "Could not find a valid price for the user's country".to_string(), + ) + })? + } + }; + + if let Price::Recurring { .. } = price_item.prices { + if product.unitary { + let user_subscriptions = + user_subscription_item::UserSubscriptionItem::get_all_user( + user.id.into(), + &**pool, + ) + .await?; + + let user_products = + product_item::ProductPriceItem::get_many( + &user_subscriptions + .iter() + .filter(|x| { + x.status + == SubscriptionStatus::Provisioned + }) + .map(|x| x.price_id) + .collect::>(), + &**pool, + ) + .await?; + + if user_products + .into_iter() + .any(|x| x.product_id == product.id) + { + return Err(ApiError::InvalidInput( + "You are already subscribed to this product!" + .to_string(), + )); + } + } + } + + ( + price as i64, + price_item.currency_code, + interval, + price_item.id, + None, + ) + } + }; + + let customer = get_or_create_customer( + user.id, + user.stripe_customer_id.as_deref(), + user.email.as_deref(), + &stripe_client, + &pool, + &redis, + ) + .await?; + let stripe_currency = Currency::from_str(¤cy_code.to_lowercase()) + .map_err(|_| { + ApiError::InvalidInput("Invalid currency code".to_string()) + })?; + + if let Some(payment_intent_id) = &payment_request.existing_payment_intent { + let mut update_payment_intent = stripe::UpdatePaymentIntent { + amount: Some(price), + currency: Some(stripe_currency), + customer: Some(customer), + ..Default::default() + }; + + if let PaymentRequestType::PaymentMethod { .. } = payment_request.type_ + { + update_payment_intent.payment_method = + Some(payment_method.id.clone()); + } + + stripe::PaymentIntent::update( + &stripe_client, + payment_intent_id, + update_payment_intent, + ) + .await?; + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "price_id": to_base62(price_id.0 as u64), + "tax": 0, + "total": price, + "payment_method": payment_method, + }))) + } else { + let mut intent = CreatePaymentIntent::new(price, stripe_currency); + + let mut metadata = HashMap::new(); + metadata.insert("modrinth_user_id".to_string(), to_base62(user.id.0)); + + if let Some(payment_metadata) = &payment_request.metadata { + metadata.insert( + "modrinth_payment_metadata".to_string(), + serde_json::to_string(&payment_metadata)?, + ); + } + + if let Some(charge_id) = charge_id { + metadata.insert( + "modrinth_charge_id".to_string(), + to_base62(charge_id.0), + ); + } else { + let mut transaction = pool.begin().await?; + let charge_id = generate_charge_id(&mut transaction).await?; + let subscription_id = + generate_user_subscription_id(&mut transaction).await?; + + metadata.insert( + "modrinth_charge_id".to_string(), + to_base62(charge_id.0 as u64), + ); + metadata.insert( + "modrinth_subscription_id".to_string(), + to_base62(subscription_id.0 as u64), + ); + + metadata.insert( + "modrinth_price_id".to_string(), + to_base62(price_id.0 as u64), + ); + + if let Some(interval) = interval { + metadata.insert( + "modrinth_subscription_interval".to_string(), + interval.as_str().to_string(), + ); + } + } + + intent.customer = Some(customer); + intent.metadata = Some(metadata); + intent.receipt_email = user.email.as_deref(); + intent.setup_future_usage = + Some(PaymentIntentSetupFutureUsage::OffSession); + + if let PaymentRequestType::PaymentMethod { .. } = payment_request.type_ + { + intent.payment_method = Some(payment_method.id.clone()); + } + + let payment_intent = + stripe::PaymentIntent::create(&stripe_client, intent).await?; + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "payment_intent_id": payment_intent.id, + "client_secret": payment_intent.client_secret, + "price_id": to_base62(price_id.0 as u64), + "tax": 0, + "total": price, + "payment_method": payment_method, + }))) + } +} + +#[post("_stripe")] +pub async fn stripe_webhook( + req: HttpRequest, + payload: String, + pool: web::Data, + redis: web::Data, + stripe_client: web::Data, +) -> Result { + let stripe_signature = req + .headers() + .get("Stripe-Signature") + .and_then(|x| x.to_str().ok()) + .unwrap_or_default(); + + if let Ok(event) = Webhook::construct_event( + &payload, + stripe_signature, + &dotenvy::var("STRIPE_WEBHOOK_SECRET")?, + ) { + struct PaymentIntentMetadata { + pub user_item: crate::database::models::user_item::User, + pub product_price_item: product_item::ProductPriceItem, + pub product_item: product_item::ProductItem, + pub charge_item: crate::database::models::charge_item::ChargeItem, + pub user_subscription_item: + Option, + pub payment_metadata: Option, + } + + async fn get_payment_intent_metadata( + metadata: HashMap, + pool: &PgPool, + redis: &RedisPool, + charge_status: ChargeStatus, + transaction: &mut Transaction<'_, Postgres>, + ) -> Result { + 'metadata: { + let user_id = if let Some(user_id) = metadata + .get("modrinth_user_id") + .and_then(|x| parse_base62(x).ok()) + .map(|x| crate::database::models::ids::UserId(x as i64)) + { + user_id + } else { + break 'metadata; + }; + + let user = if let Some(user) = + crate::database::models::user_item::User::get_id( + user_id, pool, redis, + ) + .await? + { + user + } else { + break 'metadata; + }; + + let payment_metadata = metadata + .get("modrinth_payment_metadata") + .and_then(|x| serde_json::from_str(x).ok()); + + let charge_id = if let Some(charge_id) = metadata + .get("modrinth_charge_id") + .and_then(|x| parse_base62(x).ok()) + .map(|x| crate::database::models::ids::ChargeId(x as i64)) + { + charge_id + } else { + break 'metadata; + }; + + let (charge, price, product, subscription) = if let Some( + mut charge, + ) = + crate::database::models::charge_item::ChargeItem::get( + charge_id, pool, + ) + .await? + { + let price = if let Some(price) = + product_item::ProductPriceItem::get( + charge.price_id, + pool, + ) + .await? + { + price + } else { + break 'metadata; + }; + + let product = if let Some(product) = + product_item::ProductItem::get(price.product_id, pool) + .await? + { + product + } else { + break 'metadata; + }; + + charge.status = charge_status; + charge.last_attempt = Some(Utc::now()); + charge.upsert(transaction).await?; + + if let Some(subscription_id) = charge.subscription_id { + let mut subscription = if let Some(subscription) = + user_subscription_item::UserSubscriptionItem::get( + subscription_id, + pool, + ) + .await? + { + subscription + } else { + break 'metadata; + }; + + match charge.type_ { + ChargeType::OneTime | ChargeType::Subscription => { + if let Some(interval) = + charge.subscription_interval + { + subscription.interval = interval; + } + } + ChargeType::Proration => { + subscription.price_id = charge.price_id; + } + } + + subscription.upsert(transaction).await?; + + (charge, price, product, Some(subscription)) + } else { + (charge, price, product, None) + } + } else { + let price_id = if let Some(price_id) = metadata + .get("modrinth_price_id") + .and_then(|x| parse_base62(x).ok()) + .map(|x| { + crate::database::models::ids::ProductPriceId( + x as i64, + ) + }) { + price_id + } else { + break 'metadata; + }; + + let price = if let Some(price) = + product_item::ProductPriceItem::get(price_id, pool) + .await? + { + price + } else { + break 'metadata; + }; + + let product = if let Some(product) = + product_item::ProductItem::get(price.product_id, pool) + .await? + { + product + } else { + break 'metadata; + }; + + let (amount, subscription) = match &price.prices { + Price::OneTime { price } => (*price, None), + Price::Recurring { intervals } => { + let interval = if let Some(interval) = metadata + .get("modrinth_subscription_interval") + .map(|x| PriceDuration::from_string(x)) + { + interval + } else { + break 'metadata; + }; + + if let Some(price) = intervals.get(&interval) { + let subscription_id = if let Some(subscription_id) = metadata + .get("modrinth_subscription_id") + .and_then(|x| parse_base62(x).ok()) + .map(|x| { + crate::database::models::ids::UserSubscriptionId(x as i64) + }) { + subscription_id + } else { + break 'metadata; + }; + + let subscription = user_subscription_item::UserSubscriptionItem { + id: subscription_id, + user_id, + price_id, + interval, + created: Utc::now(), + status: SubscriptionStatus::Unprovisioned, + metadata: None, + }; + + if charge_status != ChargeStatus::Failed { + subscription.upsert(transaction).await?; + } + + (*price, Some(subscription)) + } else { + break 'metadata; + } + } + }; + + let charge = ChargeItem { + id: charge_id, + user_id, + price_id, + amount: amount as i64, + currency_code: price.currency_code.clone(), + status: charge_status, + due: Utc::now(), + last_attempt: Some(Utc::now()), + type_: if subscription.is_some() { + ChargeType::Subscription + } else { + ChargeType::OneTime + }, + subscription_id: subscription.as_ref().map(|x| x.id), + subscription_interval: subscription + .as_ref() + .map(|x| x.interval), + }; + + if charge_status != ChargeStatus::Failed { + charge.upsert(transaction).await?; + } + + (charge, price, product, subscription) + }; + + return Ok(PaymentIntentMetadata { + user_item: user, + product_price_item: price, + product_item: product, + charge_item: charge, + user_subscription_item: subscription, + payment_metadata, + }); + } + + Err(ApiError::InvalidInput( + "Webhook missing required webhook metadata!".to_string(), + )) + } + + match event.type_ { + EventType::PaymentIntentSucceeded => { + if let EventObject::PaymentIntent(payment_intent) = + event.data.object + { + let mut transaction = pool.begin().await?; + + let mut metadata = get_payment_intent_metadata( + payment_intent.metadata, + &pool, + &redis, + ChargeStatus::Succeeded, + &mut transaction, + ) + .await?; + + // Provision subscription + match metadata.product_item.metadata { + ProductMetadata::Midas => { + let badges = + metadata.user_item.badges | Badges::MIDAS; + + sqlx::query!( + " + UPDATE users + SET badges = $1 + WHERE (id = $2) + ", + badges.bits() as i64, + metadata.user_item.id + as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + } + ProductMetadata::Pyro { + ram, + cpu, + swap, + storage, + } => { + if let Some(ref subscription) = + metadata.user_subscription_item + { + let client = reqwest::Client::new(); + + if let Some(SubscriptionMetadata::Pyro { id }) = + &subscription.metadata + { + client + .post(format!( + "https://archon.pyro.host/modrinth/v0/servers/{}/unsuspend", + id + )) + .header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?) + .send() + .await? + .error_for_status()?; + + // TODO: Send plan upgrade request for proration + } else { + let (server_name, source) = if let Some( + PaymentRequestMetadata::Pyro { + ref server_name, + ref source, + }, + ) = + metadata.payment_metadata + { + (server_name.clone(), source.clone()) + } else { + // Create a server with the latest version of Minecraft + let minecraft_versions = crate::database::models::legacy_loader_fields::MinecraftGameVersion::list( + Some("release"), + None, + &**pool, + &redis, + ).await?; + + ( + None, + serde_json::json!({ + "loader": "Vanilla", + "game_version": minecraft_versions.first().map(|x| x.version.clone()), + "loader_version": "" + }), + ) + }; + + let server_name = server_name + .unwrap_or_else(|| { + format!( + "{}'s server", + metadata.user_item.username + ) + }); + + #[derive(Deserialize)] + struct PyroServerResponse { + uuid: String, + } + + let res = client + .post("https://archon.pyro.host/modrinth/v0/servers/create") + .header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?) + .json(&serde_json::json!({ + "user_id": to_base62(metadata.user_item.id.0 as u64), + "name": server_name, + "specs": { + "memory_mb": ram, + "cpu": cpu, + "swap_mb": swap, + "storage_mb": storage, + }, + "source": source, + })) + .send() + .await? + .error_for_status()? + .json::() + .await?; + + if let Some(ref mut subscription) = + metadata.user_subscription_item + { + subscription.metadata = + Some(SubscriptionMetadata::Pyro { + id: res.uuid, + }); + } + } + } + } + } + + if let Some(mut subscription) = + metadata.user_subscription_item + { + let open_charge = ChargeItem::get_open_subscription( + subscription.id, + &mut *transaction, + ) + .await?; + + let new_price = match metadata.product_price_item.prices { + Price::OneTime { price } => price, + Price::Recurring { intervals } => { + *intervals.get(&subscription.interval).ok_or_else(|| { + ApiError::InvalidInput( + "Could not find a valid price for the user's country" + .to_string(), + ) + })? + } + }; + + if let Some(mut charge) = open_charge { + charge.price_id = metadata.product_price_item.id; + charge.amount = new_price as i64; + + charge.upsert(&mut transaction).await?; + } else if metadata.charge_item.status + != ChargeStatus::Cancelled + { + let charge_id = + generate_charge_id(&mut transaction).await?; + ChargeItem { + id: charge_id, + user_id: metadata.user_item.id, + price_id: metadata.product_price_item.id, + amount: new_price as i64, + currency_code: metadata + .product_price_item + .currency_code, + status: ChargeStatus::Open, + due: if subscription.status + == SubscriptionStatus::Unprovisioned + { + Utc::now() + + subscription.interval.duration() + } else { + metadata.charge_item.due + + subscription.interval.duration() + }, + last_attempt: None, + type_: ChargeType::Subscription, + subscription_id: Some(subscription.id), + subscription_interval: Some( + subscription.interval, + ), + } + .upsert(&mut transaction) + .await?; + }; + + subscription.status = SubscriptionStatus::Provisioned; + subscription.upsert(&mut transaction).await?; + } + + transaction.commit().await?; + crate::database::models::user_item::User::clear_caches( + &[(metadata.user_item.id, None)], + &redis, + ) + .await?; + } + } + EventType::PaymentIntentProcessing => { + if let EventObject::PaymentIntent(payment_intent) = + event.data.object + { + let mut transaction = pool.begin().await?; + get_payment_intent_metadata( + payment_intent.metadata, + &pool, + &redis, + ChargeStatus::Processing, + &mut transaction, + ) + .await?; + transaction.commit().await?; + } + } + EventType::PaymentIntentPaymentFailed => { + if let EventObject::PaymentIntent(payment_intent) = + event.data.object + { + let mut transaction = pool.begin().await?; + + let metadata = get_payment_intent_metadata( + payment_intent.metadata, + &pool, + &redis, + ChargeStatus::Failed, + &mut transaction, + ) + .await?; + + if let Some(email) = metadata.user_item.email { + let money = rusty_money::Money::from_minor( + metadata.charge_item.amount, + rusty_money::iso::find( + &metadata.charge_item.currency_code, + ) + .unwrap_or(rusty_money::iso::USD), + ); + + let _ = send_email( + email, + "Payment Failed for Modrinth", + &format!("Our attempt to collect payment for {money} from the payment card on file was unsuccessful."), + "Please visit the following link below to update your payment method or contact your card provider. If the button does not work, you can copy the link and paste it into your browser.", + Some(("Update billing settings", &format!("{}/{}", dotenvy::var("SITE_URL")?, dotenvy::var("SITE_BILLING_PATH")?))), + ); + } + + transaction.commit().await?; + } + } + EventType::PaymentMethodAttached => { + if let EventObject::PaymentMethod(payment_method) = + event.data.object + { + if let Some(customer_id) = + payment_method.customer.map(|x| x.id()) + { + let customer = stripe::Customer::retrieve( + &stripe_client, + &customer_id, + &[], + ) + .await?; + + if !customer + .invoice_settings + .map(|x| x.default_payment_method.is_some()) + .unwrap_or(false) + { + stripe::Customer::update( + &stripe_client, + &customer_id, + UpdateCustomer { + invoice_settings: Some( + CustomerInvoiceSettings { + default_payment_method: Some( + payment_method.id.to_string(), + ), + ..Default::default() + }, + ), + ..Default::default() + }, + ) + .await?; + } + } + } + } + _ => {} + } + } else { + return Err(ApiError::InvalidInput( + "Webhook signature validation failed!".to_string(), + )); + } + + Ok(HttpResponse::Ok().finish()) +} + +async fn get_or_create_customer( + user_id: crate::models::ids::UserId, + stripe_customer_id: Option<&str>, + user_email: Option<&str>, + client: &stripe::Client, + pool: &PgPool, + redis: &RedisPool, +) -> Result { + if let Some(customer_id) = + stripe_customer_id.and_then(|x| stripe::CustomerId::from_str(x).ok()) + { + Ok(customer_id) + } else { + let mut metadata = HashMap::new(); + metadata.insert("modrinth_user_id".to_string(), to_base62(user_id.0)); + + let customer = stripe::Customer::create( + client, + CreateCustomer { + email: user_email, + metadata: Some(metadata), + ..Default::default() + }, + ) + .await?; + + sqlx::query!( + " + UPDATE users + SET stripe_customer_id = $1 + WHERE id = $2 + ", + customer.id.as_str(), + user_id.0 as i64 + ) + .execute(pool) + .await?; + + crate::database::models::user_item::User::clear_caches( + &[(user_id.into(), None)], + redis, + ) + .await?; + + Ok(customer.id) + } +} + +pub async fn subscription_task(pool: PgPool, redis: RedisPool) { + loop { + info!("Indexing subscriptions"); + + let res = async { + let mut transaction = pool.begin().await?; + let mut clear_cache_users = Vec::new(); + + // If an active subscription has a canceled charge OR a failed charge more than two days ago, it should be cancelled + let all_charges = ChargeItem::get_unprovision(&pool).await?; + + let mut all_subscriptions = + user_subscription_item::UserSubscriptionItem::get_many( + &all_charges + .iter() + .filter_map(|x| x.subscription_id) + .collect::>() + .into_iter() + .collect::>(), + &pool, + ) + .await?; + let subscription_prices = product_item::ProductPriceItem::get_many( + &all_subscriptions + .iter() + .map(|x| x.price_id) + .collect::>() + .into_iter() + .collect::>(), + &pool, + ) + .await?; + let subscription_products = product_item::ProductItem::get_many( + &subscription_prices + .iter() + .map(|x| x.product_id) + .collect::>() + .into_iter() + .collect::>(), + &pool, + ) + .await?; + let users = crate::database::models::User::get_many_ids( + &all_subscriptions + .iter() + .map(|x| x.user_id) + .collect::>() + .into_iter() + .collect::>(), + &pool, + &redis, + ) + .await?; + + for charge in all_charges { + let subscription = if let Some(subscription) = all_subscriptions + .iter_mut() + .find(|x| Some(x.id) == charge.subscription_id) + { + subscription + } else { + continue; + }; + + if subscription.status == SubscriptionStatus::Unprovisioned { + continue; + } + + let product_price = if let Some(product_price) = + subscription_prices + .iter() + .find(|x| x.id == subscription.price_id) + { + product_price + } else { + continue; + }; + + let product = if let Some(product) = subscription_products + .iter() + .find(|x| x.id == product_price.product_id) + { + product + } else { + continue; + }; + + let user = if let Some(user) = + users.iter().find(|x| x.id == subscription.user_id) + { + user + } else { + continue; + }; + + let unprovisioned = match product.metadata { + ProductMetadata::Midas => { + let badges = user.badges - Badges::MIDAS; + + sqlx::query!( + " + UPDATE users + SET badges = $1 + WHERE (id = $2) + ", + badges.bits() as i64, + user.id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + + true + } + ProductMetadata::Pyro { .. } => { + if let Some(SubscriptionMetadata::Pyro { id }) = + &subscription.metadata + { + let res = reqwest::Client::new() + .post(format!( + "https://archon.pyro.host/modrinth/v0/servers/{}/suspend", + id + )) + .header("X-Master-Key", dotenvy::var("PYRO_API_KEY")?) + .json(&serde_json::json!({ + "reason": if charge.status == ChargeStatus::Cancelled { + "cancelled" + } else { + "paymentfailed" + } + })) + .send() + .await; + + if let Err(e) = res { + warn!("Error suspending pyro server: {:?}", e); + false + } else { + true + } + } else { + true + } + } + }; + + if unprovisioned { + subscription.status = SubscriptionStatus::Unprovisioned; + subscription.upsert(&mut transaction).await?; + } + + clear_cache_users.push(user.id); + } + + crate::database::models::User::clear_caches( + &clear_cache_users + .into_iter() + .map(|x| (x, None)) + .collect::>(), + &redis, + ) + .await?; + transaction.commit().await?; + + Ok::<(), ApiError>(()) + }; + + if let Err(e) = res.await { + warn!("Error indexing billing queue: {:?}", e); + } + + info!("Done indexing billing queue"); + + tokio::time::sleep(std::time::Duration::from_secs(60 * 5)).await; + } +} + +pub async fn task( + stripe_client: stripe::Client, + pool: PgPool, + redis: RedisPool, +) { + loop { + info!("Indexing billing queue"); + let res = async { + // If a charge is open and due or has been attempted more than two days ago, it should be processed + let charges_to_do = + crate::database::models::charge_item::ChargeItem::get_chargeable(&pool).await?; + + let prices = product_item::ProductPriceItem::get_many( + &charges_to_do + .iter() + .map(|x| x.price_id) + .collect::>() + .into_iter() + .collect::>(), + &pool, + ) + .await?; + + let users = crate::database::models::User::get_many_ids( + &charges_to_do + .iter() + .map(|x| x.user_id) + .collect::>() + .into_iter() + .collect::>(), + &pool, + &redis, + ) + .await?; + + let mut transaction = pool.begin().await?; + + for mut charge in charges_to_do { + let product_price = + if let Some(price) = prices.iter().find(|x| x.id == charge.price_id) { + price + } else { + continue; + }; + + let user = if let Some(user) = users.iter().find(|x| x.id == charge.user_id) { + user + } else { + continue; + }; + + let price = match &product_price.prices { + Price::OneTime { price } => Some(price), + Price::Recurring { intervals } => { + if let Some(ref interval) = charge.subscription_interval { + intervals.get(interval) + } else { + warn!("Could not find subscription for charge {:?}", charge.id); + continue; + } + } + }; + + if let Some(price) = price { + let customer_id = get_or_create_customer( + user.id.into(), + user.stripe_customer_id.as_deref(), + user.email.as_deref(), + &stripe_client, + &pool, + &redis, + ) + .await?; + + let customer = + stripe::Customer::retrieve(&stripe_client, &customer_id, &[]).await?; + + let currency = + match Currency::from_str(&product_price.currency_code.to_lowercase()) { + Ok(x) => x, + Err(_) => { + warn!( + "Could not find currency for {}", + product_price.currency_code + ); + continue; + } + }; + + let mut intent = CreatePaymentIntent::new(*price as i64, currency); + + let mut metadata = HashMap::new(); + metadata.insert( + "modrinth_user_id".to_string(), + to_base62(charge.user_id.0 as u64), + ); + metadata.insert( + "modrinth_charge_id".to_string(), + to_base62(charge.id.0 as u64), + ); + + intent.metadata = Some(metadata); + intent.customer = Some(customer.id); + + if let Some(payment_method) = customer + .invoice_settings + .and_then(|x| x.default_payment_method.map(|x| x.id())) + { + intent.payment_method = Some(payment_method); + intent.confirm = Some(true); + intent.off_session = Some(PaymentIntentOffSession::Exists(true)); + + charge.status = ChargeStatus::Processing; + + stripe::PaymentIntent::create(&stripe_client, intent).await?; + } else { + charge.status = ChargeStatus::Failed; + charge.last_attempt = Some(Utc::now()); + } + + charge.upsert(&mut transaction).await?; + } + } + + transaction.commit().await?; + + Ok::<(), ApiError>(()) + } + .await; + + if let Err(e) = res { + warn!("Error indexing billing queue: {:?}", e); + } + + info!("Done indexing billing queue"); + + tokio::time::sleep(std::time::Duration::from_secs(60 * 5)).await; + } +} diff --git a/apps/labrinth/src/routes/internal/flows.rs b/apps/labrinth/src/routes/internal/flows.rs new file mode 100644 index 000000000..31da67cdc --- /dev/null +++ b/apps/labrinth/src/routes/internal/flows.rs @@ -0,0 +1,2478 @@ +use crate::auth::email::send_email; +use crate::auth::validate::get_user_record_from_bearer_token; +use crate::auth::{get_user_from_headers, AuthProvider, AuthenticationError}; +use crate::database::models::flow_item::Flow; +use crate::database::redis::RedisPool; +use crate::file_hosting::FileHost; +use crate::models::ids::base62_impl::{parse_base62, to_base62}; +use crate::models::ids::random_base62_rng; +use crate::models::pats::Scopes; +use crate::models::users::{Badges, Role}; +use crate::queue::session::AuthQueue; +use crate::queue::socket::ActiveSockets; +use crate::routes::internal::session::issue_session; +use crate::routes::ApiError; +use crate::util::captcha::check_turnstile_captcha; +use crate::util::env::parse_strings_from_var; +use crate::util::ext::get_image_ext; +use crate::util::img::upload_image_optimized; +use crate::util::validate::{validation_errors_to_string, RE_URL_SAFE}; +use actix_web::web::{scope, Data, Payload, Query, ServiceConfig}; +use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; +use actix_ws::Closed; +use argon2::password_hash::SaltString; +use argon2::{Argon2, PasswordHash, PasswordHasher, PasswordVerifier}; +use base64::Engine; +use chrono::{Duration, Utc}; +use rand_chacha::rand_core::SeedableRng; +use rand_chacha::ChaCha20Rng; +use reqwest::header::AUTHORIZATION; +use serde::{Deserialize, Serialize}; +use sqlx::postgres::PgPool; +use std::collections::HashMap; +use std::str::FromStr; +use std::sync::Arc; +use tokio::sync::RwLock; +use validator::Validate; + +pub fn config(cfg: &mut ServiceConfig) { + cfg.service( + scope("auth") + .service(ws_init) + .service(init) + .service(auth_callback) + .service(delete_auth_provider) + .service(create_account_with_password) + .service(login_password) + .service(login_2fa) + .service(begin_2fa_flow) + .service(finish_2fa_flow) + .service(remove_2fa) + .service(reset_password_begin) + .service(change_password) + .service(resend_verify_email) + .service(set_email) + .service(verify_email) + .service(subscribe_newsletter), + ); +} + +#[derive(Debug)] +pub struct TempUser { + pub id: String, + pub username: String, + pub email: Option, + + pub avatar_url: Option, + pub bio: Option, + + pub country: Option, +} + +impl TempUser { + async fn create_account( + self, + provider: AuthProvider, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + client: &PgPool, + file_host: &Arc, + redis: &RedisPool, + ) -> Result { + if let Some(email) = &self.email { + if crate::database::models::User::get_email(email, client) + .await? + .is_some() + { + return Err(AuthenticationError::DuplicateUser); + } + } + + let user_id = + crate::database::models::generate_user_id(transaction).await?; + + let mut username_increment: i32 = 0; + let mut username = None; + + while username.is_none() { + let test_username = format!( + "{}{}", + self.username, + if username_increment > 0 { + username_increment.to_string() + } else { + "".to_string() + } + ); + + let new_id = crate::database::models::User::get( + &test_username, + client, + redis, + ) + .await?; + + if new_id.is_none() { + username = Some(test_username); + } else { + username_increment += 1; + } + } + + let (avatar_url, raw_avatar_url) = + if let Some(avatar_url) = self.avatar_url { + let res = reqwest::get(&avatar_url).await?; + let headers = res.headers().clone(); + + let img_data = if let Some(content_type) = headers + .get(reqwest::header::CONTENT_TYPE) + .and_then(|ct| ct.to_str().ok()) + { + get_image_ext(content_type) + } else { + avatar_url.rsplit('.').next() + }; + + if let Some(ext) = img_data { + let bytes = res.bytes().await?; + + let upload_result = upload_image_optimized( + &format!( + "user/{}", + crate::models::users::UserId::from(user_id) + ), + bytes, + ext, + Some(96), + Some(1.0), + &**file_host, + ) + .await; + + if let Ok(upload_result) = upload_result { + (Some(upload_result.url), Some(upload_result.raw_url)) + } else { + (None, None) + } + } else { + (None, None) + } + } else { + (None, None) + }; + + if let Some(username) = username { + crate::database::models::User { + id: user_id, + github_id: if provider == AuthProvider::GitHub { + Some( + self.id.clone().parse().map_err(|_| { + AuthenticationError::InvalidCredentials + })?, + ) + } else { + None + }, + discord_id: if provider == AuthProvider::Discord { + Some( + self.id.parse().map_err(|_| { + AuthenticationError::InvalidCredentials + })?, + ) + } else { + None + }, + gitlab_id: if provider == AuthProvider::GitLab { + Some( + self.id.parse().map_err(|_| { + AuthenticationError::InvalidCredentials + })?, + ) + } else { + None + }, + google_id: if provider == AuthProvider::Google { + Some(self.id.clone()) + } else { + None + }, + steam_id: if provider == AuthProvider::Steam { + Some( + self.id.parse().map_err(|_| { + AuthenticationError::InvalidCredentials + })?, + ) + } else { + None + }, + microsoft_id: if provider == AuthProvider::Microsoft { + Some(self.id.clone()) + } else { + None + }, + password: None, + paypal_id: if provider == AuthProvider::PayPal { + Some(self.id) + } else { + None + }, + paypal_country: self.country, + paypal_email: if provider == AuthProvider::PayPal { + self.email.clone() + } else { + None + }, + venmo_handle: None, + stripe_customer_id: None, + totp_secret: None, + username, + email: self.email, + email_verified: true, + avatar_url, + raw_avatar_url, + bio: self.bio, + created: Utc::now(), + role: Role::Developer.to_string(), + badges: Badges::default(), + } + .insert(transaction) + .await?; + + Ok(user_id) + } else { + Err(AuthenticationError::InvalidCredentials) + } + } +} + +impl AuthProvider { + pub fn get_redirect_url( + &self, + state: String, + ) -> Result { + let self_addr = dotenvy::var("SELF_ADDR")?; + let raw_redirect_uri = format!("{}/v2/auth/callback", self_addr); + let redirect_uri = urlencoding::encode(&raw_redirect_uri); + + Ok(match self { + AuthProvider::GitHub => { + let client_id = dotenvy::var("GITHUB_CLIENT_ID")?; + + format!( + "https://github.com/login/oauth/authorize?client_id={}&prompt=select_account&state={}&scope=read%3Auser%20user%3Aemail&redirect_uri={}", + client_id, + state, + redirect_uri, + ) + } + AuthProvider::Discord => { + let client_id = dotenvy::var("DISCORD_CLIENT_ID")?; + + format!("https://discord.com/api/oauth2/authorize?client_id={}&state={}&response_type=code&scope=identify%20email&redirect_uri={}", client_id, state, redirect_uri) + } + AuthProvider::Microsoft => { + let client_id = dotenvy::var("MICROSOFT_CLIENT_ID")?; + + format!("https://login.live.com/oauth20_authorize.srf?client_id={}&response_type=code&scope=user.read&state={}&prompt=select_account&redirect_uri={}", client_id, state, redirect_uri) + } + AuthProvider::GitLab => { + let client_id = dotenvy::var("GITLAB_CLIENT_ID")?; + + format!( + "https://gitlab.com/oauth/authorize?client_id={}&state={}&scope=read_user+profile+email&response_type=code&redirect_uri={}", + client_id, + state, + redirect_uri, + ) + } + AuthProvider::Google => { + let client_id = dotenvy::var("GOOGLE_CLIENT_ID")?; + + format!( + "https://accounts.google.com/o/oauth2/v2/auth?client_id={}&state={}&scope={}&response_type=code&redirect_uri={}", + client_id, + state, + urlencoding::encode("https://www.googleapis.com/auth/userinfo.email https://www.googleapis.com/auth/userinfo.profile"), + redirect_uri, + ) + } + AuthProvider::Steam => { + format!( + "https://steamcommunity.com/openid/login?openid.ns={}&openid.mode={}&openid.return_to={}{}{}&openid.realm={}&openid.identity={}&openid.claimed_id={}", + urlencoding::encode("http://specs.openid.net/auth/2.0"), + "checkid_setup", + redirect_uri, urlencoding::encode("?state="), state, + self_addr, + "http://specs.openid.net/auth/2.0/identifier_select", + "http://specs.openid.net/auth/2.0/identifier_select", + ) + } + AuthProvider::PayPal => { + let api_url = dotenvy::var("PAYPAL_API_URL")?; + let client_id = dotenvy::var("PAYPAL_CLIENT_ID")?; + + let auth_url = if api_url.contains("sandbox") { + "sandbox.paypal.com" + } else { + "paypal.com" + }; + + format!( + "https://{auth_url}/connect?flowEntry=static&client_id={client_id}&scope={}&response_type=code&redirect_uri={redirect_uri}&state={state}", + urlencoding::encode("openid email address https://uri.paypal.com/services/paypalattributes"), + ) + } + }) + } + + pub async fn get_token( + &self, + query: HashMap, + ) -> Result { + let redirect_uri = + format!("{}/v2/auth/callback", dotenvy::var("SELF_ADDR")?); + + #[derive(Deserialize)] + struct AccessToken { + pub access_token: String, + } + + let res = match self { + AuthProvider::GitHub => { + let code = query + .get("code") + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + let client_id = dotenvy::var("GITHUB_CLIENT_ID")?; + let client_secret = dotenvy::var("GITHUB_CLIENT_SECRET")?; + + let url = format!( + "https://github.com/login/oauth/access_token?client_id={}&client_secret={}&code={}&redirect_uri={}", + client_id, client_secret, code, redirect_uri + ); + + let token: AccessToken = reqwest::Client::new() + .post(&url) + .header(reqwest::header::ACCEPT, "application/json") + .send() + .await? + .json() + .await?; + + token.access_token + } + AuthProvider::Discord => { + let code = query + .get("code") + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + let client_id = dotenvy::var("DISCORD_CLIENT_ID")?; + let client_secret = dotenvy::var("DISCORD_CLIENT_SECRET")?; + + let mut map = HashMap::new(); + map.insert("client_id", &*client_id); + map.insert("client_secret", &*client_secret); + map.insert("code", code); + map.insert("grant_type", "authorization_code"); + map.insert("redirect_uri", &redirect_uri); + + let token: AccessToken = reqwest::Client::new() + .post("https://discord.com/api/v10/oauth2/token") + .header(reqwest::header::ACCEPT, "application/json") + .form(&map) + .send() + .await? + .json() + .await?; + + token.access_token + } + AuthProvider::Microsoft => { + let code = query + .get("code") + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + let client_id = dotenvy::var("MICROSOFT_CLIENT_ID")?; + let client_secret = dotenvy::var("MICROSOFT_CLIENT_SECRET")?; + + let mut map = HashMap::new(); + map.insert("client_id", &*client_id); + map.insert("client_secret", &*client_secret); + map.insert("code", code); + map.insert("grant_type", "authorization_code"); + map.insert("redirect_uri", &redirect_uri); + + let token: AccessToken = reqwest::Client::new() + .post("https://login.live.com/oauth20_token.srf") + .header(reqwest::header::ACCEPT, "application/json") + .form(&map) + .send() + .await? + .json() + .await?; + + token.access_token + } + AuthProvider::GitLab => { + let code = query + .get("code") + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + let client_id = dotenvy::var("GITLAB_CLIENT_ID")?; + let client_secret = dotenvy::var("GITLAB_CLIENT_SECRET")?; + + let mut map = HashMap::new(); + map.insert("client_id", &*client_id); + map.insert("client_secret", &*client_secret); + map.insert("code", code); + map.insert("grant_type", "authorization_code"); + map.insert("redirect_uri", &redirect_uri); + + let token: AccessToken = reqwest::Client::new() + .post("https://gitlab.com/oauth/token") + .header(reqwest::header::ACCEPT, "application/json") + .form(&map) + .send() + .await? + .json() + .await?; + + token.access_token + } + AuthProvider::Google => { + let code = query + .get("code") + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + let client_id = dotenvy::var("GOOGLE_CLIENT_ID")?; + let client_secret = dotenvy::var("GOOGLE_CLIENT_SECRET")?; + + let mut map = HashMap::new(); + map.insert("client_id", &*client_id); + map.insert("client_secret", &*client_secret); + map.insert("code", code); + map.insert("grant_type", "authorization_code"); + map.insert("redirect_uri", &redirect_uri); + + let token: AccessToken = reqwest::Client::new() + .post("https://oauth2.googleapis.com/token") + .header(reqwest::header::ACCEPT, "application/json") + .form(&map) + .send() + .await? + .json() + .await?; + + token.access_token + } + AuthProvider::Steam => { + let mut form = HashMap::new(); + + let signed = query + .get("openid.signed") + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + form.insert( + "openid.assoc_handle".to_string(), + &**query.get("openid.assoc_handle").ok_or_else(|| { + AuthenticationError::InvalidCredentials + })?, + ); + form.insert("openid.signed".to_string(), &**signed); + form.insert( + "openid.sig".to_string(), + &**query.get("openid.sig").ok_or_else(|| { + AuthenticationError::InvalidCredentials + })?, + ); + form.insert( + "openid.ns".to_string(), + "http://specs.openid.net/auth/2.0", + ); + form.insert("openid.mode".to_string(), "check_authentication"); + + for val in signed.split(',') { + if let Some(arr_val) = query.get(&format!("openid.{}", val)) + { + form.insert(format!("openid.{}", val), &**arr_val); + } + } + + let res = reqwest::Client::new() + .post("https://steamcommunity.com/openid/login") + .header("Accept-language", "en") + .form(&form) + .send() + .await? + .text() + .await?; + + if res.contains("is_valid:true") { + let identity = + query.get("openid.identity").ok_or_else(|| { + AuthenticationError::InvalidCredentials + })?; + + identity + .rsplit('/') + .next() + .ok_or_else(|| AuthenticationError::InvalidCredentials)? + .to_string() + } else { + return Err(AuthenticationError::InvalidCredentials); + } + } + AuthProvider::PayPal => { + let code = query + .get("code") + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + let api_url = dotenvy::var("PAYPAL_API_URL")?; + let client_id = dotenvy::var("PAYPAL_CLIENT_ID")?; + let client_secret = dotenvy::var("PAYPAL_CLIENT_SECRET")?; + + let mut map = HashMap::new(); + map.insert("code", code.as_str()); + map.insert("grant_type", "authorization_code"); + + let token: AccessToken = reqwest::Client::new() + .post(format!("{api_url}oauth2/token")) + .header(reqwest::header::ACCEPT, "application/json") + .header( + AUTHORIZATION, + format!( + "Basic {}", + base64::engine::general_purpose::STANDARD + .encode(format!("{client_id}:{client_secret}")) + ), + ) + .form(&map) + .send() + .await? + .json() + .await?; + + token.access_token + } + }; + + Ok(res) + } + + pub async fn get_user( + &self, + token: &str, + ) -> Result { + let res = match self { + AuthProvider::GitHub => { + let response = reqwest::Client::new() + .get("https://api.github.com/user") + .header(reqwest::header::USER_AGENT, "Modrinth") + .header(AUTHORIZATION, format!("token {token}")) + .send() + .await?; + + if token.starts_with("gho_") { + let client_id = response + .headers() + .get("x-oauth-client-id") + .and_then(|x| x.to_str().ok()); + + if client_id + != Some(&*dotenvy::var("GITHUB_CLIENT_ID").unwrap()) + { + return Err(AuthenticationError::InvalidClientId); + } + } + + #[derive(Serialize, Deserialize, Debug)] + pub struct GitHubUser { + pub login: String, + pub id: u64, + pub avatar_url: String, + pub name: Option, + pub email: Option, + pub bio: Option, + } + + let github_user: GitHubUser = response.json().await?; + + TempUser { + id: github_user.id.to_string(), + username: github_user.login, + email: github_user.email, + avatar_url: Some(github_user.avatar_url), + bio: github_user.bio, + country: None, + } + } + AuthProvider::Discord => { + #[derive(Serialize, Deserialize, Debug)] + pub struct DiscordUser { + pub username: String, + pub id: String, + pub avatar: Option, + pub global_name: Option, + pub email: Option, + } + + let discord_user: DiscordUser = reqwest::Client::new() + .get("https://discord.com/api/v10/users/@me") + .header(reqwest::header::USER_AGENT, "Modrinth") + .header(AUTHORIZATION, format!("Bearer {token}")) + .send() + .await? + .json() + .await?; + + let id = discord_user.id.clone(); + TempUser { + id: discord_user.id, + username: discord_user.username, + email: discord_user.email, + avatar_url: discord_user.avatar.map(|x| { + format!( + "https://cdn.discordapp.com/avatars/{}/{}.webp", + id, x + ) + }), + bio: None, + country: None, + } + } + AuthProvider::Microsoft => { + #[derive(Deserialize, Debug)] + #[serde(rename_all = "camelCase")] + pub struct MicrosoftUser { + pub id: String, + pub mail: Option, + pub user_principal_name: String, + } + + let microsoft_user: MicrosoftUser = reqwest::Client::new() + .get("https://graph.microsoft.com/v1.0/me?$select=id,displayName,mail,userPrincipalName") + .header(reqwest::header::USER_AGENT, "Modrinth") + .header(AUTHORIZATION, format!("Bearer {token}")) + .send() + .await?.json().await?; + + TempUser { + id: microsoft_user.id, + username: microsoft_user + .user_principal_name + .split('@') + .next() + .unwrap_or_default() + .to_string(), + email: microsoft_user.mail, + avatar_url: None, + bio: None, + country: None, + } + } + AuthProvider::GitLab => { + #[derive(Serialize, Deserialize, Debug)] + pub struct GitLabUser { + pub id: i32, + pub username: String, + pub email: Option, + pub avatar_url: Option, + pub name: Option, + pub bio: Option, + } + + let gitlab_user: GitLabUser = reqwest::Client::new() + .get("https://gitlab.com/api/v4/user") + .header(reqwest::header::USER_AGENT, "Modrinth") + .header(AUTHORIZATION, format!("Bearer {token}")) + .send() + .await? + .json() + .await?; + + TempUser { + id: gitlab_user.id.to_string(), + username: gitlab_user.username, + email: gitlab_user.email, + avatar_url: gitlab_user.avatar_url, + bio: gitlab_user.bio, + country: None, + } + } + AuthProvider::Google => { + #[derive(Deserialize, Debug)] + pub struct GoogleUser { + pub id: String, + pub email: String, + pub picture: Option, + } + + let google_user: GoogleUser = reqwest::Client::new() + .get("https://www.googleapis.com/userinfo/v2/me") + .header(reqwest::header::USER_AGENT, "Modrinth") + .header(AUTHORIZATION, format!("Bearer {token}")) + .send() + .await? + .json() + .await?; + + TempUser { + id: google_user.id, + username: google_user + .email + .split('@') + .next() + .unwrap_or_default() + .to_string(), + email: Some(google_user.email), + avatar_url: google_user.picture, + bio: None, + country: None, + } + } + AuthProvider::Steam => { + let api_key = dotenvy::var("STEAM_API_KEY")?; + + #[derive(Deserialize)] + struct SteamResponse { + response: Players, + } + + #[derive(Deserialize)] + struct Players { + players: Vec, + } + + #[derive(Deserialize)] + struct Player { + steamid: String, + profileurl: String, + avatar: Option, + } + + let response: String = reqwest::get( + &format!( + "https://api.steampowered.com/ISteamUser/GetPlayerSummaries/v0002/?key={}&steamids={}", + api_key, + token + ) + ) + .await? + .text() + .await?; + + let mut response: SteamResponse = + serde_json::from_str(&response)?; + + if let Some(player) = response.response.players.pop() { + let username = player + .profileurl + .trim_matches('/') + .rsplit('/') + .next() + .unwrap_or(&player.steamid) + .to_string(); + TempUser { + id: player.steamid, + username, + email: None, + avatar_url: player.avatar, + bio: None, + country: None, + } + } else { + return Err(AuthenticationError::InvalidCredentials); + } + } + AuthProvider::PayPal => { + #[derive(Deserialize, Debug)] + pub struct PayPalUser { + pub payer_id: String, + pub email: String, + pub picture: Option, + pub address: PayPalAddress, + } + + #[derive(Deserialize, Debug)] + pub struct PayPalAddress { + pub country: String, + } + + let api_url = dotenvy::var("PAYPAL_API_URL")?; + + let paypal_user: PayPalUser = reqwest::Client::new() + .get(format!( + "{api_url}identity/openidconnect/userinfo?schema=openid" + )) + .header(reqwest::header::USER_AGENT, "Modrinth") + .header(AUTHORIZATION, format!("Bearer {token}")) + .send() + .await? + .json() + .await?; + + TempUser { + id: paypal_user.payer_id, + username: paypal_user + .email + .split('@') + .next() + .unwrap_or_default() + .to_string(), + email: Some(paypal_user.email), + avatar_url: paypal_user.picture, + bio: None, + country: Some(paypal_user.address.country), + } + } + }; + + Ok(res) + } + + pub async fn get_user_id<'a, 'b, E>( + &self, + id: &str, + executor: E, + ) -> Result, AuthenticationError> + where + E: sqlx::Executor<'a, Database = sqlx::Postgres>, + { + Ok(match self { + AuthProvider::GitHub => { + let value = sqlx::query!( + "SELECT id FROM users WHERE github_id = $1", + id.parse::() + .map_err(|_| AuthenticationError::InvalidCredentials)? + ) + .fetch_optional(executor) + .await?; + + value.map(|x| crate::database::models::UserId(x.id)) + } + AuthProvider::Discord => { + let value = sqlx::query!( + "SELECT id FROM users WHERE discord_id = $1", + id.parse::() + .map_err(|_| AuthenticationError::InvalidCredentials)? + ) + .fetch_optional(executor) + .await?; + + value.map(|x| crate::database::models::UserId(x.id)) + } + AuthProvider::Microsoft => { + let value = sqlx::query!( + "SELECT id FROM users WHERE microsoft_id = $1", + id + ) + .fetch_optional(executor) + .await?; + + value.map(|x| crate::database::models::UserId(x.id)) + } + AuthProvider::GitLab => { + let value = sqlx::query!( + "SELECT id FROM users WHERE gitlab_id = $1", + id.parse::() + .map_err(|_| AuthenticationError::InvalidCredentials)? + ) + .fetch_optional(executor) + .await?; + + value.map(|x| crate::database::models::UserId(x.id)) + } + AuthProvider::Google => { + let value = sqlx::query!( + "SELECT id FROM users WHERE google_id = $1", + id + ) + .fetch_optional(executor) + .await?; + + value.map(|x| crate::database::models::UserId(x.id)) + } + AuthProvider::Steam => { + let value = sqlx::query!( + "SELECT id FROM users WHERE steam_id = $1", + id.parse::() + .map_err(|_| AuthenticationError::InvalidCredentials)? + ) + .fetch_optional(executor) + .await?; + + value.map(|x| crate::database::models::UserId(x.id)) + } + AuthProvider::PayPal => { + let value = sqlx::query!( + "SELECT id FROM users WHERE paypal_id = $1", + id + ) + .fetch_optional(executor) + .await?; + + value.map(|x| crate::database::models::UserId(x.id)) + } + }) + } + + pub async fn update_user_id( + &self, + user_id: crate::database::models::UserId, + id: Option<&str>, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + ) -> Result<(), AuthenticationError> { + match self { + AuthProvider::GitHub => { + sqlx::query!( + " + UPDATE users + SET github_id = $2 + WHERE (id = $1) + ", + user_id as crate::database::models::UserId, + id.and_then(|x| x.parse::().ok()) + ) + .execute(&mut **transaction) + .await?; + } + AuthProvider::Discord => { + sqlx::query!( + " + UPDATE users + SET discord_id = $2 + WHERE (id = $1) + ", + user_id as crate::database::models::UserId, + id.and_then(|x| x.parse::().ok()) + ) + .execute(&mut **transaction) + .await?; + } + AuthProvider::Microsoft => { + sqlx::query!( + " + UPDATE users + SET microsoft_id = $2 + WHERE (id = $1) + ", + user_id as crate::database::models::UserId, + id, + ) + .execute(&mut **transaction) + .await?; + } + AuthProvider::GitLab => { + sqlx::query!( + " + UPDATE users + SET gitlab_id = $2 + WHERE (id = $1) + ", + user_id as crate::database::models::UserId, + id.and_then(|x| x.parse::().ok()) + ) + .execute(&mut **transaction) + .await?; + } + AuthProvider::Google => { + sqlx::query!( + " + UPDATE users + SET google_id = $2 + WHERE (id = $1) + ", + user_id as crate::database::models::UserId, + id, + ) + .execute(&mut **transaction) + .await?; + } + AuthProvider::Steam => { + sqlx::query!( + " + UPDATE users + SET steam_id = $2 + WHERE (id = $1) + ", + user_id as crate::database::models::UserId, + id.and_then(|x| x.parse::().ok()) + ) + .execute(&mut **transaction) + .await?; + } + AuthProvider::PayPal => { + if id.is_none() { + sqlx::query!( + " + UPDATE users + SET paypal_country = NULL, paypal_email = NULL, paypal_id = NULL + WHERE (id = $1) + ", + user_id as crate::database::models::UserId, + ) + .execute(&mut **transaction) + .await?; + } else { + sqlx::query!( + " + UPDATE users + SET paypal_id = $2 + WHERE (id = $1) + ", + user_id as crate::database::models::UserId, + id, + ) + .execute(&mut **transaction) + .await?; + } + } + } + + Ok(()) + } + + pub fn as_str(&self) -> &'static str { + match self { + AuthProvider::GitHub => "GitHub", + AuthProvider::Discord => "Discord", + AuthProvider::Microsoft => "Microsoft", + AuthProvider::GitLab => "GitLab", + AuthProvider::Google => "Google", + AuthProvider::Steam => "Steam", + AuthProvider::PayPal => "PayPal", + } + } +} + +#[derive(Serialize, Deserialize)] +pub struct AuthorizationInit { + pub url: String, + #[serde(default)] + pub provider: AuthProvider, + pub token: Option, +} +#[derive(Serialize, Deserialize)] +pub struct Authorization { + pub code: String, + pub state: String, +} + +// Init link takes us to GitHub API and calls back to callback endpoint with a code and state +// http://localhost:8000/auth/init?url=https://modrinth.com +#[get("init")] +pub async fn init( + req: HttpRequest, + Query(info): Query, // callback url + client: Data, + redis: Data, + session_queue: Data, +) -> Result { + let url = + url::Url::parse(&info.url).map_err(|_| AuthenticationError::Url)?; + + let allowed_callback_urls = + parse_strings_from_var("ALLOWED_CALLBACK_URLS").unwrap_or_default(); + let domain = url.host_str().ok_or(AuthenticationError::Url)?; + if !allowed_callback_urls.iter().any(|x| domain.ends_with(x)) + && domain != "modrinth.com" + { + return Err(AuthenticationError::Url); + } + + let user_id = if let Some(token) = info.token { + let (_, user) = get_user_record_from_bearer_token( + &req, + Some(&token), + &**client, + &redis, + &session_queue, + ) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + + Some(user.id) + } else { + None + }; + + let state = Flow::OAuth { + user_id, + url: Some(info.url), + provider: info.provider, + } + .insert(Duration::minutes(30), &redis) + .await?; + + let url = info.provider.get_redirect_url(state)?; + Ok(HttpResponse::TemporaryRedirect() + .append_header(("Location", &*url)) + .json(serde_json::json!({ "url": url }))) +} + +#[derive(Serialize, Deserialize)] +pub struct WsInit { + pub provider: AuthProvider, +} + +#[get("ws")] +pub async fn ws_init( + req: HttpRequest, + Query(info): Query, + body: Payload, + db: Data>, + redis: Data, +) -> Result { + let (res, session, _msg_stream) = actix_ws::handle(&req, body)?; + + async fn sock( + mut ws_stream: actix_ws::Session, + info: WsInit, + db: Data>, + redis: Data, + ) -> Result<(), Closed> { + let flow = Flow::OAuth { + user_id: None, + url: None, + provider: info.provider, + } + .insert(Duration::minutes(30), &redis) + .await; + + if let Ok(state) = flow { + if let Ok(url) = info.provider.get_redirect_url(state.clone()) { + ws_stream + .text(serde_json::json!({ "url": url }).to_string()) + .await?; + + let db = db.write().await; + db.auth_sockets.insert(state, ws_stream); + } + } + + Ok(()) + } + + let _ = sock(session, info, db, redis).await; + + Ok(res) +} + +#[get("callback")] +pub async fn auth_callback( + req: HttpRequest, + Query(query): Query>, + active_sockets: Data>, + client: Data, + file_host: Data>, + redis: Data, +) -> Result { + let state_string = query + .get("state") + .ok_or_else(|| AuthenticationError::InvalidCredentials)? + .clone(); + + let sockets = active_sockets.clone(); + let state = state_string.clone(); + let res: Result = async move { + + let flow = Flow::get(&state, &redis).await?; + + // Extract cookie header from request + if let Some(Flow::OAuth { + user_id, + provider, + url, + }) = flow + { + Flow::remove(&state, &redis).await?; + + let token = provider.get_token(query).await?; + let oauth_user = provider.get_user(&token).await?; + + let user_id_opt = provider.get_user_id(&oauth_user.id, &**client).await?; + + let mut transaction = client.begin().await?; + if let Some(id) = user_id { + if user_id_opt.is_some() { + return Err(AuthenticationError::DuplicateUser); + } + + provider + .update_user_id(id, Some(&oauth_user.id), &mut transaction) + .await?; + + let user = crate::database::models::User::get_id(id, &**client, &redis).await?; + + if provider == AuthProvider::PayPal { + sqlx::query!( + " + UPDATE users + SET paypal_country = $1, paypal_email = $2, paypal_id = $3 + WHERE (id = $4) + ", + oauth_user.country, + oauth_user.email, + oauth_user.id, + id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + } else if let Some(email) = user.and_then(|x| x.email) { + send_email( + email, + "Authentication method added", + &format!("When logging into Modrinth, you can now log in using the {} authentication provider.", provider.as_str()), + "If you did not make this change, please contact us immediately through our support channels on Discord or via email (support@modrinth.com).", + None, + )?; + } + + transaction.commit().await?; + crate::database::models::User::clear_caches(&[(id, None)], &redis).await?; + + if let Some(url) = url { + Ok(HttpResponse::TemporaryRedirect() + .append_header(("Location", &*url)) + .json(serde_json::json!({ "url": url }))) + } else { + Err(AuthenticationError::InvalidCredentials) + } + } else { + let user_id = if let Some(user_id) = user_id_opt { + let user = crate::database::models::User::get_id(user_id, &**client, &redis) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + + if user.totp_secret.is_some() { + let flow = Flow::Login2FA { user_id: user.id } + .insert(Duration::minutes(30), &redis) + .await?; + + if let Some(url) = url { + let redirect_url = format!( + "{}{}error=2fa_required&flow={}", + url, + if url.contains('?') { "&" } else { "?" }, + flow + ); + + return Ok(HttpResponse::TemporaryRedirect() + .append_header(("Location", &*redirect_url)) + .json(serde_json::json!({ "url": redirect_url }))); + } else { + let mut ws_conn = { + let db = sockets.read().await; + + let mut x = db + .auth_sockets + .get_mut(&state) + .ok_or_else(|| AuthenticationError::SocketError)?; + + x.value_mut().clone() + }; + + ws_conn + .text( + serde_json::json!({ + "error": "2fa_required", + "flow": flow, + }).to_string() + ) + .await.map_err(|_| AuthenticationError::SocketError)?; + + let _ = ws_conn.close(None).await; + + return Ok(crate::auth::templates::Success { + icon: user.avatar_url.as_deref().unwrap_or("https://cdn-raw.modrinth.com/placeholder.svg"), + name: &user.username, + }.render()); + } + } + + user_id + } else { + oauth_user.create_account(provider, &mut transaction, &client, &file_host, &redis).await? + }; + + let session = issue_session(req, user_id, &mut transaction, &redis).await?; + transaction.commit().await?; + + if let Some(url) = url { + let redirect_url = format!( + "{}{}code={}{}", + url, + if url.contains('?') { '&' } else { '?' }, + session.session, + if user_id_opt.is_none() { + "&new_account=true" + } else { + "" + } + ); + + Ok(HttpResponse::TemporaryRedirect() + .append_header(("Location", &*redirect_url)) + .json(serde_json::json!({ "url": redirect_url }))) + } else { + let user = crate::database::models::user_item::User::get_id( + user_id, + &**client, + &redis, + ) + .await?.ok_or_else(|| AuthenticationError::InvalidCredentials)?; + + let mut ws_conn = { + let db = sockets.read().await; + + let mut x = db + .auth_sockets + .get_mut(&state) + .ok_or_else(|| AuthenticationError::SocketError)?; + + x.value_mut().clone() + }; + + ws_conn + .text( + serde_json::json!({ + "code": session.session, + }).to_string() + ) + .await.map_err(|_| AuthenticationError::SocketError)?; + let _ = ws_conn.close(None).await; + + return Ok(crate::auth::templates::Success { + icon: user.avatar_url.as_deref().unwrap_or("https://cdn-raw.modrinth.com/placeholder.svg"), + name: &user.username, + }.render()); + } + } + } else { + Err::(AuthenticationError::InvalidCredentials) + } + }.await; + + // Because this is callback route, if we have an error, we need to ensure we close the original socket if it exists + if let Err(ref e) = res { + let db = active_sockets.read().await; + let mut x = db.auth_sockets.get_mut(&state_string); + + if let Some(x) = x.as_mut() { + let mut ws_conn = x.value_mut().clone(); + + ws_conn + .text( + serde_json::json!({ + "error": &e.error_name(), + "description": &e.to_string(), + } ) + .to_string(), + ) + .await + .map_err(|_| AuthenticationError::SocketError)?; + let _ = ws_conn.close(None).await; + } + } + + Ok(res?) +} + +#[derive(Deserialize)] +pub struct DeleteAuthProvider { + pub provider: AuthProvider, +} + +#[delete("provider")] +pub async fn delete_auth_provider( + req: HttpRequest, + pool: Data, + redis: Data, + delete_provider: web::Json, + session_queue: Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::USER_AUTH_WRITE]), + ) + .await? + .1; + + if !user.auth_providers.map(|x| x.len() > 1).unwrap_or(false) + && !user.has_password.unwrap_or(false) + { + return Err(ApiError::InvalidInput( + "You must have another authentication method added to this account!".to_string(), + )); + } + + let mut transaction = pool.begin().await?; + + delete_provider + .provider + .update_user_id(user.id.into(), None, &mut transaction) + .await?; + + if delete_provider.provider != AuthProvider::PayPal { + if let Some(email) = user.email { + send_email( + email, + "Authentication method removed", + &format!("When logging into Modrinth, you can no longer log in using the {} authentication provider.", delete_provider.provider.as_str()), + "If you did not make this change, please contact us immediately through our support channels on Discord or via email (support@modrinth.com).", + None, + )?; + } + } + + transaction.commit().await?; + crate::database::models::User::clear_caches( + &[(user.id.into(), None)], + &redis, + ) + .await?; + + Ok(HttpResponse::NoContent().finish()) +} + +pub async fn sign_up_beehiiv(email: &str) -> Result<(), AuthenticationError> { + let id = dotenvy::var("BEEHIIV_PUBLICATION_ID")?; + let api_key = dotenvy::var("BEEHIIV_API_KEY")?; + let site_url = dotenvy::var("SITE_URL")?; + + let client = reqwest::Client::new(); + client + .post(format!( + "https://api.beehiiv.com/v2/publications/{id}/subscriptions" + )) + .header(AUTHORIZATION, format!("Bearer {}", api_key)) + .json(&serde_json::json!({ + "email": email, + "utm_source": "modrinth", + "utm_medium": "account_creation", + "referring_site": site_url, + })) + .send() + .await? + .error_for_status()? + .text() + .await?; + + Ok(()) +} + +#[derive(Deserialize, Validate)] +pub struct NewAccount { + #[validate(length(min = 1, max = 39), regex = "RE_URL_SAFE")] + pub username: String, + #[validate(length(min = 8, max = 256))] + pub password: String, + #[validate(email)] + pub email: String, + pub challenge: String, + pub sign_up_newsletter: Option, +} + +#[post("create")] +pub async fn create_account_with_password( + req: HttpRequest, + pool: Data, + redis: Data, + new_account: web::Json, +) -> Result { + new_account.0.validate().map_err(|err| { + ApiError::InvalidInput(validation_errors_to_string(err, None)) + })?; + + if !check_turnstile_captcha(&req, &new_account.challenge).await? { + return Err(ApiError::Turnstile); + } + + if crate::database::models::User::get( + &new_account.username, + &**pool, + &redis, + ) + .await? + .is_some() + { + return Err(ApiError::InvalidInput("Username is taken!".to_string())); + } + + let mut transaction = pool.begin().await?; + let user_id = + crate::database::models::generate_user_id(&mut transaction).await?; + + let new_account = new_account.0; + + let score = zxcvbn::zxcvbn( + &new_account.password, + &[&new_account.username, &new_account.email], + )?; + + if score.score() < 3 { + return Err(ApiError::InvalidInput( + if let Some(feedback) = + score.feedback().clone().and_then(|x| x.warning()) + { + format!("Password too weak: {}", feedback) + } else { + "Specified password is too weak! Please improve its strength." + .to_string() + }, + )); + } + + let hasher = Argon2::default(); + let salt = SaltString::generate(&mut ChaCha20Rng::from_entropy()); + let password_hash = hasher + .hash_password(new_account.password.as_bytes(), &salt)? + .to_string(); + + if crate::database::models::User::get_email(&new_account.email, &**pool) + .await? + .is_some() + { + return Err(ApiError::InvalidInput( + "Email is already registered on Modrinth!".to_string(), + )); + } + + let flow = Flow::ConfirmEmail { + user_id, + confirm_email: new_account.email.clone(), + } + .insert(Duration::hours(24), &redis) + .await?; + + send_email_verify( + new_account.email.clone(), + flow, + &format!("Welcome to Modrinth, {}!", new_account.username), + )?; + + crate::database::models::User { + id: user_id, + github_id: None, + discord_id: None, + gitlab_id: None, + google_id: None, + steam_id: None, + microsoft_id: None, + password: Some(password_hash), + paypal_id: None, + paypal_country: None, + paypal_email: None, + venmo_handle: None, + stripe_customer_id: None, + totp_secret: None, + username: new_account.username.clone(), + email: Some(new_account.email.clone()), + email_verified: false, + avatar_url: None, + raw_avatar_url: None, + bio: None, + created: Utc::now(), + role: Role::Developer.to_string(), + badges: Badges::default(), + } + .insert(&mut transaction) + .await?; + + let session = issue_session(req, user_id, &mut transaction, &redis).await?; + let res = crate::models::sessions::Session::from(session, true, None); + + if new_account.sign_up_newsletter.unwrap_or(false) { + sign_up_beehiiv(&new_account.email).await?; + } + + transaction.commit().await?; + + Ok(HttpResponse::Ok().json(res)) +} + +#[derive(Deserialize, Validate)] +pub struct Login { + pub username: String, + pub password: String, + pub challenge: String, +} + +#[post("login")] +pub async fn login_password( + req: HttpRequest, + pool: Data, + redis: Data, + login: web::Json, +) -> Result { + if !check_turnstile_captcha(&req, &login.challenge).await? { + return Err(ApiError::Turnstile); + } + + let user = if let Some(user) = + crate::database::models::User::get(&login.username, &**pool, &redis) + .await? + { + user + } else { + let user = + crate::database::models::User::get_email(&login.username, &**pool) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + + crate::database::models::User::get_id(user, &**pool, &redis) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)? + }; + + let hasher = Argon2::default(); + hasher + .verify_password( + login.password.as_bytes(), + &PasswordHash::new( + &user + .password + .ok_or_else(|| AuthenticationError::InvalidCredentials)?, + )?, + ) + .map_err(|_| AuthenticationError::InvalidCredentials)?; + + if user.totp_secret.is_some() { + let flow = Flow::Login2FA { user_id: user.id } + .insert(Duration::minutes(30), &redis) + .await?; + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "error": "2fa_required", + "description": "2FA is required to complete this operation.", + "flow": flow, + }))) + } else { + let mut transaction = pool.begin().await?; + let session = + issue_session(req, user.id, &mut transaction, &redis).await?; + let res = crate::models::sessions::Session::from(session, true, None); + transaction.commit().await?; + + Ok(HttpResponse::Ok().json(res)) + } +} + +#[derive(Deserialize, Validate)] +pub struct Login2FA { + pub code: String, + pub flow: String, +} + +async fn validate_2fa_code( + input: String, + secret: String, + allow_backup: bool, + user_id: crate::database::models::UserId, + redis: &RedisPool, + pool: &PgPool, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result { + let totp = totp_rs::TOTP::new( + totp_rs::Algorithm::SHA1, + 6, + 1, + 30, + totp_rs::Secret::Encoded(secret) + .to_bytes() + .map_err(|_| AuthenticationError::InvalidCredentials)?, + ) + .map_err(|_| AuthenticationError::InvalidCredentials)?; + let token = totp + .generate_current() + .map_err(|_| AuthenticationError::InvalidCredentials)?; + + const TOTP_NAMESPACE: &str = "used_totp"; + let mut conn = redis.connect().await?; + + // Check if TOTP has already been used + if conn + .get(TOTP_NAMESPACE, &format!("{}-{}", token, user_id.0)) + .await? + .is_some() + { + return Err(AuthenticationError::InvalidCredentials); + } + + if input == token { + conn.set( + TOTP_NAMESPACE, + &format!("{}-{}", token, user_id.0), + "", + Some(60), + ) + .await?; + + Ok(true) + } else if allow_backup { + let backup_codes = + crate::database::models::User::get_backup_codes(user_id, pool) + .await?; + + if !backup_codes.contains(&input) { + Ok(false) + } else { + let code = parse_base62(&input).unwrap_or_default(); + + sqlx::query!( + " + DELETE FROM user_backup_codes + WHERE user_id = $1 AND code = $2 + ", + user_id as crate::database::models::ids::UserId, + code as i64, + ) + .execute(&mut **transaction) + .await?; + + crate::database::models::User::clear_caches( + &[(user_id, None)], + redis, + ) + .await?; + + Ok(true) + } + } else { + Err(AuthenticationError::InvalidCredentials) + } +} + +#[post("login/2fa")] +pub async fn login_2fa( + req: HttpRequest, + pool: Data, + redis: Data, + login: web::Json, +) -> Result { + let flow = Flow::get(&login.flow, &redis) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + + if let Flow::Login2FA { user_id } = flow { + let user = + crate::database::models::User::get_id(user_id, &**pool, &redis) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + + let mut transaction = pool.begin().await?; + if !validate_2fa_code( + login.code.clone(), + user.totp_secret + .ok_or_else(|| AuthenticationError::InvalidCredentials)?, + true, + user.id, + &redis, + &pool, + &mut transaction, + ) + .await? + { + return Err(ApiError::Authentication( + AuthenticationError::InvalidCredentials, + )); + } + Flow::remove(&login.flow, &redis).await?; + + let session = + issue_session(req, user_id, &mut transaction, &redis).await?; + let res = crate::models::sessions::Session::from(session, true, None); + transaction.commit().await?; + + Ok(HttpResponse::Ok().json(res)) + } else { + Err(ApiError::Authentication( + AuthenticationError::InvalidCredentials, + )) + } +} + +#[post("2fa/get_secret")] +pub async fn begin_2fa_flow( + req: HttpRequest, + pool: Data, + redis: Data, + session_queue: Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::USER_AUTH_WRITE]), + ) + .await? + .1; + + if !user.has_totp.unwrap_or(false) { + let string = totp_rs::Secret::generate_secret(); + let encoded = string.to_encoded(); + + let flow = Flow::Initialize2FA { + user_id: user.id.into(), + secret: encoded.to_string(), + } + .insert(Duration::minutes(30), &redis) + .await?; + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "secret": encoded.to_string(), + "flow": flow, + }))) + } else { + Err(ApiError::InvalidInput( + "User already has 2FA enabled on their account!".to_string(), + )) + } +} + +#[post("2fa")] +pub async fn finish_2fa_flow( + req: HttpRequest, + pool: Data, + redis: Data, + login: web::Json, + session_queue: Data, +) -> Result { + let flow = Flow::get(&login.flow, &redis) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + + if let Flow::Initialize2FA { user_id, secret } = flow { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::USER_AUTH_WRITE]), + ) + .await? + .1; + + if user.id != user_id.into() { + return Err(ApiError::Authentication( + AuthenticationError::InvalidCredentials, + )); + } + + let mut transaction = pool.begin().await?; + + if !validate_2fa_code( + login.code.clone(), + secret.clone(), + false, + user.id.into(), + &redis, + &pool, + &mut transaction, + ) + .await? + { + return Err(ApiError::Authentication( + AuthenticationError::InvalidCredentials, + )); + } + + Flow::remove(&login.flow, &redis).await?; + + sqlx::query!( + " + UPDATE users + SET totp_secret = $1 + WHERE (id = $2) + ", + secret, + user_id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + " + DELETE FROM user_backup_codes + WHERE user_id = $1 + ", + user_id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + + let mut codes = Vec::new(); + + for _ in 0..6 { + let mut rng = ChaCha20Rng::from_entropy(); + let val = random_base62_rng(&mut rng, 11); + + sqlx::query!( + " + INSERT INTO user_backup_codes ( + user_id, code + ) + VALUES ( + $1, $2 + ) + ", + user_id as crate::database::models::ids::UserId, + val as i64, + ) + .execute(&mut *transaction) + .await?; + + codes.push(to_base62(val)); + } + + if let Some(email) = user.email { + send_email( + email, + "Two-factor authentication enabled", + "When logging into Modrinth, you can now enter a code generated by your authenticator app in addition to entering your usual email address and password.", + "If you did not make this change, please contact us immediately through our support channels on Discord or via email (support@modrinth.com).", + None, + )?; + } + + transaction.commit().await?; + crate::database::models::User::clear_caches( + &[(user.id.into(), None)], + &redis, + ) + .await?; + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "backup_codes": codes, + }))) + } else { + Err(ApiError::Authentication( + AuthenticationError::InvalidCredentials, + )) + } +} + +#[derive(Deserialize)] +pub struct Remove2FA { + pub code: String, +} + +#[delete("2fa")] +pub async fn remove_2fa( + req: HttpRequest, + pool: Data, + redis: Data, + login: web::Json, + session_queue: Data, +) -> Result { + let (scopes, user) = get_user_record_from_bearer_token( + &req, + None, + &**pool, + &redis, + &session_queue, + ) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + + if !scopes.contains(Scopes::USER_AUTH_WRITE) { + return Err(ApiError::Authentication( + AuthenticationError::InvalidCredentials, + )); + } + + let mut transaction = pool.begin().await?; + + if !validate_2fa_code( + login.code.clone(), + user.totp_secret.ok_or_else(|| { + ApiError::InvalidInput( + "User does not have 2FA enabled on the account!".to_string(), + ) + })?, + true, + user.id, + &redis, + &pool, + &mut transaction, + ) + .await? + { + return Err(ApiError::Authentication( + AuthenticationError::InvalidCredentials, + )); + } + + sqlx::query!( + " + UPDATE users + SET totp_secret = NULL + WHERE (id = $1) + ", + user.id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + " + DELETE FROM user_backup_codes + WHERE user_id = $1 + ", + user.id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + + if let Some(email) = user.email { + send_email( + email, + "Two-factor authentication removed", + "When logging into Modrinth, you no longer need two-factor authentication to gain access.", + "If you did not make this change, please contact us immediately through our support channels on Discord or via email (support@modrinth.com).", + None, + )?; + } + + transaction.commit().await?; + crate::database::models::User::clear_caches(&[(user.id, None)], &redis) + .await?; + + Ok(HttpResponse::NoContent().finish()) +} + +#[derive(Deserialize)] +pub struct ResetPassword { + pub username: String, + pub challenge: String, +} + +#[post("password/reset")] +pub async fn reset_password_begin( + req: HttpRequest, + pool: Data, + redis: Data, + reset_password: web::Json, +) -> Result { + if !check_turnstile_captcha(&req, &reset_password.challenge).await? { + return Err(ApiError::Turnstile); + } + + let user = if let Some(user_id) = crate::database::models::User::get_email( + &reset_password.username, + &**pool, + ) + .await? + { + crate::database::models::User::get_id(user_id, &**pool, &redis).await? + } else { + crate::database::models::User::get( + &reset_password.username, + &**pool, + &redis, + ) + .await? + }; + + if let Some(user) = user { + let flow = Flow::ForgotPassword { user_id: user.id } + .insert(Duration::hours(24), &redis) + .await?; + + if let Some(email) = user.email { + send_email( + email, + "Reset your password", + "Please visit the following link below to reset your password. If the button does not work, you can copy the link and paste it into your browser.", + "If you did not request for your password to be reset, you can safely ignore this email.", + Some(("Reset password", &format!("{}/{}?flow={}", dotenvy::var("SITE_URL")?, dotenvy::var("SITE_RESET_PASSWORD_PATH")?, flow))), + )?; + } + } + + Ok(HttpResponse::Ok().finish()) +} + +#[derive(Deserialize, Validate)] +pub struct ChangePassword { + pub flow: Option, + pub old_password: Option, + pub new_password: Option, +} + +#[patch("password")] +pub async fn change_password( + req: HttpRequest, + pool: Data, + redis: Data, + change_password: web::Json, + session_queue: Data, +) -> Result { + let user = if let Some(flow) = &change_password.flow { + let flow = Flow::get(flow, &redis).await?; + + if let Some(Flow::ForgotPassword { user_id }) = flow { + let user = + crate::database::models::User::get_id(user_id, &**pool, &redis) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + + Some(user) + } else { + None + } + } else { + None + }; + + let user = if let Some(user) = user { + user + } else { + let (scopes, user) = get_user_record_from_bearer_token( + &req, + None, + &**pool, + &redis, + &session_queue, + ) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + + if !scopes.contains(Scopes::USER_AUTH_WRITE) { + return Err(ApiError::Authentication( + AuthenticationError::InvalidCredentials, + )); + } + + if let Some(pass) = user.password.as_ref() { + let old_password = change_password.old_password.as_ref().ok_or_else(|| { + ApiError::CustomAuthentication( + "You must specify the old password to change your password!".to_string(), + ) + })?; + + let hasher = Argon2::default(); + hasher.verify_password( + old_password.as_bytes(), + &PasswordHash::new(pass)?, + )?; + } + + user + }; + + let mut transaction = pool.begin().await?; + + let update_password = if let Some(new_password) = + &change_password.new_password + { + let score = zxcvbn::zxcvbn( + new_password, + &[&user.username, &user.email.clone().unwrap_or_default()], + )?; + + if score.score() < 3 { + return Err(ApiError::InvalidInput( + if let Some(feedback) = + score.feedback().clone().and_then(|x| x.warning()) + { + format!("Password too weak: {}", feedback) + } else { + "Specified password is too weak! Please improve its strength.".to_string() + }, + )); + } + + let hasher = Argon2::default(); + let salt = SaltString::generate(&mut ChaCha20Rng::from_entropy()); + let password_hash = hasher + .hash_password(new_password.as_bytes(), &salt)? + .to_string(); + + Some(password_hash) + } else { + if !(user.github_id.is_some() + || user.gitlab_id.is_some() + || user.microsoft_id.is_some() + || user.google_id.is_some() + || user.steam_id.is_some() + || user.discord_id.is_some()) + { + return Err(ApiError::InvalidInput( + "You must have another authentication method added to remove password authentication!".to_string(), + )); + } + + None + }; + + sqlx::query!( + " + UPDATE users + SET password = $1 + WHERE (id = $2) + ", + update_password, + user.id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + + if let Some(flow) = &change_password.flow { + Flow::remove(flow, &redis).await?; + } + + if let Some(email) = user.email { + let changed = if update_password.is_some() { + "changed" + } else { + "removed" + }; + + send_email( + email, + &format!("Password {}", changed), + &format!("Your password has been {} on your account.", changed), + "If you did not make this change, please contact us immediately through our support channels on Discord or via email (support@modrinth.com).", + None, + )?; + } + + transaction.commit().await?; + crate::database::models::User::clear_caches(&[(user.id, None)], &redis) + .await?; + + Ok(HttpResponse::Ok().finish()) +} + +#[derive(Deserialize, Validate)] +pub struct SetEmail { + #[validate(email)] + pub email: String, +} + +#[patch("email")] +pub async fn set_email( + req: HttpRequest, + pool: Data, + redis: Data, + email: web::Json, + session_queue: Data, + stripe_client: Data, +) -> Result { + email.0.validate().map_err(|err| { + ApiError::InvalidInput(validation_errors_to_string(err, None)) + })?; + + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::USER_AUTH_WRITE]), + ) + .await? + .1; + + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE users + SET email = $1, email_verified = FALSE + WHERE (id = $2) + ", + email.email, + user.id.0 as i64, + ) + .execute(&mut *transaction) + .await?; + + if let Some(user_email) = user.email { + send_email( + user_email, + "Email changed", + &format!("Your email has been updated to {} on your account.", email.email), + "If you did not make this change, please contact us immediately through our support channels on Discord or via email (support@modrinth.com).", + None, + )?; + } + + if let Some(customer_id) = user + .stripe_customer_id + .as_ref() + .and_then(|x| stripe::CustomerId::from_str(x).ok()) + { + stripe::Customer::update( + &stripe_client, + &customer_id, + stripe::UpdateCustomer { + email: Some(&email.email), + ..Default::default() + }, + ) + .await?; + } + + let flow = Flow::ConfirmEmail { + user_id: user.id.into(), + confirm_email: email.email.clone(), + } + .insert(Duration::hours(24), &redis) + .await?; + + send_email_verify( + email.email.clone(), + flow, + "We need to verify your email address.", + )?; + + transaction.commit().await?; + crate::database::models::User::clear_caches( + &[(user.id.into(), None)], + &redis, + ) + .await?; + + Ok(HttpResponse::Ok().finish()) +} + +#[post("email/resend_verify")] +pub async fn resend_verify_email( + req: HttpRequest, + pool: Data, + redis: Data, + session_queue: Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::USER_AUTH_WRITE]), + ) + .await? + .1; + + if let Some(email) = user.email { + if user.email_verified.unwrap_or(false) { + return Err(ApiError::InvalidInput( + "User email is already verified!".to_string(), + )); + } + + let flow = Flow::ConfirmEmail { + user_id: user.id.into(), + confirm_email: email.clone(), + } + .insert(Duration::hours(24), &redis) + .await?; + + send_email_verify( + email, + flow, + "We need to verify your email address.", + )?; + + Ok(HttpResponse::NoContent().finish()) + } else { + Err(ApiError::InvalidInput( + "User does not have an email.".to_string(), + )) + } +} + +#[derive(Deserialize)] +pub struct VerifyEmail { + pub flow: String, +} + +#[post("email/verify")] +pub async fn verify_email( + pool: Data, + redis: Data, + email: web::Json, +) -> Result { + let flow = Flow::get(&email.flow, &redis).await?; + + if let Some(Flow::ConfirmEmail { + user_id, + confirm_email, + }) = flow + { + let user = + crate::database::models::User::get_id(user_id, &**pool, &redis) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + + if user.email != Some(confirm_email) { + return Err(ApiError::InvalidInput( + "E-mail does not match verify email. Try re-requesting the verification link." + .to_string(), + )); + } + + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE users + SET email_verified = TRUE + WHERE (id = $1) + ", + user.id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + + Flow::remove(&email.flow, &redis).await?; + transaction.commit().await?; + crate::database::models::User::clear_caches(&[(user.id, None)], &redis) + .await?; + + Ok(HttpResponse::NoContent().finish()) + } else { + Err(ApiError::InvalidInput( + "Flow does not exist. Try re-requesting the verification link." + .to_string(), + )) + } +} + +#[post("email/subscribe")] +pub async fn subscribe_newsletter( + req: HttpRequest, + pool: Data, + redis: Data, + session_queue: Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::USER_AUTH_WRITE]), + ) + .await? + .1; + + if let Some(email) = user.email { + sign_up_beehiiv(&email).await?; + + Ok(HttpResponse::NoContent().finish()) + } else { + Err(ApiError::InvalidInput( + "User does not have an email.".to_string(), + )) + } +} + +fn send_email_verify( + email: String, + flow: String, + opener: &str, +) -> Result<(), crate::auth::email::MailError> { + send_email( + email, + "Verify your email", + opener, + "Please visit the following link below to verify your email. If the button does not work, you can copy the link and paste it into your browser. This link expires in 24 hours.", + Some(("Verify email", &format!("{}/{}?flow={}", dotenvy::var("SITE_URL")?, dotenvy::var("SITE_VERIFY_EMAIL_PATH")?, flow))), + ) +} diff --git a/apps/labrinth/src/routes/internal/gdpr.rs b/apps/labrinth/src/routes/internal/gdpr.rs new file mode 100644 index 000000000..6f1d3e611 --- /dev/null +++ b/apps/labrinth/src/routes/internal/gdpr.rs @@ -0,0 +1,204 @@ +use crate::auth::get_user_from_headers; +use crate::database::redis::RedisPool; +use crate::models::pats::Scopes; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use actix_web::{post, web, HttpRequest, HttpResponse}; +use sqlx::PgPool; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(web::scope("gdpr").service(export)); +} + +#[post("/export")] +pub async fn export( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::SESSION_ACCESS]), + ) + .await? + .1; + + let user_id = user.id.into(); + + let collection_ids = + crate::database::models::User::get_collections(user_id, &**pool) + .await?; + let collections = crate::database::models::Collection::get_many( + &collection_ids, + &**pool, + &redis, + ) + .await? + .into_iter() + .map(crate::models::collections::Collection::from) + .collect::>(); + + let follows = crate::database::models::User::get_follows(user_id, &**pool) + .await? + .into_iter() + .map(crate::models::ids::ProjectId::from) + .collect::>(); + + let projects = + crate::database::models::User::get_projects(user_id, &**pool, &redis) + .await? + .into_iter() + .map(crate::models::ids::ProjectId::from) + .collect::>(); + + let org_ids = + crate::database::models::User::get_organizations(user_id, &**pool) + .await?; + let orgs = + crate::database::models::organization_item::Organization::get_many_ids( + &org_ids, &**pool, &redis, + ) + .await? + .into_iter() + // TODO: add team members + .map(|x| crate::models::organizations::Organization::from(x, vec![])) + .collect::>(); + + let notifs = crate::database::models::notification_item::Notification::get_many_user( + user_id, &**pool, &redis, + ) + .await? + .into_iter() + .map(crate::models::notifications::Notification::from) + .collect::>(); + + let oauth_clients = + crate::database::models::oauth_client_item::OAuthClient::get_all_user_clients( + user_id, &**pool, + ) + .await? + .into_iter() + .map(crate::models::oauth_clients::OAuthClient::from) + .collect::>(); + + let oauth_authorizations = crate::database::models::oauth_client_authorization_item::OAuthClientAuthorization::get_all_for_user( + user_id, &**pool, + ) + .await? + .into_iter() + .map(crate::models::oauth_clients::OAuthClientAuthorization::from) + .collect::>(); + + let pat_ids = + crate::database::models::pat_item::PersonalAccessToken::get_user_pats( + user_id, &**pool, &redis, + ) + .await?; + let pats = + crate::database::models::pat_item::PersonalAccessToken::get_many_ids( + &pat_ids, &**pool, &redis, + ) + .await? + .into_iter() + .map(|x| crate::models::pats::PersonalAccessToken::from(x, false)) + .collect::>(); + + let payout_ids = + crate::database::models::payout_item::Payout::get_all_for_user( + user_id, &**pool, + ) + .await?; + + let payouts = crate::database::models::payout_item::Payout::get_many( + &payout_ids, + &**pool, + ) + .await? + .into_iter() + .map(crate::models::payouts::Payout::from) + .collect::>(); + + let report_ids = + crate::database::models::user_item::User::get_reports(user_id, &**pool) + .await?; + let reports = crate::database::models::report_item::Report::get_many( + &report_ids, + &**pool, + ) + .await? + .into_iter() + .map(crate::models::reports::Report::from) + .collect::>(); + + let message_ids = sqlx::query!( + " + SELECT id FROM threads_messages WHERE author_id = $1 AND hide_identity = FALSE + ", + user_id.0 + ) + .fetch_all(pool.as_ref()) + .await? + .into_iter() + .map(|x| crate::database::models::ids::ThreadMessageId(x.id)) + .collect::>(); + + let messages = + crate::database::models::thread_item::ThreadMessage::get_many( + &message_ids, + &**pool, + ) + .await? + .into_iter() + .map(|x| crate::models::threads::ThreadMessage::from(x, &user)) + .collect::>(); + + let uploaded_images_ids = sqlx::query!( + "SELECT id FROM uploaded_images WHERE owner_id = $1", + user_id.0 + ) + .fetch_all(pool.as_ref()) + .await? + .into_iter() + .map(|x| crate::database::models::ids::ImageId(x.id)) + .collect::>(); + + let uploaded_images = crate::database::models::image_item::Image::get_many( + &uploaded_images_ids, + &**pool, + &redis, + ) + .await? + .into_iter() + .map(crate::models::images::Image::from) + .collect::>(); + + let subscriptions = + crate::database::models::user_subscription_item::UserSubscriptionItem::get_all_user( + user_id, &**pool, + ) + .await? + .into_iter() + .map(crate::models::billing::UserSubscription::from) + .collect::>(); + + Ok(HttpResponse::Ok().json(serde_json::json!({ + "user": user, + "collections": collections, + "follows": follows, + "projects": projects, + "orgs": orgs, + "notifs": notifs, + "oauth_clients": oauth_clients, + "oauth_authorizations": oauth_authorizations, + "pats": pats, + "payouts": payouts, + "reports": reports, + "messages": messages, + "uploaded_images": uploaded_images, + "subscriptions": subscriptions, + }))) +} diff --git a/apps/labrinth/src/routes/internal/mod.rs b/apps/labrinth/src/routes/internal/mod.rs new file mode 100644 index 000000000..0472899d1 --- /dev/null +++ b/apps/labrinth/src/routes/internal/mod.rs @@ -0,0 +1,26 @@ +pub(crate) mod admin; +pub mod billing; +pub mod flows; +pub mod gdpr; +pub mod moderation; +pub mod pats; +pub mod session; + +use super::v3::oauth_clients; +pub use super::ApiError; +use crate::util::cors::default_cors; + +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service( + actix_web::web::scope("_internal") + .wrap(default_cors()) + .configure(admin::config) + .configure(oauth_clients::config) + .configure(session::config) + .configure(flows::config) + .configure(pats::config) + .configure(moderation::config) + .configure(billing::config) + .configure(gdpr::config), + ); +} diff --git a/apps/labrinth/src/routes/internal/moderation.rs b/apps/labrinth/src/routes/internal/moderation.rs new file mode 100644 index 000000000..9f59e738e --- /dev/null +++ b/apps/labrinth/src/routes/internal/moderation.rs @@ -0,0 +1,316 @@ +use super::ApiError; +use crate::database; +use crate::database::redis::RedisPool; +use crate::models::ids::random_base62; +use crate::models::projects::ProjectStatus; +use crate::queue::moderation::{ApprovalType, IdentifiedFile, MissingMetadata}; +use crate::queue::session::AuthQueue; +use crate::{auth::check_is_moderator_from_headers, models::pats::Scopes}; +use actix_web::{web, HttpRequest, HttpResponse}; +use serde::Deserialize; +use sqlx::PgPool; +use std::collections::HashMap; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.route("moderation/projects", web::get().to(get_projects)); + cfg.route("moderation/project/{id}", web::get().to(get_project_meta)); + cfg.route("moderation/project", web::post().to(set_project_meta)); +} + +#[derive(Deserialize)] +pub struct ResultCount { + #[serde(default = "default_count")] + pub count: i16, +} + +fn default_count() -> i16 { + 100 +} + +pub async fn get_projects( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + count: web::Query, + session_queue: web::Data, +) -> Result { + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await?; + + use futures::stream::TryStreamExt; + + let project_ids = sqlx::query!( + " + SELECT id FROM mods + WHERE status = $1 + ORDER BY queued ASC + LIMIT $2; + ", + ProjectStatus::Processing.as_str(), + count.count as i64 + ) + .fetch(&**pool) + .map_ok(|m| database::models::ProjectId(m.id)) + .try_collect::>() + .await?; + + let projects: Vec<_> = + database::Project::get_many_ids(&project_ids, &**pool, &redis) + .await? + .into_iter() + .map(crate::models::projects::Project::from) + .collect(); + + Ok(HttpResponse::Ok().json(projects)) +} + +pub async fn get_project_meta( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + info: web::Path<(String,)>, +) -> Result { + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await?; + + let project_id = info.into_inner().0; + let project = + database::models::Project::get(&project_id, &**pool, &redis).await?; + + if let Some(project) = project { + let rows = sqlx::query!( + " + SELECT + f.metadata, v.id version_id + FROM versions v + INNER JOIN files f ON f.version_id = v.id + WHERE v.mod_id = $1 + ", + project.inner.id.0 + ) + .fetch_all(&**pool) + .await?; + + let mut merged = MissingMetadata { + identified: HashMap::new(), + flame_files: HashMap::new(), + unknown_files: HashMap::new(), + }; + + let mut check_hashes = Vec::new(); + let mut check_flames = Vec::new(); + + for row in rows { + if let Some(metadata) = row + .metadata + .and_then(|x| serde_json::from_value::(x).ok()) + { + merged.identified.extend(metadata.identified); + merged.flame_files.extend(metadata.flame_files); + merged.unknown_files.extend(metadata.unknown_files); + + check_hashes.extend(merged.flame_files.keys().cloned()); + check_hashes.extend(merged.unknown_files.keys().cloned()); + check_flames + .extend(merged.flame_files.values().map(|x| x.id as i32)); + } + } + + let rows = sqlx::query!( + " + SELECT encode(mef.sha1, 'escape') sha1, mel.status status + FROM moderation_external_files mef + INNER JOIN moderation_external_licenses mel ON mef.external_license_id = mel.id + WHERE mef.sha1 = ANY($1) + ", + &check_hashes + .iter() + .map(|x| x.as_bytes().to_vec()) + .collect::>() + ) + .fetch_all(&**pool) + .await?; + + for row in rows { + if let Some(sha1) = row.sha1 { + if let Some(val) = merged.flame_files.remove(&sha1) { + merged.identified.insert( + sha1, + IdentifiedFile { + file_name: val.file_name, + status: ApprovalType::from_string(&row.status) + .unwrap_or(ApprovalType::Unidentified), + }, + ); + } else if let Some(val) = merged.unknown_files.remove(&sha1) { + merged.identified.insert( + sha1, + IdentifiedFile { + file_name: val, + status: ApprovalType::from_string(&row.status) + .unwrap_or(ApprovalType::Unidentified), + }, + ); + } + } + } + + let rows = sqlx::query!( + " + SELECT mel.id, mel.flame_project_id, mel.status status + FROM moderation_external_licenses mel + WHERE mel.flame_project_id = ANY($1) + ", + &check_flames, + ) + .fetch_all(&**pool) + .await?; + + for row in rows { + if let Some(sha1) = merged + .flame_files + .iter() + .find(|x| Some(x.1.id as i32) == row.flame_project_id) + .map(|x| x.0.clone()) + { + if let Some(val) = merged.flame_files.remove(&sha1) { + merged.identified.insert( + sha1, + IdentifiedFile { + file_name: val.file_name.clone(), + status: ApprovalType::from_string(&row.status) + .unwrap_or(ApprovalType::Unidentified), + }, + ); + } + } + } + + Ok(HttpResponse::Ok().json(merged)) + } else { + Err(ApiError::NotFound) + } +} + +#[derive(Deserialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Judgement { + Flame { + id: i32, + status: ApprovalType, + link: String, + title: String, + }, + Unknown { + status: ApprovalType, + proof: Option, + link: Option, + title: Option, + }, +} + +pub async fn set_project_meta( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + judgements: web::Json>, +) -> Result { + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await?; + + let mut transaction = pool.begin().await?; + + let mut ids = Vec::new(); + let mut titles = Vec::new(); + let mut statuses = Vec::new(); + let mut links = Vec::new(); + let mut proofs = Vec::new(); + let mut flame_ids = Vec::new(); + + let mut file_hashes = Vec::new(); + + for (hash, judgement) in judgements.0 { + let id = random_base62(8); + + let (title, status, link, proof, flame_id) = match judgement { + Judgement::Flame { + id, + status, + link, + title, + } => ( + Some(title), + status, + Some(link), + Some("See Flame page/license for permission".to_string()), + Some(id), + ), + Judgement::Unknown { + status, + proof, + link, + title, + } => (title, status, link, proof, None), + }; + + ids.push(id as i64); + titles.push(title); + statuses.push(status.as_str()); + links.push(link); + proofs.push(proof); + flame_ids.push(flame_id); + file_hashes.push(hash); + } + + sqlx::query( + " + INSERT INTO moderation_external_licenses (id, title, status, link, proof, flame_project_id) + SELECT * FROM UNNEST ($1::bigint[], $2::varchar[], $3::varchar[], $4::varchar[], $5::varchar[], $6::integer[]) + " + ) + .bind(&ids[..]) + .bind(&titles[..]) + .bind(&statuses[..]) + .bind(&links[..]) + .bind(&proofs[..]) + .bind(&flame_ids[..]) + .execute(&mut *transaction) + .await?; + + sqlx::query( + " + INSERT INTO moderation_external_files (sha1, external_license_id) + SELECT * FROM UNNEST ($1::bytea[], $2::bigint[]) + ON CONFLICT (sha1) + DO NOTHING + ", + ) + .bind(&file_hashes[..]) + .bind(&ids[..]) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().finish()) +} diff --git a/apps/labrinth/src/routes/internal/pats.rs b/apps/labrinth/src/routes/internal/pats.rs new file mode 100644 index 000000000..8539f2596 --- /dev/null +++ b/apps/labrinth/src/routes/internal/pats.rs @@ -0,0 +1,293 @@ +use crate::database; +use crate::database::models::generate_pat_id; + +use crate::auth::get_user_from_headers; +use crate::routes::ApiError; + +use crate::database::redis::RedisPool; +use actix_web::web::{self, Data}; +use actix_web::{delete, get, patch, post, HttpRequest, HttpResponse}; +use chrono::{DateTime, Utc}; +use rand::distributions::Alphanumeric; +use rand::Rng; +use rand_chacha::rand_core::SeedableRng; +use rand_chacha::ChaCha20Rng; + +use crate::models::pats::{PersonalAccessToken, Scopes}; +use crate::queue::session::AuthQueue; +use crate::util::validate::validation_errors_to_string; +use serde::Deserialize; +use sqlx::postgres::PgPool; +use validator::Validate; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(get_pats); + cfg.service(create_pat); + cfg.service(edit_pat); + cfg.service(delete_pat); +} + +#[get("pat")] +pub async fn get_pats( + req: HttpRequest, + pool: Data, + redis: Data, + session_queue: Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PAT_READ]), + ) + .await? + .1; + + let pat_ids = + database::models::pat_item::PersonalAccessToken::get_user_pats( + user.id.into(), + &**pool, + &redis, + ) + .await?; + let pats = database::models::pat_item::PersonalAccessToken::get_many_ids( + &pat_ids, &**pool, &redis, + ) + .await?; + + Ok(HttpResponse::Ok().json( + pats.into_iter() + .map(|x| PersonalAccessToken::from(x, false)) + .collect::>(), + )) +} + +#[derive(Deserialize, Validate)] +pub struct NewPersonalAccessToken { + pub scopes: Scopes, + #[validate(length(min = 3, max = 255))] + pub name: String, + pub expires: DateTime, +} + +#[post("pat")] +pub async fn create_pat( + req: HttpRequest, + info: web::Json, + pool: Data, + redis: Data, + session_queue: Data, +) -> Result { + info.0.validate().map_err(|err| { + ApiError::InvalidInput(validation_errors_to_string(err, None)) + })?; + + if info.scopes.is_restricted() { + return Err(ApiError::InvalidInput( + "Invalid scopes requested!".to_string(), + )); + } + if info.expires < Utc::now() { + return Err(ApiError::InvalidInput( + "Expire date must be in the future!".to_string(), + )); + } + + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PAT_CREATE]), + ) + .await? + .1; + + let mut transaction = pool.begin().await?; + + let id = generate_pat_id(&mut transaction).await?; + + let token = ChaCha20Rng::from_entropy() + .sample_iter(&Alphanumeric) + .take(60) + .map(char::from) + .collect::(); + let token = format!("mrp_{}", token); + + let name = info.name.clone(); + database::models::pat_item::PersonalAccessToken { + id, + name: name.clone(), + access_token: token.clone(), + scopes: info.scopes, + user_id: user.id.into(), + created: Utc::now(), + expires: info.expires, + last_used: None, + } + .insert(&mut transaction) + .await?; + + transaction.commit().await?; + database::models::pat_item::PersonalAccessToken::clear_cache( + vec![(None, None, Some(user.id.into()))], + &redis, + ) + .await?; + + Ok(HttpResponse::Ok().json(PersonalAccessToken { + id: id.into(), + name, + access_token: Some(token), + scopes: info.scopes, + user_id: user.id, + created: Utc::now(), + expires: info.expires, + last_used: None, + })) +} + +#[derive(Deserialize, Validate)] +pub struct ModifyPersonalAccessToken { + pub scopes: Option, + #[validate(length(min = 3, max = 255))] + pub name: Option, + pub expires: Option>, +} + +#[patch("pat/{id}")] +pub async fn edit_pat( + req: HttpRequest, + id: web::Path<(String,)>, + info: web::Json, + pool: Data, + redis: Data, + session_queue: Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PAT_WRITE]), + ) + .await? + .1; + + let id = id.into_inner().0; + let pat = database::models::pat_item::PersonalAccessToken::get( + &id, &**pool, &redis, + ) + .await?; + + if let Some(pat) = pat { + if pat.user_id == user.id.into() { + let mut transaction = pool.begin().await?; + + if let Some(scopes) = &info.scopes { + if scopes.is_restricted() { + return Err(ApiError::InvalidInput( + "Invalid scopes requested!".to_string(), + )); + } + + sqlx::query!( + " + UPDATE pats + SET scopes = $1 + WHERE id = $2 + ", + scopes.bits() as i64, + pat.id.0 + ) + .execute(&mut *transaction) + .await?; + } + if let Some(name) = &info.name { + sqlx::query!( + " + UPDATE pats + SET name = $1 + WHERE id = $2 + ", + name, + pat.id.0 + ) + .execute(&mut *transaction) + .await?; + } + if let Some(expires) = &info.expires { + if expires < &Utc::now() { + return Err(ApiError::InvalidInput( + "Expire date must be in the future!".to_string(), + )); + } + + sqlx::query!( + " + UPDATE pats + SET expires = $1 + WHERE id = $2 + ", + expires, + pat.id.0 + ) + .execute(&mut *transaction) + .await?; + } + + transaction.commit().await?; + database::models::pat_item::PersonalAccessToken::clear_cache( + vec![(Some(pat.id), Some(pat.access_token), Some(pat.user_id))], + &redis, + ) + .await?; + } + } + + Ok(HttpResponse::NoContent().finish()) +} + +#[delete("pat/{id}")] +pub async fn delete_pat( + req: HttpRequest, + id: web::Path<(String,)>, + pool: Data, + redis: Data, + session_queue: Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PAT_DELETE]), + ) + .await? + .1; + let id = id.into_inner().0; + let pat = database::models::pat_item::PersonalAccessToken::get( + &id, &**pool, &redis, + ) + .await?; + + if let Some(pat) = pat { + if pat.user_id == user.id.into() { + let mut transaction = pool.begin().await?; + database::models::pat_item::PersonalAccessToken::remove( + pat.id, + &mut transaction, + ) + .await?; + transaction.commit().await?; + database::models::pat_item::PersonalAccessToken::clear_cache( + vec![(Some(pat.id), Some(pat.access_token), Some(pat.user_id))], + &redis, + ) + .await?; + } + } + + Ok(HttpResponse::NoContent().finish()) +} diff --git a/apps/labrinth/src/routes/internal/session.rs b/apps/labrinth/src/routes/internal/session.rs new file mode 100644 index 000000000..b928c70db --- /dev/null +++ b/apps/labrinth/src/routes/internal/session.rs @@ -0,0 +1,261 @@ +use crate::auth::{get_user_from_headers, AuthenticationError}; +use crate::database::models::session_item::Session as DBSession; +use crate::database::models::session_item::SessionBuilder; +use crate::database::models::UserId; +use crate::database::redis::RedisPool; +use crate::models::pats::Scopes; +use crate::models::sessions::Session; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use crate::util::env::parse_var; +use actix_web::http::header::AUTHORIZATION; +use actix_web::web::{scope, Data, ServiceConfig}; +use actix_web::{delete, get, post, web, HttpRequest, HttpResponse}; +use chrono::Utc; +use rand::distributions::Alphanumeric; +use rand::{Rng, SeedableRng}; +use rand_chacha::ChaCha20Rng; +use sqlx::PgPool; +use woothee::parser::Parser; + +pub fn config(cfg: &mut ServiceConfig) { + cfg.service( + scope("session") + .service(list) + .service(delete) + .service(refresh), + ); +} + +pub struct SessionMetadata { + pub city: Option, + pub country: Option, + pub ip: String, + + pub os: Option, + pub platform: Option, + pub user_agent: String, +} + +pub async fn get_session_metadata( + req: &HttpRequest, +) -> Result { + let conn_info = req.connection_info().clone(); + let ip_addr = if parse_var("CLOUDFLARE_INTEGRATION").unwrap_or(false) { + if let Some(header) = req.headers().get("CF-Connecting-IP") { + header.to_str().ok() + } else { + conn_info.peer_addr() + } + } else { + conn_info.peer_addr() + }; + + let country = req + .headers() + .get("cf-ipcountry") + .and_then(|x| x.to_str().ok()); + let city = req.headers().get("cf-ipcity").and_then(|x| x.to_str().ok()); + + let user_agent = req + .headers() + .get("user-agent") + .and_then(|x| x.to_str().ok()) + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + + let parser = Parser::new(); + let info = parser.parse(user_agent); + let os = if let Some(info) = info { + Some((info.os, info.name)) + } else { + None + }; + + Ok(SessionMetadata { + os: os.map(|x| x.0.to_string()), + platform: os.map(|x| x.1.to_string()), + city: city.map(|x| x.to_string()), + country: country.map(|x| x.to_string()), + ip: ip_addr + .ok_or_else(|| AuthenticationError::InvalidCredentials)? + .to_string(), + user_agent: user_agent.to_string(), + }) +} + +pub async fn issue_session( + req: HttpRequest, + user_id: UserId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &RedisPool, +) -> Result { + let metadata = get_session_metadata(&req).await?; + + let session = ChaCha20Rng::from_entropy() + .sample_iter(&Alphanumeric) + .take(60) + .map(char::from) + .collect::(); + + let session = format!("mra_{session}"); + + let id = SessionBuilder { + session, + user_id, + os: metadata.os, + platform: metadata.platform, + city: metadata.city, + country: metadata.country, + ip: metadata.ip, + user_agent: metadata.user_agent, + } + .insert(transaction) + .await?; + + let session = DBSession::get_id(id, &mut **transaction, redis) + .await? + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + + DBSession::clear_cache( + vec![( + Some(session.id), + Some(session.session.clone()), + Some(session.user_id), + )], + redis, + ) + .await?; + + Ok(session) +} + +#[get("list")] +pub async fn list( + req: HttpRequest, + pool: Data, + redis: Data, + session_queue: Data, +) -> Result { + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::SESSION_READ]), + ) + .await? + .1; + + let session = req + .headers() + .get(AUTHORIZATION) + .and_then(|x| x.to_str().ok()) + .ok_or_else(|| AuthenticationError::InvalidCredentials)?; + + let session_ids = + DBSession::get_user_sessions(current_user.id.into(), &**pool, &redis) + .await?; + let sessions = DBSession::get_many_ids(&session_ids, &**pool, &redis) + .await? + .into_iter() + .filter(|x| x.expires > Utc::now()) + .map(|x| Session::from(x, false, Some(session))) + .collect::>(); + + Ok(HttpResponse::Ok().json(sessions)) +} + +#[delete("{id}")] +pub async fn delete( + info: web::Path<(String,)>, + req: HttpRequest, + pool: Data, + redis: Data, + session_queue: Data, +) -> Result { + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::SESSION_DELETE]), + ) + .await? + .1; + + let session = DBSession::get(info.into_inner().0, &**pool, &redis).await?; + + if let Some(session) = session { + if session.user_id == current_user.id.into() { + let mut transaction = pool.begin().await?; + DBSession::remove(session.id, &mut transaction).await?; + transaction.commit().await?; + DBSession::clear_cache( + vec![( + Some(session.id), + Some(session.session), + Some(session.user_id), + )], + &redis, + ) + .await?; + } + } + + Ok(HttpResponse::NoContent().body("")) +} + +#[post("refresh")] +pub async fn refresh( + req: HttpRequest, + pool: Data, + redis: Data, + session_queue: Data, +) -> Result { + let current_user = + get_user_from_headers(&req, &**pool, &redis, &session_queue, None) + .await? + .1; + let session = req + .headers() + .get(AUTHORIZATION) + .and_then(|x| x.to_str().ok()) + .ok_or_else(|| { + ApiError::Authentication(AuthenticationError::InvalidCredentials) + })?; + + let session = DBSession::get(session, &**pool, &redis).await?; + + if let Some(session) = session { + if current_user.id != session.user_id.into() + || session.refresh_expires < Utc::now() + { + return Err(ApiError::Authentication( + AuthenticationError::InvalidCredentials, + )); + } + + let mut transaction = pool.begin().await?; + + DBSession::remove(session.id, &mut transaction).await?; + let new_session = + issue_session(req, session.user_id, &mut transaction, &redis) + .await?; + transaction.commit().await?; + DBSession::clear_cache( + vec![( + Some(session.id), + Some(session.session), + Some(session.user_id), + )], + &redis, + ) + .await?; + + Ok(HttpResponse::Ok().json(Session::from(new_session, true, None))) + } else { + Err(ApiError::Authentication( + AuthenticationError::InvalidCredentials, + )) + } +} diff --git a/apps/labrinth/src/routes/maven.rs b/apps/labrinth/src/routes/maven.rs new file mode 100644 index 000000000..c337127a2 --- /dev/null +++ b/apps/labrinth/src/routes/maven.rs @@ -0,0 +1,431 @@ +use crate::auth::checks::{is_visible_project, is_visible_version}; +use crate::database::models::legacy_loader_fields::MinecraftGameVersion; +use crate::database::models::loader_fields::Loader; +use crate::database::models::project_item::QueryProject; +use crate::database::models::version_item::{QueryFile, QueryVersion}; +use crate::database::redis::RedisPool; +use crate::models::pats::Scopes; +use crate::models::projects::{ProjectId, VersionId}; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use crate::{auth::get_user_from_headers, database}; +use actix_web::{get, route, web, HttpRequest, HttpResponse}; +use sqlx::PgPool; +use std::collections::HashSet; +use yaserde_derive::YaSerialize; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(maven_metadata); + cfg.service(version_file_sha512); + cfg.service(version_file_sha1); + cfg.service(version_file); +} + +// TODO: These were modified in v3 and should be tested + +#[derive(Default, Debug, Clone, YaSerialize)] +#[yaserde(root = "metadata", rename = "metadata")] +pub struct Metadata { + #[yaserde(rename = "groupId")] + group_id: String, + #[yaserde(rename = "artifactId")] + artifact_id: String, + versioning: Versioning, +} + +#[derive(Default, Debug, Clone, YaSerialize)] +#[yaserde(rename = "versioning")] +pub struct Versioning { + latest: String, + release: String, + versions: Versions, + #[yaserde(rename = "lastUpdated")] + last_updated: String, +} + +#[derive(Default, Debug, Clone, YaSerialize)] +#[yaserde(rename = "versions")] +pub struct Versions { + #[yaserde(rename = "version")] + versions: Vec, +} + +#[derive(Default, Debug, Clone, YaSerialize)] +#[yaserde(rename = "project", namespace = "http://maven.apache.org/POM/4.0.0")] +pub struct MavenPom { + #[yaserde(rename = "xsi:schemaLocation", attribute)] + schema_location: String, + #[yaserde(rename = "xmlns:xsi", attribute)] + xsi: String, + #[yaserde(rename = "modelVersion")] + model_version: String, + #[yaserde(rename = "groupId")] + group_id: String, + #[yaserde(rename = "artifactId")] + artifact_id: String, + version: String, + name: String, + description: String, +} + +#[get("maven/modrinth/{id}/maven-metadata.xml")] +pub async fn maven_metadata( + req: HttpRequest, + params: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let project_id = params.into_inner().0; + let Some(project) = + database::models::Project::get(&project_id, &**pool, &redis).await? + else { + return Err(ApiError::NotFound); + }; + + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + if !is_visible_project(&project.inner, &user_option, &pool, false).await? { + return Err(ApiError::NotFound); + } + + let version_names = sqlx::query!( + " + SELECT id, version_number, version_type + FROM versions + WHERE mod_id = $1 AND status = ANY($2) + ORDER BY ordering ASC NULLS LAST, date_published ASC + ", + project.inner.id as database::models::ids::ProjectId, + &*crate::models::projects::VersionStatus::iterator() + .filter(|x| x.is_listed()) + .map(|x| x.to_string()) + .collect::>(), + ) + .fetch_all(&**pool) + .await?; + + let mut new_versions = Vec::new(); + let mut vals = HashSet::new(); + let mut latest_release = None; + + for row in version_names { + let value = if vals.contains(&row.version_number) { + format!("{}", VersionId(row.id as u64)) + } else { + row.version_number + }; + + vals.insert(value.clone()); + if row.version_type == "release" { + latest_release = Some(value.clone()) + } + + new_versions.push(value); + } + + let project_id: ProjectId = project.inner.id.into(); + + let respdata = Metadata { + group_id: "maven.modrinth".to_string(), + artifact_id: project_id.to_string(), + versioning: Versioning { + latest: new_versions + .last() + .unwrap_or(&"release".to_string()) + .to_string(), + release: latest_release.unwrap_or_default(), + versions: Versions { + versions: new_versions, + }, + last_updated: project + .inner + .updated + .format("%Y%m%d%H%M%S") + .to_string(), + }, + }; + + Ok(HttpResponse::Ok() + .content_type("text/xml") + .body(yaserde::ser::to_string(&respdata).map_err(ApiError::Xml)?)) +} + +async fn find_version( + project: &QueryProject, + vcoords: &String, + pool: &PgPool, + redis: &RedisPool, +) -> Result, ApiError> { + let id_option = crate::models::ids::base62_impl::parse_base62(vcoords) + .ok() + .map(|x| x as i64); + + let all_versions = + database::models::Version::get_many(&project.versions, pool, redis) + .await?; + + let exact_matches = all_versions + .iter() + .filter(|x| { + &x.inner.version_number == vcoords + || Some(x.inner.id.0) == id_option + }) + .collect::>(); + + if exact_matches.len() == 1 { + return Ok(Some(exact_matches[0].clone())); + } + + // Try to parse version filters from version coords. + let Some((vnumber, filter)) = vcoords.rsplit_once('-') else { + return Ok(exact_matches.first().map(|x| (*x).clone())); + }; + + let db_loaders: HashSet = Loader::list(pool, redis) + .await? + .into_iter() + .map(|x| x.loader) + .collect(); + + let (loaders, game_versions) = filter + .split(',') + .map(String::from) + .partition::, _>(|el| db_loaders.contains(el)); + + let matched = all_versions + .iter() + .filter(|x| { + let mut bool = x.inner.version_number == vnumber; + + if !loaders.is_empty() { + bool &= x.loaders.iter().any(|y| loaders.contains(y)); + } + + // For maven in particular, we will hardcode it to use GameVersions rather than generic loader fields, as this is minecraft-java exclusive + if !game_versions.is_empty() { + let version_game_versions = + x.version_fields.clone().into_iter().find_map(|v| { + MinecraftGameVersion::try_from_version_field(&v).ok() + }); + if let Some(version_game_versions) = version_game_versions { + bool &= version_game_versions + .iter() + .any(|y| game_versions.contains(&y.version)); + } + } + + bool + }) + .collect::>(); + + Ok(matched + .first() + .or_else(|| exact_matches.first()) + .copied() + .cloned()) +} + +fn find_file<'a>( + project_id: &str, + vcoords: &str, + version: &'a QueryVersion, + file: &str, +) -> Option<&'a QueryFile> { + if let Some(selected_file) = + version.files.iter().find(|x| x.filename == file) + { + return Some(selected_file); + } + + // Minecraft mods are not going to be both a mod and a modpack, so this minecraft-specific handling is fine + // As there can be multiple project types, returns the first allowable match + let mut fileexts = vec![]; + for project_type in version.project_types.iter() { + match project_type.as_str() { + "mod" => fileexts.push("jar"), + "modpack" => fileexts.push("mrpack"), + _ => (), + } + } + + for fileext in fileexts { + if file == format!("{}-{}.{}", &project_id, &vcoords, fileext) { + return version + .files + .iter() + .find(|x| x.primary) + .or_else(|| version.files.iter().last()); + } + } + None +} + +#[route( + "maven/modrinth/{id}/{versionnum}/{file}", + method = "GET", + method = "HEAD" +)] +pub async fn version_file( + req: HttpRequest, + params: web::Path<(String, String, String)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let (project_id, vnum, file) = params.into_inner(); + let Some(project) = + database::models::Project::get(&project_id, &**pool, &redis).await? + else { + return Err(ApiError::NotFound); + }; + + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + if !is_visible_project(&project.inner, &user_option, &pool, false).await? { + return Err(ApiError::NotFound); + } + + let Some(version) = find_version(&project, &vnum, &pool, &redis).await? + else { + return Err(ApiError::NotFound); + }; + + if !is_visible_version(&version.inner, &user_option, &pool, &redis).await? { + return Err(ApiError::NotFound); + } + + if file == format!("{}-{}.pom", &project_id, &vnum) { + let respdata = MavenPom { + schema_location: + "http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd" + .to_string(), + xsi: "http://www.w3.org/2001/XMLSchema-instance".to_string(), + model_version: "4.0.0".to_string(), + group_id: "maven.modrinth".to_string(), + artifact_id: project_id, + version: vnum, + name: project.inner.name, + description: project.inner.description, + }; + return Ok(HttpResponse::Ok() + .content_type("text/xml") + .body(yaserde::ser::to_string(&respdata).map_err(ApiError::Xml)?)); + } else if let Some(selected_file) = + find_file(&project_id, &vnum, &version, &file) + { + return Ok(HttpResponse::TemporaryRedirect() + .append_header(("location", &*selected_file.url)) + .body("")); + } + + Err(ApiError::NotFound) +} + +#[get("maven/modrinth/{id}/{versionnum}/{file}.sha1")] +pub async fn version_file_sha1( + req: HttpRequest, + params: web::Path<(String, String, String)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let (project_id, vnum, file) = params.into_inner(); + let Some(project) = + database::models::Project::get(&project_id, &**pool, &redis).await? + else { + return Err(ApiError::NotFound); + }; + + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + if !is_visible_project(&project.inner, &user_option, &pool, false).await? { + return Err(ApiError::NotFound); + } + + let Some(version) = find_version(&project, &vnum, &pool, &redis).await? + else { + return Err(ApiError::NotFound); + }; + + if !is_visible_version(&version.inner, &user_option, &pool, &redis).await? { + return Err(ApiError::NotFound); + } + + Ok(find_file(&project_id, &vnum, &version, &file) + .and_then(|file| file.hashes.get("sha1")) + .map(|hash_str| HttpResponse::Ok().body(hash_str.clone())) + .unwrap_or_else(|| HttpResponse::NotFound().body(""))) +} + +#[get("maven/modrinth/{id}/{versionnum}/{file}.sha512")] +pub async fn version_file_sha512( + req: HttpRequest, + params: web::Path<(String, String, String)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let (project_id, vnum, file) = params.into_inner(); + let Some(project) = + database::models::Project::get(&project_id, &**pool, &redis).await? + else { + return Err(ApiError::NotFound); + }; + + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + if !is_visible_project(&project.inner, &user_option, &pool, false).await? { + return Err(ApiError::NotFound); + } + + let Some(version) = find_version(&project, &vnum, &pool, &redis).await? + else { + return Err(ApiError::NotFound); + }; + + if !is_visible_version(&version.inner, &user_option, &pool, &redis).await? { + return Err(ApiError::NotFound); + } + + Ok(find_file(&project_id, &vnum, &version, &file) + .and_then(|file| file.hashes.get("sha512")) + .map(|hash_str| HttpResponse::Ok().body(hash_str.clone())) + .unwrap_or_else(|| HttpResponse::NotFound().body(""))) +} diff --git a/apps/labrinth/src/routes/mod.rs b/apps/labrinth/src/routes/mod.rs new file mode 100644 index 000000000..38247d053 --- /dev/null +++ b/apps/labrinth/src/routes/mod.rs @@ -0,0 +1,216 @@ +use crate::file_hosting::FileHostingError; +use crate::routes::analytics::{page_view_ingest, playtime_ingest}; +use crate::util::cors::default_cors; +use crate::util::env::parse_strings_from_var; +use actix_cors::Cors; +use actix_files::Files; +use actix_web::http::StatusCode; +use actix_web::{web, HttpResponse}; +use futures::FutureExt; + +pub mod internal; +pub mod v2; +pub mod v3; + +pub mod v2_reroute; + +mod analytics; +mod index; +mod maven; +mod not_found; +mod updates; + +pub use self::not_found::not_found; + +pub fn root_config(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("maven") + .wrap(default_cors()) + .configure(maven::config), + ); + cfg.service( + web::scope("updates") + .wrap(default_cors()) + .configure(updates::config), + ); + cfg.service( + web::scope("analytics") + .wrap( + Cors::default() + .allowed_origin_fn(|origin, _req_head| { + let allowed_origins = + parse_strings_from_var("ANALYTICS_ALLOWED_ORIGINS") + .unwrap_or_default(); + + allowed_origins.contains(&"*".to_string()) + || allowed_origins.contains( + &origin + .to_str() + .unwrap_or_default() + .to_string(), + ) + }) + .allowed_methods(vec!["GET", "POST"]) + .allowed_headers(vec![ + actix_web::http::header::AUTHORIZATION, + actix_web::http::header::ACCEPT, + actix_web::http::header::CONTENT_TYPE, + ]) + .max_age(3600), + ) + .service(page_view_ingest) + .service(playtime_ingest), + ); + cfg.service( + web::scope("api/v1") + .wrap(default_cors()) + .wrap_fn(|req, _srv| { + async { + Ok(req.into_response( + HttpResponse::Gone() + .content_type("application/json") + .body(r#"{"error":"api_deprecated","description":"You are using an application that uses an outdated version of Modrinth's API. Please either update it or switch to another application. For developers: https://docs.modrinth.com/docs/migrations/v1-to-v2/"}"#) + )) + }.boxed_local() + }) + ); + cfg.service( + web::scope("") + .wrap(default_cors()) + .service(index::index_get) + .service(Files::new("/", "assets/")), + ); +} + +#[derive(thiserror::Error, Debug)] +pub enum ApiError { + #[error("Environment Error")] + Env(#[from] dotenvy::Error), + #[error("Error while uploading file: {0}")] + FileHosting(#[from] FileHostingError), + #[error("Database Error: {0}")] + Database(#[from] crate::database::models::DatabaseError), + #[error("Database Error: {0}")] + SqlxDatabase(#[from] sqlx::Error), + #[error("Clickhouse Error: {0}")] + Clickhouse(#[from] clickhouse::error::Error), + #[error("Internal server error: {0}")] + Xml(String), + #[error("Deserialization error: {0}")] + Json(#[from] serde_json::Error), + #[error("Authentication Error: {0}")] + Authentication(#[from] crate::auth::AuthenticationError), + #[error("Authentication Error: {0}")] + CustomAuthentication(String), + #[error("Invalid Input: {0}")] + InvalidInput(String), + #[error("Error while validating input: {0}")] + Validation(String), + #[error("Search Error: {0}")] + Search(#[from] meilisearch_sdk::errors::Error), + #[error("Indexing Error: {0}")] + Indexing(#[from] crate::search::indexing::IndexingError), + #[error("Payments Error: {0}")] + Payments(String), + #[error("Discord Error: {0}")] + Discord(String), + #[error("Captcha Error. Try resubmitting the form.")] + Turnstile, + #[error("Error while decoding Base62: {0}")] + Decoding(#[from] crate::models::ids::DecodingError), + #[error("Image Parsing Error: {0}")] + ImageParse(#[from] image::ImageError), + #[error("Password Hashing Error: {0}")] + PasswordHashing(#[from] argon2::password_hash::Error), + #[error("Password strength checking error: {0}")] + PasswordStrengthCheck(#[from] zxcvbn::ZxcvbnError), + #[error("{0}")] + Mail(#[from] crate::auth::email::MailError), + #[error("Error while rerouting request: {0}")] + Reroute(#[from] reqwest::Error), + #[error("Unable to read Zip Archive: {0}")] + Zip(#[from] zip::result::ZipError), + #[error("IO Error: {0}")] + Io(#[from] std::io::Error), + #[error("Resource not found")] + NotFound, + #[error("You are being rate-limited. Please wait {0} milliseconds. 0/{1} remaining.")] + RateLimitError(u128, u32), + #[error("Error while interacting with payment processor: {0}")] + Stripe(#[from] stripe::StripeError), +} + +impl ApiError { + pub fn as_api_error<'a>(&self) -> crate::models::error::ApiError<'a> { + crate::models::error::ApiError { + error: match self { + ApiError::Env(..) => "environment_error", + ApiError::SqlxDatabase(..) => "database_error", + ApiError::Database(..) => "database_error", + ApiError::Authentication(..) => "unauthorized", + ApiError::CustomAuthentication(..) => "unauthorized", + ApiError::Xml(..) => "xml_error", + ApiError::Json(..) => "json_error", + ApiError::Search(..) => "search_error", + ApiError::Indexing(..) => "indexing_error", + ApiError::FileHosting(..) => "file_hosting_error", + ApiError::InvalidInput(..) => "invalid_input", + ApiError::Validation(..) => "invalid_input", + ApiError::Payments(..) => "payments_error", + ApiError::Discord(..) => "discord_error", + ApiError::Turnstile => "turnstile_error", + ApiError::Decoding(..) => "decoding_error", + ApiError::ImageParse(..) => "invalid_image", + ApiError::PasswordHashing(..) => "password_hashing_error", + ApiError::PasswordStrengthCheck(..) => "strength_check_error", + ApiError::Mail(..) => "mail_error", + ApiError::Clickhouse(..) => "clickhouse_error", + ApiError::Reroute(..) => "reroute_error", + ApiError::NotFound => "not_found", + ApiError::Zip(..) => "zip_error", + ApiError::Io(..) => "io_error", + ApiError::RateLimitError(..) => "ratelimit_error", + ApiError::Stripe(..) => "stripe_error", + }, + description: self.to_string(), + } + } +} + +impl actix_web::ResponseError for ApiError { + fn status_code(&self) -> StatusCode { + match self { + ApiError::Env(..) => StatusCode::INTERNAL_SERVER_ERROR, + ApiError::Database(..) => StatusCode::INTERNAL_SERVER_ERROR, + ApiError::SqlxDatabase(..) => StatusCode::INTERNAL_SERVER_ERROR, + ApiError::Clickhouse(..) => StatusCode::INTERNAL_SERVER_ERROR, + ApiError::Authentication(..) => StatusCode::UNAUTHORIZED, + ApiError::CustomAuthentication(..) => StatusCode::UNAUTHORIZED, + ApiError::Xml(..) => StatusCode::INTERNAL_SERVER_ERROR, + ApiError::Json(..) => StatusCode::BAD_REQUEST, + ApiError::Search(..) => StatusCode::INTERNAL_SERVER_ERROR, + ApiError::Indexing(..) => StatusCode::INTERNAL_SERVER_ERROR, + ApiError::FileHosting(..) => StatusCode::INTERNAL_SERVER_ERROR, + ApiError::InvalidInput(..) => StatusCode::BAD_REQUEST, + ApiError::Validation(..) => StatusCode::BAD_REQUEST, + ApiError::Payments(..) => StatusCode::FAILED_DEPENDENCY, + ApiError::Discord(..) => StatusCode::FAILED_DEPENDENCY, + ApiError::Turnstile => StatusCode::BAD_REQUEST, + ApiError::Decoding(..) => StatusCode::BAD_REQUEST, + ApiError::ImageParse(..) => StatusCode::BAD_REQUEST, + ApiError::PasswordHashing(..) => StatusCode::INTERNAL_SERVER_ERROR, + ApiError::PasswordStrengthCheck(..) => StatusCode::BAD_REQUEST, + ApiError::Mail(..) => StatusCode::INTERNAL_SERVER_ERROR, + ApiError::Reroute(..) => StatusCode::INTERNAL_SERVER_ERROR, + ApiError::NotFound => StatusCode::NOT_FOUND, + ApiError::Zip(..) => StatusCode::BAD_REQUEST, + ApiError::Io(..) => StatusCode::BAD_REQUEST, + ApiError::RateLimitError(..) => StatusCode::TOO_MANY_REQUESTS, + ApiError::Stripe(..) => StatusCode::FAILED_DEPENDENCY, + } + } + + fn error_response(&self) -> HttpResponse { + HttpResponse::build(self.status_code()).json(self.as_api_error()) + } +} diff --git a/apps/labrinth/src/routes/not_found.rs b/apps/labrinth/src/routes/not_found.rs new file mode 100644 index 000000000..2da930bd7 --- /dev/null +++ b/apps/labrinth/src/routes/not_found.rs @@ -0,0 +1,11 @@ +use crate::models::error::ApiError; +use actix_web::{HttpResponse, Responder}; + +pub async fn not_found() -> impl Responder { + let data = ApiError { + error: "not_found", + description: "the requested route does not exist".to_string(), + }; + + HttpResponse::NotFound().json(data) +} diff --git a/apps/labrinth/src/routes/updates.rs b/apps/labrinth/src/routes/updates.rs new file mode 100644 index 000000000..64ee7b218 --- /dev/null +++ b/apps/labrinth/src/routes/updates.rs @@ -0,0 +1,133 @@ +use std::collections::HashMap; + +use actix_web::{get, web, HttpRequest, HttpResponse}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; + +use crate::auth::checks::{filter_visible_versions, is_visible_project}; +use crate::auth::get_user_from_headers; +use crate::database; +use crate::database::models::legacy_loader_fields::MinecraftGameVersion; +use crate::database::redis::RedisPool; +use crate::models::pats::Scopes; +use crate::models::projects::VersionType; +use crate::queue::session::AuthQueue; + +use super::ApiError; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(forge_updates); +} + +#[derive(Serialize, Deserialize)] +pub struct NeoForge { + #[serde(default = "default_neoforge")] + pub neoforge: String, +} + +fn default_neoforge() -> String { + "none".into() +} + +#[get("{id}/forge_updates.json")] +pub async fn forge_updates( + req: HttpRequest, + web::Query(neo): web::Query, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + const ERROR: &str = "The specified project does not exist!"; + + let (id,) = info.into_inner(); + + let project = database::models::Project::get(&id, &**pool, &redis) + .await? + .ok_or_else(|| ApiError::InvalidInput(ERROR.to_string()))?; + + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + if !is_visible_project(&project.inner, &user_option, &pool, false).await? { + return Err(ApiError::InvalidInput(ERROR.to_string())); + } + + let versions = + database::models::Version::get_many(&project.versions, &**pool, &redis) + .await?; + + let loaders = match &*neo.neoforge { + "only" => |x: &String| *x == "neoforge", + "include" => |x: &String| *x == "forge" || *x == "neoforge", + _ => |x: &String| *x == "forge", + }; + + let mut versions = filter_visible_versions( + versions + .into_iter() + .filter(|x| x.loaders.iter().any(loaders)) + .collect(), + &user_option, + &pool, + &redis, + ) + .await?; + + versions.sort_by(|a, b| b.date_published.cmp(&a.date_published)); + + #[derive(Serialize)] + struct ForgeUpdates { + homepage: String, + promos: HashMap, + } + + let mut response = ForgeUpdates { + homepage: format!( + "{}/mod/{}", + dotenvy::var("SITE_URL").unwrap_or_default(), + id + ), + promos: HashMap::new(), + }; + + for version in versions { + // For forge in particular, we will hardcode it to use GameVersions rather than generic loader fields, as this is minecraft-java exclusive + // Will have duplicates between game_versions (for non-forge loaders), but that's okay as + // before v3 this was stored to the project and not the version + let game_versions: Vec = version + .fields + .iter() + .find(|(key, _)| key.as_str() == MinecraftGameVersion::FIELD_NAME) + .and_then(|(_, value)| { + serde_json::from_value::>(value.clone()).ok() + }) + .unwrap_or_default(); + + if version.version_type == VersionType::Release { + for game_version in &game_versions { + response + .promos + .entry(format!("{}-recommended", game_version)) + .or_insert_with(|| version.version_number.clone()); + } + } + + for game_version in &game_versions { + response + .promos + .entry(format!("{}-latest", game_version)) + .or_insert_with(|| version.version_number.clone()); + } + } + + Ok(HttpResponse::Ok().json(response)) +} diff --git a/apps/labrinth/src/routes/v2/mod.rs b/apps/labrinth/src/routes/v2/mod.rs new file mode 100644 index 000000000..13f823a6a --- /dev/null +++ b/apps/labrinth/src/routes/v2/mod.rs @@ -0,0 +1,40 @@ +mod moderation; +mod notifications; +pub(crate) mod project_creation; +mod projects; +mod reports; +mod statistics; +pub mod tags; +mod teams; +mod threads; +mod users; +mod version_creation; +pub mod version_file; +mod versions; + +pub use super::ApiError; +use crate::util::cors::default_cors; + +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service( + actix_web::web::scope("v2") + .wrap(default_cors()) + .configure(super::internal::admin::config) + // Todo: separate these- they need to also follow v2-v3 conversion + .configure(super::internal::session::config) + .configure(super::internal::flows::config) + .configure(super::internal::pats::config) + .configure(moderation::config) + .configure(notifications::config) + .configure(project_creation::config) + .configure(projects::config) + .configure(reports::config) + .configure(statistics::config) + .configure(tags::config) + .configure(teams::config) + .configure(threads::config) + .configure(users::config) + .configure(version_file::config) + .configure(versions::config), + ); +} diff --git a/apps/labrinth/src/routes/v2/moderation.rs b/apps/labrinth/src/routes/v2/moderation.rs new file mode 100644 index 000000000..e961da24a --- /dev/null +++ b/apps/labrinth/src/routes/v2/moderation.rs @@ -0,0 +1,52 @@ +use super::ApiError; +use crate::models::projects::Project; +use crate::models::v2::projects::LegacyProject; +use crate::queue::session::AuthQueue; +use crate::routes::internal; +use crate::{database::redis::RedisPool, routes::v2_reroute}; +use actix_web::{get, web, HttpRequest, HttpResponse}; +use serde::Deserialize; +use sqlx::PgPool; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(web::scope("moderation").service(get_projects)); +} + +#[derive(Deserialize)] +pub struct ResultCount { + #[serde(default = "default_count")] + pub count: i16, +} + +fn default_count() -> i16 { + 100 +} + +#[get("projects")] +pub async fn get_projects( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + count: web::Query, + session_queue: web::Data, +) -> Result { + let response = internal::moderation::get_projects( + req, + pool.clone(), + redis.clone(), + web::Query(internal::moderation::ResultCount { count: count.count }), + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error)?; + + // Convert to V2 projects + match v2_reroute::extract_ok_json::>(response).await { + Ok(project) => { + let legacy_projects = + LegacyProject::from_many(project, &**pool, &redis).await?; + Ok(HttpResponse::Ok().json(legacy_projects)) + } + Err(response) => Ok(response), + } +} diff --git a/apps/labrinth/src/routes/v2/notifications.rs b/apps/labrinth/src/routes/v2/notifications.rs new file mode 100644 index 000000000..85810d614 --- /dev/null +++ b/apps/labrinth/src/routes/v2/notifications.rs @@ -0,0 +1,158 @@ +use crate::database::redis::RedisPool; +use crate::models::ids::NotificationId; +use crate::models::notifications::Notification; +use crate::models::v2::notifications::LegacyNotification; +use crate::queue::session::AuthQueue; +use crate::routes::v2_reroute; +use crate::routes::v3; +use crate::routes::ApiError; +use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(notifications_get); + cfg.service(notifications_delete); + cfg.service(notifications_read); + + cfg.service( + web::scope("notification") + .service(notification_get) + .service(notification_read) + .service(notification_delete), + ); +} + +#[derive(Serialize, Deserialize)] +pub struct NotificationIds { + pub ids: String, +} + +#[get("notifications")] +pub async fn notifications_get( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let resp = v3::notifications::notifications_get( + req, + web::Query(v3::notifications::NotificationIds { ids: ids.ids }), + pool, + redis, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error); + match v2_reroute::extract_ok_json::>(resp?).await { + Ok(notifications) => { + let notifications: Vec = notifications + .into_iter() + .map(LegacyNotification::from) + .collect(); + Ok(HttpResponse::Ok().json(notifications)) + } + Err(response) => Ok(response), + } +} + +#[get("{id}")] +pub async fn notification_get( + req: HttpRequest, + info: web::Path<(NotificationId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let response = v3::notifications::notification_get( + req, + info, + pool, + redis, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error)?; + match v2_reroute::extract_ok_json::(response).await { + Ok(notification) => { + let notification = LegacyNotification::from(notification); + Ok(HttpResponse::Ok().json(notification)) + } + Err(response) => Ok(response), + } +} + +#[patch("{id}")] +pub async fn notification_read( + req: HttpRequest, + info: web::Path<(NotificationId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + // Returns NoContent, so no need to convert + v3::notifications::notification_read(req, info, pool, redis, session_queue) + .await + .or_else(v2_reroute::flatten_404_error) +} + +#[delete("{id}")] +pub async fn notification_delete( + req: HttpRequest, + info: web::Path<(NotificationId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + // Returns NoContent, so no need to convert + v3::notifications::notification_delete( + req, + info, + pool, + redis, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error) +} + +#[patch("notifications")] +pub async fn notifications_read( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + // Returns NoContent, so no need to convert + v3::notifications::notifications_read( + req, + web::Query(v3::notifications::NotificationIds { ids: ids.ids }), + pool, + redis, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error) +} + +#[delete("notifications")] +pub async fn notifications_delete( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + // Returns NoContent, so no need to convert + v3::notifications::notifications_delete( + req, + web::Query(v3::notifications::NotificationIds { ids: ids.ids }), + pool, + redis, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error) +} diff --git a/apps/labrinth/src/routes/v2/project_creation.rs b/apps/labrinth/src/routes/v2/project_creation.rs new file mode 100644 index 000000000..decc71a3d --- /dev/null +++ b/apps/labrinth/src/routes/v2/project_creation.rs @@ -0,0 +1,273 @@ +use crate::database::models::version_item; +use crate::database::redis::RedisPool; +use crate::file_hosting::FileHost; +use crate::models; +use crate::models::ids::ImageId; +use crate::models::projects::{Loader, Project, ProjectStatus}; +use crate::models::v2::projects::{ + DonationLink, LegacyProject, LegacySideType, +}; +use crate::queue::session::AuthQueue; +use crate::routes::v3::project_creation::default_project_type; +use crate::routes::v3::project_creation::{CreateError, NewGalleryItem}; +use crate::routes::{v2_reroute, v3}; +use actix_multipart::Multipart; +use actix_web::web::Data; +use actix_web::{post, HttpRequest, HttpResponse}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sqlx::postgres::PgPool; + +use std::collections::HashMap; +use std::sync::Arc; +use validator::Validate; + +use super::version_creation::InitialVersionData; + +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.service(project_create); +} + +pub fn default_requested_status() -> ProjectStatus { + ProjectStatus::Approved +} + +#[derive(Serialize, Deserialize, Validate, Clone)] +struct ProjectCreateData { + #[validate( + length(min = 3, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + #[serde(alias = "mod_name")] + /// The title or name of the project. + pub title: String, + #[validate(length(min = 1, max = 64))] + #[serde(default = "default_project_type")] + /// The project type of this mod + pub project_type: String, + #[validate( + length(min = 3, max = 64), + regex = "crate::util::validate::RE_URL_SAFE" + )] + #[serde(alias = "mod_slug")] + /// The slug of a project, used for vanity URLs + pub slug: String, + #[validate(length(min = 3, max = 255))] + #[serde(alias = "mod_description")] + /// A short description of the project. + pub description: String, + #[validate(length(max = 65536))] + #[serde(alias = "mod_body")] + /// A long description of the project, in markdown. + pub body: String, + + /// The support range for the client project + pub client_side: LegacySideType, + /// The support range for the server project + pub server_side: LegacySideType, + + #[validate(length(max = 32))] + #[validate] + /// A list of initial versions to upload with the created project + pub initial_versions: Vec, + #[validate(length(max = 3))] + /// A list of the categories that the project is in. + pub categories: Vec, + #[validate(length(max = 256))] + #[serde(default = "Vec::new")] + /// A list of the categories that the project is in. + pub additional_categories: Vec, + + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + /// An optional link to where to submit bugs or issues with the project. + pub issues_url: Option, + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + /// An optional link to the source code for the project. + pub source_url: Option, + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + /// An optional link to the project's wiki page or other relevant information. + pub wiki_url: Option, + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + /// An optional link to the project's license page + pub license_url: Option, + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + /// An optional link to the project's discord. + pub discord_url: Option, + /// An optional list of all donation links the project has\ + #[validate] + pub donation_urls: Option>, + + /// An optional boolean. If true, the project will be created as a draft. + pub is_draft: Option, + + /// The license id that the project follows + pub license_id: String, + + #[validate(length(max = 64))] + #[validate] + /// The multipart names of the gallery items to upload + pub gallery_items: Option>, + #[serde(default = "default_requested_status")] + /// The status of the mod to be set once it is approved + pub requested_status: ProjectStatus, + + // Associations to uploaded images in body/description + #[validate(length(max = 10))] + #[serde(default)] + pub uploaded_images: Vec, + + /// The id of the organization to create the project in + pub organization_id: Option, +} + +#[post("project")] +pub async fn project_create( + req: HttpRequest, + payload: Multipart, + client: Data, + redis: Data, + file_host: Data>, + session_queue: Data, +) -> Result { + // Convert V2 multipart payload to V3 multipart payload + let payload = v2_reroute::alter_actix_multipart( + payload, + req.headers().clone(), + |legacy_create: ProjectCreateData, _| async move { + // Side types will be applied to each version + let client_side = legacy_create.client_side; + let server_side = legacy_create.server_side; + + let project_type = legacy_create.project_type; + + let initial_versions = legacy_create + .initial_versions + .into_iter() + .map(|v| { + let mut fields = HashMap::new(); + fields.extend(v2_reroute::convert_side_types_v3( + client_side, + server_side, + )); + fields.insert( + "game_versions".to_string(), + json!(v.game_versions), + ); + + // Modpacks now use the "mrpack" loader, and loaders are converted to loader fields. + // Setting of 'project_type' directly is removed, it's loader-based now. + if project_type == "modpack" { + fields.insert( + "mrpack_loaders".to_string(), + json!(v.loaders), + ); + } + + let loaders = if project_type == "modpack" { + vec![Loader("mrpack".to_string())] + } else { + v.loaders + }; + + v3::version_creation::InitialVersionData { + project_id: v.project_id, + file_parts: v.file_parts, + version_number: v.version_number, + version_title: v.version_title, + version_body: v.version_body, + dependencies: v.dependencies, + release_channel: v.release_channel, + loaders, + featured: v.featured, + primary_file: v.primary_file, + status: v.status, + file_types: v.file_types, + uploaded_images: v.uploaded_images, + ordering: v.ordering, + fields, + } + }) + .collect(); + + let mut link_urls = HashMap::new(); + if let Some(issue_url) = legacy_create.issues_url { + link_urls.insert("issues".to_string(), issue_url); + } + if let Some(source_url) = legacy_create.source_url { + link_urls.insert("source".to_string(), source_url); + } + if let Some(wiki_url) = legacy_create.wiki_url { + link_urls.insert("wiki".to_string(), wiki_url); + } + if let Some(discord_url) = legacy_create.discord_url { + link_urls.insert("discord".to_string(), discord_url); + } + if let Some(donation_urls) = legacy_create.donation_urls { + for donation_url in donation_urls { + link_urls.insert(donation_url.platform, donation_url.url); + } + } + + Ok(v3::project_creation::ProjectCreateData { + name: legacy_create.title, + slug: legacy_create.slug, + summary: legacy_create.description, // Description becomes summary + description: legacy_create.body, // Body becomes description + initial_versions, + categories: legacy_create.categories, + additional_categories: legacy_create.additional_categories, + license_url: legacy_create.license_url, + link_urls, + is_draft: legacy_create.is_draft, + license_id: legacy_create.license_id, + gallery_items: legacy_create.gallery_items, + requested_status: legacy_create.requested_status, + uploaded_images: legacy_create.uploaded_images, + organization_id: legacy_create.organization_id, + }) + }, + ) + .await?; + + // Call V3 project creation + let response = v3::project_creation::project_create( + req, + payload, + client.clone(), + redis.clone(), + file_host, + session_queue, + ) + .await?; + + // Convert response to V2 format + match v2_reroute::extract_ok_json::(response).await { + Ok(project) => { + let version_item = match project.versions.first() { + Some(vid) => { + version_item::Version::get((*vid).into(), &**client, &redis) + .await? + } + None => None, + }; + let project = LegacyProject::from(project, version_item); + Ok(HttpResponse::Ok().json(project)) + } + Err(response) => Ok(response), + } +} diff --git a/apps/labrinth/src/routes/v2/projects.rs b/apps/labrinth/src/routes/v2/projects.rs new file mode 100644 index 000000000..3ee33336b --- /dev/null +++ b/apps/labrinth/src/routes/v2/projects.rs @@ -0,0 +1,965 @@ +use crate::database::models::categories::LinkPlatform; +use crate::database::models::{project_item, version_item}; +use crate::database::redis::RedisPool; +use crate::file_hosting::FileHost; +use crate::models::projects::{ + Link, MonetizationStatus, Project, ProjectStatus, SearchRequest, Version, +}; +use crate::models::v2::projects::{ + DonationLink, LegacyProject, LegacySideType, LegacyVersion, +}; +use crate::models::v2::search::LegacySearchResults; +use crate::queue::moderation::AutomatedModerationQueue; +use crate::queue::session::AuthQueue; +use crate::routes::v3::projects::ProjectIds; +use crate::routes::{v2_reroute, v3, ApiError}; +use crate::search::{search_for_project, SearchConfig, SearchError}; +use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use std::collections::HashMap; +use std::sync::Arc; +use validator::Validate; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(project_search); + cfg.service(projects_get); + cfg.service(projects_edit); + cfg.service(random_projects_get); + + cfg.service( + web::scope("project") + .service(project_get) + .service(project_get_check) + .service(project_delete) + .service(project_edit) + .service(project_icon_edit) + .service(delete_project_icon) + .service(add_gallery_item) + .service(edit_gallery_item) + .service(delete_gallery_item) + .service(project_follow) + .service(project_unfollow) + .service(super::teams::team_members_get_project) + .service( + web::scope("{project_id}") + .service(super::versions::version_list) + .service(super::versions::version_project_get) + .service(dependency_list), + ), + ); +} + +#[get("search")] +pub async fn project_search( + web::Query(info): web::Query, + config: web::Data, +) -> Result { + // Search now uses loader_fields instead of explicit 'client_side' and 'server_side' fields + // While the backend for this has changed, it doesnt affect much + // in the API calls except that 'versions:x' is now 'game_versions:x' + let facets: Option>> = if let Some(facets) = info.facets { + let facets = serde_json::from_str::>>(&facets)?; + + // These loaders specifically used to be combined with 'mod' to be a plugin, but now + // they are their own loader type. We will convert 'mod' to 'mod' OR 'plugin' + // as it essentially was before. + let facets = v2_reroute::convert_plugin_loader_facets_v3(facets); + + Some( + facets + .into_iter() + .map(|facet| { + facet + .into_iter() + .map(|facet| { + if let Some((key, operator, val)) = + parse_facet(&facet) + { + format!( + "{}{}{}", + match key.as_str() { + "versions" => "game_versions", + "project_type" => "project_types", + "title" => "name", + x => x, + }, + operator, + val + ) + } else { + facet.to_string() + } + }) + .collect::>() + }) + .collect(), + ) + } else { + None + }; + + let info = SearchRequest { + facets: facets.and_then(|x| serde_json::to_string(&x).ok()), + ..info + }; + + let results = search_for_project(&info, &config).await?; + + let results = LegacySearchResults::from(results); + + Ok(HttpResponse::Ok().json(results)) +} + +/// Parses a facet into a key, operator, and value +fn parse_facet(facet: &str) -> Option<(String, String, String)> { + let mut key = String::new(); + let mut operator = String::new(); + let mut val = String::new(); + + let mut iterator = facet.chars(); + while let Some(char) = iterator.next() { + match char { + ':' | '=' => { + operator.push(char); + val = iterator.collect::(); + return Some((key, operator, val)); + } + '<' | '>' => { + operator.push(char); + if let Some(next_char) = iterator.next() { + if next_char == '=' { + operator.push(next_char); + } else { + val.push(next_char); + } + } + val.push_str(&iterator.collect::()); + return Some((key, operator, val)); + } + ' ' => continue, + _ => key.push(char), + } + } + + None +} + +#[derive(Deserialize, Validate)] +pub struct RandomProjects { + #[validate(range(min = 1, max = 100))] + pub count: u32, +} + +#[get("projects_random")] +pub async fn random_projects_get( + web::Query(count): web::Query, + pool: web::Data, + redis: web::Data, +) -> Result { + let count = v3::projects::RandomProjects { count: count.count }; + + let response = v3::projects::random_projects_get( + web::Query(count), + pool.clone(), + redis.clone(), + ) + .await + .or_else(v2_reroute::flatten_404_error) + .or_else(v2_reroute::flatten_404_error)?; + // Convert response to V2 format + match v2_reroute::extract_ok_json::>(response).await { + Ok(project) => { + let legacy_projects = + LegacyProject::from_many(project, &**pool, &redis).await?; + Ok(HttpResponse::Ok().json(legacy_projects)) + } + Err(response) => Ok(response), + } +} + +#[get("projects")] +pub async fn projects_get( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + // Call V3 project creation + let response = v3::projects::projects_get( + req, + web::Query(ids), + pool.clone(), + redis.clone(), + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error) + .or_else(v2_reroute::flatten_404_error)?; + + // Convert response to V2 format + match v2_reroute::extract_ok_json::>(response).await { + Ok(project) => { + let legacy_projects = + LegacyProject::from_many(project, &**pool, &redis).await?; + Ok(HttpResponse::Ok().json(legacy_projects)) + } + Err(response) => Ok(response), + } +} + +#[get("{id}")] +pub async fn project_get( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + // Convert V2 data to V3 data + // Call V3 project creation + let response = v3::projects::project_get( + req, + info, + pool.clone(), + redis.clone(), + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error)?; + + // Convert response to V2 format + match v2_reroute::extract_ok_json::(response).await { + Ok(project) => { + let version_item = match project.versions.first() { + Some(vid) => { + version_item::Version::get((*vid).into(), &**pool, &redis) + .await? + } + None => None, + }; + let project = LegacyProject::from(project, version_item); + Ok(HttpResponse::Ok().json(project)) + } + Err(response) => Ok(response), + } +} + +//checks the validity of a project id or slug +#[get("{id}/check")] +pub async fn project_get_check( + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, +) -> Result { + // Returns an id only, do not need to convert + v3::projects::project_get_check(info, pool, redis) + .await + .or_else(v2_reroute::flatten_404_error) +} + +#[derive(Serialize)] +struct DependencyInfo { + pub projects: Vec, + pub versions: Vec, +} + +#[get("dependencies")] +pub async fn dependency_list( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + // TODO: tests, probably + let response = v3::projects::dependency_list( + req, + info, + pool.clone(), + redis.clone(), + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error)?; + + match v2_reroute::extract_ok_json::< + crate::routes::v3::projects::DependencyInfo, + >(response) + .await + { + Ok(dependency_info) => { + let converted_projects = LegacyProject::from_many( + dependency_info.projects, + &**pool, + &redis, + ) + .await?; + let converted_versions = dependency_info + .versions + .into_iter() + .map(LegacyVersion::from) + .collect(); + + Ok(HttpResponse::Ok().json(DependencyInfo { + projects: converted_projects, + versions: converted_versions, + })) + } + Err(response) => Ok(response), + } +} + +#[derive(Serialize, Deserialize, Validate)] +pub struct EditProject { + #[validate( + length(min = 3, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + pub title: Option, + #[validate(length(min = 3, max = 256))] + pub description: Option, + #[validate(length(max = 65536))] + pub body: Option, + #[validate(length(max = 3))] + pub categories: Option>, + #[validate(length(max = 256))] + pub additional_categories: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + pub issues_url: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + pub source_url: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + pub wiki_url: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + pub license_url: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + pub discord_url: Option>, + #[validate] + pub donation_urls: Option>, + pub license_id: Option, + pub client_side: Option, + pub server_side: Option, + #[validate( + length(min = 3, max = 64), + regex = "crate::util::validate::RE_URL_SAFE" + )] + pub slug: Option, + pub status: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + pub requested_status: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate(length(max = 2000))] + pub moderation_message: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate(length(max = 65536))] + pub moderation_message_body: Option>, + pub monetization_status: Option, +} + +#[patch("{id}")] +#[allow(clippy::too_many_arguments)] +pub async fn project_edit( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + search_config: web::Data, + new_project: web::Json, + redis: web::Data, + session_queue: web::Data, + moderation_queue: web::Data, +) -> Result { + let v2_new_project = new_project.into_inner(); + let client_side = v2_new_project.client_side; + let server_side = v2_new_project.server_side; + let new_slug = v2_new_project.slug.clone(); + + // TODO: Some kind of handling here to ensure project type is fine. + // We expect the version uploaded to be of loader type modpack, but there might not be a way to check here for that. + // After all, theoretically, they could be creating a genuine 'fabric' mod, and modpack no longer carries information on whether its a mod or modpack, + // as those are out to the versions. + + // Ideally this would, if the project 'should' be a modpack: + // - change the loaders to mrpack only + // - add categories to the project for the corresponding loaders + + let mut new_links = HashMap::new(); + if let Some(issues_url) = v2_new_project.issues_url { + if let Some(issues_url) = issues_url { + new_links.insert("issues".to_string(), Some(issues_url)); + } else { + new_links.insert("issues".to_string(), None); + } + } + + if let Some(source_url) = v2_new_project.source_url { + if let Some(source_url) = source_url { + new_links.insert("source".to_string(), Some(source_url)); + } else { + new_links.insert("source".to_string(), None); + } + } + + if let Some(wiki_url) = v2_new_project.wiki_url { + if let Some(wiki_url) = wiki_url { + new_links.insert("wiki".to_string(), Some(wiki_url)); + } else { + new_links.insert("wiki".to_string(), None); + } + } + + if let Some(discord_url) = v2_new_project.discord_url { + if let Some(discord_url) = discord_url { + new_links.insert("discord".to_string(), Some(discord_url)); + } else { + new_links.insert("discord".to_string(), None); + } + } + + // In v2, setting donation links resets all other donation links + // (resetting to the new ones) + if let Some(donation_urls) = v2_new_project.donation_urls { + // Fetch current donation links from project so we know what to delete + let fetched_example_project = + project_item::Project::get(&info.0, &**pool, &redis).await?; + let donation_links = fetched_example_project + .map(|x| { + x.urls + .into_iter() + .filter_map(|l| { + if l.donation { + Some(Link::from(l)) // TODO: tests + } else { + None + } + }) + .collect::>() + }) + .unwrap_or_default(); + + // Set existing donation links to None + for old_link in donation_links { + new_links.insert(old_link.platform, None); + } + + // Add new donation links + for donation_url in donation_urls { + new_links.insert(donation_url.id, Some(donation_url.url)); + } + } + + let new_project = v3::projects::EditProject { + name: v2_new_project.title, + summary: v2_new_project.description, // Description becomes summary + description: v2_new_project.body, // Body becomes description + categories: v2_new_project.categories, + additional_categories: v2_new_project.additional_categories, + license_url: v2_new_project.license_url, + link_urls: Some(new_links), + license_id: v2_new_project.license_id, + slug: v2_new_project.slug, + status: v2_new_project.status, + requested_status: v2_new_project.requested_status, + moderation_message: v2_new_project.moderation_message, + moderation_message_body: v2_new_project.moderation_message_body, + monetization_status: v2_new_project.monetization_status, + }; + + // This returns 204 or failure so we don't need to do anything with it + let project_id = info.clone().0; + let mut response = v3::projects::project_edit( + req.clone(), + info, + pool.clone(), + search_config, + web::Json(new_project), + redis.clone(), + session_queue.clone(), + moderation_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error)?; + + // If client and server side were set, we will call + // the version setting route for each version to set the side types for each of them. + if response.status().is_success() + && (client_side.is_some() || server_side.is_some()) + { + let project_item = project_item::Project::get( + &new_slug.unwrap_or(project_id), + &**pool, + &redis, + ) + .await?; + let version_ids = project_item.map(|x| x.versions).unwrap_or_default(); + let versions = + version_item::Version::get_many(&version_ids, &**pool, &redis) + .await?; + for version in versions { + let version = Version::from(version); + let mut fields = version.fields; + let (current_client_side, current_server_side) = + v2_reroute::convert_side_types_v2(&fields, None); + let client_side = client_side.unwrap_or(current_client_side); + let server_side = server_side.unwrap_or(current_server_side); + fields.extend(v2_reroute::convert_side_types_v3( + client_side, + server_side, + )); + + response = v3::versions::version_edit_helper( + req.clone(), + (version.id,), + pool.clone(), + redis.clone(), + v3::versions::EditVersion { + fields, + ..Default::default() + }, + session_queue.clone(), + ) + .await?; + } + } + Ok(response) +} + +#[derive(Deserialize, Validate)] +pub struct BulkEditProject { + #[validate(length(max = 3))] + pub categories: Option>, + #[validate(length(max = 3))] + pub add_categories: Option>, + pub remove_categories: Option>, + + #[validate(length(max = 256))] + pub additional_categories: Option>, + #[validate(length(max = 3))] + pub add_additional_categories: Option>, + pub remove_additional_categories: Option>, + + #[validate] + pub donation_urls: Option>, + #[validate] + pub add_donation_urls: Option>, + #[validate] + pub remove_donation_urls: Option>, + + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + pub issues_url: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + pub source_url: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + pub wiki_url: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + pub discord_url: Option>, +} + +#[patch("projects")] +pub async fn projects_edit( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + bulk_edit_project: web::Json, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let bulk_edit_project = bulk_edit_project.into_inner(); + + let mut link_urls = HashMap::new(); + + // If we are *setting* donation links, we will set every possible donation link to None, as + // setting will delete all of them then 're-add' the ones we want to keep + if let Some(donation_url) = bulk_edit_project.donation_urls { + let link_platforms = LinkPlatform::list(&**pool, &redis).await?; + for link in link_platforms { + if link.donation { + link_urls.insert(link.name, None); + } + } + // add + for donation_url in donation_url { + link_urls.insert(donation_url.id, Some(donation_url.url)); + } + } + + // For every delete, we will set the link to None + if let Some(donation_url) = bulk_edit_project.remove_donation_urls { + for donation_url in donation_url { + link_urls.insert(donation_url.id, None); + } + } + + // For every add, we will set the link to the new url + if let Some(donation_url) = bulk_edit_project.add_donation_urls { + for donation_url in donation_url { + link_urls.insert(donation_url.id, Some(donation_url.url)); + } + } + + if let Some(issue_url) = bulk_edit_project.issues_url { + if let Some(issue_url) = issue_url { + link_urls.insert("issues".to_string(), Some(issue_url)); + } else { + link_urls.insert("issues".to_string(), None); + } + } + + if let Some(source_url) = bulk_edit_project.source_url { + if let Some(source_url) = source_url { + link_urls.insert("source".to_string(), Some(source_url)); + } else { + link_urls.insert("source".to_string(), None); + } + } + + if let Some(wiki_url) = bulk_edit_project.wiki_url { + if let Some(wiki_url) = wiki_url { + link_urls.insert("wiki".to_string(), Some(wiki_url)); + } else { + link_urls.insert("wiki".to_string(), None); + } + } + + if let Some(discord_url) = bulk_edit_project.discord_url { + if let Some(discord_url) = discord_url { + link_urls.insert("discord".to_string(), Some(discord_url)); + } else { + link_urls.insert("discord".to_string(), None); + } + } + + // This returns NoContent or failure so we don't need to do anything with it + v3::projects::projects_edit( + req, + web::Query(ids), + pool.clone(), + web::Json(v3::projects::BulkEditProject { + categories: bulk_edit_project.categories, + add_categories: bulk_edit_project.add_categories, + remove_categories: bulk_edit_project.remove_categories, + additional_categories: bulk_edit_project.additional_categories, + add_additional_categories: bulk_edit_project + .add_additional_categories, + remove_additional_categories: bulk_edit_project + .remove_additional_categories, + link_urls: Some(link_urls), + }), + redis, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error) +} + +#[derive(Serialize, Deserialize)] +pub struct Extension { + pub ext: String, +} + +#[patch("{id}/icon")] +#[allow(clippy::too_many_arguments)] +pub async fn project_icon_edit( + web::Query(ext): web::Query, + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + payload: web::Payload, + session_queue: web::Data, +) -> Result { + // Returns NoContent, so no need to convert + v3::projects::project_icon_edit( + web::Query(v3::projects::Extension { ext: ext.ext }), + req, + info, + pool, + redis, + file_host, + payload, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error) +} + +#[delete("{id}/icon")] +pub async fn delete_project_icon( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + session_queue: web::Data, +) -> Result { + // Returns NoContent, so no need to convert + v3::projects::delete_project_icon( + req, + info, + pool, + redis, + file_host, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error) +} + +#[derive(Serialize, Deserialize, Validate)] +pub struct GalleryCreateQuery { + pub featured: bool, + #[validate(length(min = 1, max = 255))] + pub title: Option, + #[validate(length(min = 1, max = 2048))] + pub description: Option, + pub ordering: Option, +} + +#[post("{id}/gallery")] +#[allow(clippy::too_many_arguments)] +pub async fn add_gallery_item( + web::Query(ext): web::Query, + req: HttpRequest, + web::Query(item): web::Query, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + payload: web::Payload, + session_queue: web::Data, +) -> Result { + // Returns NoContent, so no need to convert + v3::projects::add_gallery_item( + web::Query(v3::projects::Extension { ext: ext.ext }), + req, + web::Query(v3::projects::GalleryCreateQuery { + featured: item.featured, + name: item.title, + description: item.description, + ordering: item.ordering, + }), + info, + pool, + redis, + file_host, + payload, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error) +} + +#[derive(Serialize, Deserialize, Validate)] +pub struct GalleryEditQuery { + /// The url of the gallery item to edit + pub url: String, + pub featured: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate(length(min = 1, max = 255))] + pub title: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate(length(min = 1, max = 2048))] + pub description: Option>, + pub ordering: Option, +} + +#[patch("{id}/gallery")] +pub async fn edit_gallery_item( + req: HttpRequest, + web::Query(item): web::Query, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + // Returns NoContent, so no need to convert + v3::projects::edit_gallery_item( + req, + web::Query(v3::projects::GalleryEditQuery { + url: item.url, + featured: item.featured, + name: item.title, + description: item.description, + ordering: item.ordering, + }), + info, + pool, + redis, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error) +} + +#[derive(Serialize, Deserialize)] +pub struct GalleryDeleteQuery { + pub url: String, +} + +#[delete("{id}/gallery")] +pub async fn delete_gallery_item( + req: HttpRequest, + web::Query(item): web::Query, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + session_queue: web::Data, +) -> Result { + // Returns NoContent, so no need to convert + v3::projects::delete_gallery_item( + req, + web::Query(v3::projects::GalleryDeleteQuery { url: item.url }), + info, + pool, + redis, + file_host, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error) +} + +#[delete("{id}")] +pub async fn project_delete( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + search_config: web::Data, + session_queue: web::Data, +) -> Result { + // Returns NoContent, so no need to convert + v3::projects::project_delete( + req, + info, + pool, + redis, + search_config, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error) +} + +#[post("{id}/follow")] +pub async fn project_follow( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + // Returns NoContent, so no need to convert + v3::projects::project_follow(req, info, pool, redis, session_queue) + .await + .or_else(v2_reroute::flatten_404_error) +} + +#[delete("{id}/follow")] +pub async fn project_unfollow( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + // Returns NoContent, so no need to convert + v3::projects::project_unfollow(req, info, pool, redis, session_queue) + .await + .or_else(v2_reroute::flatten_404_error) +} diff --git a/apps/labrinth/src/routes/v2/reports.rs b/apps/labrinth/src/routes/v2/reports.rs new file mode 100644 index 000000000..35173102d --- /dev/null +++ b/apps/labrinth/src/routes/v2/reports.rs @@ -0,0 +1,192 @@ +use crate::database::redis::RedisPool; +use crate::models::reports::Report; +use crate::models::v2::reports::LegacyReport; +use crate::queue::session::AuthQueue; +use crate::routes::{v2_reroute, v3, ApiError}; +use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; +use serde::Deserialize; +use sqlx::PgPool; +use validator::Validate; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(reports_get); + cfg.service(reports); + cfg.service(report_create); + cfg.service(report_edit); + cfg.service(report_delete); + cfg.service(report_get); +} + +#[post("report")] +pub async fn report_create( + req: HttpRequest, + pool: web::Data, + body: web::Payload, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let response = + v3::reports::report_create(req, pool, body, redis, session_queue) + .await + .or_else(v2_reroute::flatten_404_error)?; + + // Convert response to V2 format + match v2_reroute::extract_ok_json::(response).await { + Ok(report) => { + let report = LegacyReport::from(report); + Ok(HttpResponse::Ok().json(report)) + } + Err(response) => Ok(response), + } +} + +#[derive(Deserialize)] +pub struct ReportsRequestOptions { + #[serde(default = "default_count")] + count: i16, + #[serde(default = "default_all")] + all: bool, +} + +fn default_count() -> i16 { + 100 +} +fn default_all() -> bool { + true +} + +#[get("report")] +pub async fn reports( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + count: web::Query, + session_queue: web::Data, +) -> Result { + let response = v3::reports::reports( + req, + pool, + redis, + web::Query(v3::reports::ReportsRequestOptions { + count: count.count, + all: count.all, + }), + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error)?; + + // Convert response to V2 format + match v2_reroute::extract_ok_json::>(response).await { + Ok(reports) => { + let reports: Vec<_> = + reports.into_iter().map(LegacyReport::from).collect(); + Ok(HttpResponse::Ok().json(reports)) + } + Err(response) => Ok(response), + } +} + +#[derive(Deserialize)] +pub struct ReportIds { + pub ids: String, +} + +#[get("reports")] +pub async fn reports_get( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let response = v3::reports::reports_get( + req, + web::Query(v3::reports::ReportIds { ids: ids.ids }), + pool, + redis, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error)?; + + // Convert response to V2 format + match v2_reroute::extract_ok_json::>(response).await { + Ok(report_list) => { + let report_list: Vec<_> = + report_list.into_iter().map(LegacyReport::from).collect(); + Ok(HttpResponse::Ok().json(report_list)) + } + Err(response) => Ok(response), + } +} + +#[get("report/{id}")] +pub async fn report_get( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + info: web::Path<(crate::models::reports::ReportId,)>, + session_queue: web::Data, +) -> Result { + let response = + v3::reports::report_get(req, pool, redis, info, session_queue) + .await + .or_else(v2_reroute::flatten_404_error)?; + + // Convert response to V2 format + match v2_reroute::extract_ok_json::(response).await { + Ok(report) => { + let report = LegacyReport::from(report); + Ok(HttpResponse::Ok().json(report)) + } + Err(response) => Ok(response), + } +} + +#[derive(Deserialize, Validate)] +pub struct EditReport { + #[validate(length(max = 65536))] + pub body: Option, + pub closed: Option, +} + +#[patch("report/{id}")] +pub async fn report_edit( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + info: web::Path<(crate::models::reports::ReportId,)>, + session_queue: web::Data, + edit_report: web::Json, +) -> Result { + let edit_report = edit_report.into_inner(); + // Returns NoContent, so no need to convert + v3::reports::report_edit( + req, + pool, + redis, + info, + session_queue, + web::Json(v3::reports::EditReport { + body: edit_report.body, + closed: edit_report.closed, + }), + ) + .await + .or_else(v2_reroute::flatten_404_error) +} + +#[delete("report/{id}")] +pub async fn report_delete( + req: HttpRequest, + pool: web::Data, + info: web::Path<(crate::models::reports::ReportId,)>, + redis: web::Data, + session_queue: web::Data, +) -> Result { + // Returns NoContent, so no need to convert + v3::reports::report_delete(req, pool, info, redis, session_queue) + .await + .or_else(v2_reroute::flatten_404_error) +} diff --git a/apps/labrinth/src/routes/v2/statistics.rs b/apps/labrinth/src/routes/v2/statistics.rs new file mode 100644 index 000000000..bd98da12e --- /dev/null +++ b/apps/labrinth/src/routes/v2/statistics.rs @@ -0,0 +1,41 @@ +use crate::routes::{ + v2_reroute, + v3::{self, statistics::V3Stats}, + ApiError, +}; +use actix_web::{get, web, HttpResponse}; +use sqlx::PgPool; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(get_stats); +} + +#[derive(serde::Serialize)] +pub struct V2Stats { + pub projects: Option, + pub versions: Option, + pub authors: Option, + pub files: Option, +} + +#[get("statistics")] +pub async fn get_stats( + pool: web::Data, +) -> Result { + let response = v3::statistics::get_stats(pool) + .await + .or_else(v2_reroute::flatten_404_error)?; + + match v2_reroute::extract_ok_json::(response).await { + Ok(stats) => { + let stats = V2Stats { + projects: stats.projects, + versions: stats.versions, + authors: stats.authors, + files: stats.files, + }; + Ok(HttpResponse::Ok().json(stats)) + } + Err(response) => Ok(response), + } +} diff --git a/apps/labrinth/src/routes/v2/tags.rs b/apps/labrinth/src/routes/v2/tags.rs new file mode 100644 index 000000000..3233e9aed --- /dev/null +++ b/apps/labrinth/src/routes/v2/tags.rs @@ -0,0 +1,332 @@ +use std::collections::HashMap; + +use super::ApiError; +use crate::database::models::loader_fields::LoaderFieldEnumValue; +use crate::database::redis::RedisPool; +use crate::models::v2::projects::LegacySideType; +use crate::routes::v2_reroute::capitalize_first; +use crate::routes::v3::tags::{LinkPlatformQueryData, LoaderFieldsEnumQuery}; +use crate::routes::{v2_reroute, v3}; +use actix_web::{get, web, HttpResponse}; +use chrono::{DateTime, Utc}; +use itertools::Itertools; +use sqlx::PgPool; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("tag") + .service(category_list) + .service(loader_list) + .service(game_version_list) + .service(license_list) + .service(license_text) + .service(donation_platform_list) + .service(report_type_list) + .service(project_type_list) + .service(side_type_list), + ); +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct CategoryData { + pub icon: String, + pub name: String, + pub project_type: String, + pub header: String, +} + +#[get("category")] +pub async fn category_list( + pool: web::Data, + redis: web::Data, +) -> Result { + let response = v3::tags::category_list(pool, redis).await?; + + // Convert to V2 format + match v2_reroute::extract_ok_json::>(response) + .await + { + Ok(categories) => { + let categories = categories + .into_iter() + .map(|c| CategoryData { + icon: c.icon, + name: c.name, + project_type: c.project_type, + header: c.header, + }) + .collect::>(); + Ok(HttpResponse::Ok().json(categories)) + } + Err(response) => Ok(response), + } +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct LoaderData { + pub icon: String, + pub name: String, + pub supported_project_types: Vec, +} + +#[get("loader")] +pub async fn loader_list( + pool: web::Data, + redis: web::Data, +) -> Result { + let response = v3::tags::loader_list(pool, redis).await?; + + // Convert to V2 format + match v2_reroute::extract_ok_json::>(response) + .await + { + Ok(loaders) => { + let loaders = loaders + .into_iter() + .filter(|l| &*l.name != "mrpack") + .map(|l| { + let mut supported_project_types = l.supported_project_types; + // Add generic 'project' type to all loaders, which is the v2 representation of + // a project type before any versions are set. + supported_project_types.push("project".to_string()); + + if ["forge", "fabric", "quilt", "neoforge"] + .contains(&&*l.name) + { + supported_project_types.push("modpack".to_string()); + } + + if supported_project_types.contains(&"datapack".to_string()) + || supported_project_types + .contains(&"plugin".to_string()) + { + supported_project_types.push("mod".to_string()); + } + + LoaderData { + icon: l.icon, + name: l.name, + supported_project_types, + } + }) + .collect::>(); + Ok(HttpResponse::Ok().json(loaders)) + } + Err(response) => Ok(response), + } +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct GameVersionQueryData { + pub version: String, + pub version_type: String, + pub date: DateTime, + pub major: bool, +} + +#[derive(serde::Deserialize)] +pub struct GameVersionQuery { + #[serde(rename = "type")] + type_: Option, + major: Option, +} + +#[get("game_version")] +pub async fn game_version_list( + pool: web::Data, + query: web::Query, + redis: web::Data, +) -> Result { + let mut filters = HashMap::new(); + if let Some(type_) = &query.type_ { + filters.insert("type".to_string(), serde_json::json!(type_)); + } + if let Some(major) = query.major { + filters.insert("major".to_string(), serde_json::json!(major)); + } + let response = v3::tags::loader_fields_list( + pool, + web::Query(LoaderFieldsEnumQuery { + loader_field: "game_versions".to_string(), + filters: Some(filters), + }), + redis, + ) + .await?; + + // Convert to V2 format + Ok( + match v2_reroute::extract_ok_json::>(response) + .await + { + Ok(fields) => { + let fields = fields + .into_iter() + .map(|f| GameVersionQueryData { + version: f.value, + version_type: f + .metadata + .get("type") + .and_then(|m| m.as_str()) + .unwrap_or_default() + .to_string(), + date: f.created, + major: f + .metadata + .get("major") + .and_then(|m| m.as_bool()) + .unwrap_or_default(), + }) + .collect::>(); + HttpResponse::Ok().json(fields) + } + Err(response) => response, + }, + ) +} + +#[derive(serde::Serialize)] +pub struct License { + pub short: String, + pub name: String, +} + +#[get("license")] +pub async fn license_list() -> HttpResponse { + let response = v3::tags::license_list().await; + + // Convert to V2 format + match v2_reroute::extract_ok_json::>(response).await + { + Ok(licenses) => { + let licenses = licenses + .into_iter() + .map(|l| License { + short: l.short, + name: l.name, + }) + .collect::>(); + HttpResponse::Ok().json(licenses) + } + Err(response) => response, + } +} + +#[derive(serde::Serialize)] +pub struct LicenseText { + pub title: String, + pub body: String, +} + +#[get("license/{id}")] +pub async fn license_text( + params: web::Path<(String,)>, +) -> Result { + let license = v3::tags::license_text(params) + .await + .or_else(v2_reroute::flatten_404_error)?; + + // Convert to V2 format + Ok( + match v2_reroute::extract_ok_json::(license) + .await + { + Ok(license) => HttpResponse::Ok().json(LicenseText { + title: license.title, + body: license.body, + }), + Err(response) => response, + }, + ) +} + +#[derive(serde::Serialize, serde::Deserialize, PartialEq, Eq, Debug)] +pub struct DonationPlatformQueryData { + // The difference between name and short is removed in v3. + // Now, the 'id' becomes the name, and the 'name' is removed (the frontend uses the id as the name) + // pub short: String, + pub short: String, + pub name: String, +} + +#[get("donation_platform")] +pub async fn donation_platform_list( + pool: web::Data, + redis: web::Data, +) -> Result { + let response = v3::tags::link_platform_list(pool, redis).await?; + + // Convert to V2 format + Ok( + match v2_reroute::extract_ok_json::>( + response, + ) + .await + { + Ok(platforms) => { + let platforms = platforms + .into_iter() + .filter_map(|p| { + if p.donation { + Some(DonationPlatformQueryData { + // Short vs name is no longer a recognized difference in v3. + // We capitalize to recreate the old behavior, with some special handling. + // This may result in different behaviour for platforms added after the v3 migration. + name: match p.name.as_str() { + "bmac" => "Buy Me A Coffee".to_string(), + "github" => "GitHub Sponsors".to_string(), + "ko-fi" => "Ko-fi".to_string(), + "paypal" => "PayPal".to_string(), + // Otherwise, capitalize it + _ => capitalize_first(&p.name), + }, + short: p.name, + }) + } else { + None + } + }) + .collect::>(); + HttpResponse::Ok().json(platforms) + } + Err(response) => response, + }, + ) + .or_else(v2_reroute::flatten_404_error) +} + +#[get("report_type")] +pub async fn report_type_list( + pool: web::Data, + redis: web::Data, +) -> Result { + // This returns a list of strings directly, so we don't need to convert to v2 format. + v3::tags::report_type_list(pool, redis) + .await + .or_else(v2_reroute::flatten_404_error) +} + +#[get("project_type")] +pub async fn project_type_list( + pool: web::Data, + redis: web::Data, +) -> Result { + // This returns a list of strings directly, so we don't need to convert to v2 format. + v3::tags::project_type_list(pool, redis) + .await + .or_else(v2_reroute::flatten_404_error) +} + +#[get("side_type")] +pub async fn side_type_list() -> Result { + // Original side types are no longer reflected in the database. + // Therefore, we hardcode and return all the fields that are supported by our v2 conversion logic. + let side_types = [ + LegacySideType::Required, + LegacySideType::Optional, + LegacySideType::Unsupported, + LegacySideType::Unknown, + ]; + let side_types = side_types.iter().map(|s| s.to_string()).collect_vec(); + Ok(HttpResponse::Ok().json(side_types)) +} diff --git a/apps/labrinth/src/routes/v2/teams.rs b/apps/labrinth/src/routes/v2/teams.rs new file mode 100644 index 000000000..444ff13ec --- /dev/null +++ b/apps/labrinth/src/routes/v2/teams.rs @@ -0,0 +1,274 @@ +use crate::database::redis::RedisPool; +use crate::models::teams::{ + OrganizationPermissions, ProjectPermissions, TeamId, TeamMember, +}; +use crate::models::users::UserId; +use crate::models::v2::teams::LegacyTeamMember; +use crate::queue::session::AuthQueue; +use crate::routes::{v2_reroute, v3, ApiError}; +use actix_web::{delete, get, patch, post, web, HttpRequest, HttpResponse}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(teams_get); + + cfg.service( + web::scope("team") + .service(team_members_get) + .service(edit_team_member) + .service(transfer_ownership) + .service(add_team_member) + .service(join_team) + .service(remove_team_member), + ); +} + +// Returns all members of a project, +// including the team members of the project's team, but +// also the members of the organization's team if the project is associated with an organization +// (Unlike team_members_get_project, which only returns the members of the project's team) +// They can be differentiated by the "organization_permissions" field being null or not +#[get("{id}/members")] +pub async fn team_members_get_project( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let response = v3::teams::team_members_get_project( + req, + info, + pool, + redis, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error)?; + // Convert response to V2 format + match v2_reroute::extract_ok_json::>(response).await { + Ok(members) => { + let members = members + .into_iter() + .map(LegacyTeamMember::from) + .collect::>(); + Ok(HttpResponse::Ok().json(members)) + } + Err(response) => Ok(response), + } +} + +// Returns all members of a team, but not necessarily those of a project-team's organization (unlike team_members_get_project) +#[get("{id}/members")] +pub async fn team_members_get( + req: HttpRequest, + info: web::Path<(TeamId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let response = + v3::teams::team_members_get(req, info, pool, redis, session_queue) + .await + .or_else(v2_reroute::flatten_404_error)?; + // Convert response to V2 format + match v2_reroute::extract_ok_json::>(response).await { + Ok(members) => { + let members = members + .into_iter() + .map(LegacyTeamMember::from) + .collect::>(); + Ok(HttpResponse::Ok().json(members)) + } + Err(response) => Ok(response), + } +} + +#[derive(Serialize, Deserialize)] +pub struct TeamIds { + pub ids: String, +} + +#[get("teams")] +pub async fn teams_get( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let response = v3::teams::teams_get( + req, + web::Query(v3::teams::TeamIds { ids: ids.ids }), + pool, + redis, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error); + // Convert response to V2 format + match v2_reroute::extract_ok_json::>>(response?).await { + Ok(members) => { + let members = members + .into_iter() + .map(|members| { + members + .into_iter() + .map(LegacyTeamMember::from) + .collect::>() + }) + .collect::>(); + Ok(HttpResponse::Ok().json(members)) + } + Err(response) => Ok(response), + } +} + +#[post("{id}/join")] +pub async fn join_team( + req: HttpRequest, + info: web::Path<(TeamId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + // Returns NoContent, so we don't need to convert the response + v3::teams::join_team(req, info, pool, redis, session_queue) + .await + .or_else(v2_reroute::flatten_404_error) +} + +fn default_role() -> String { + "Member".to_string() +} + +fn default_ordering() -> i64 { + 0 +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct NewTeamMember { + pub user_id: UserId, + #[serde(default = "default_role")] + pub role: String, + #[serde(default)] + pub permissions: ProjectPermissions, + #[serde(default)] + pub organization_permissions: Option, + #[serde(default)] + #[serde(with = "rust_decimal::serde::float")] + pub payouts_split: Decimal, + #[serde(default = "default_ordering")] + pub ordering: i64, +} + +#[post("{id}/members")] +pub async fn add_team_member( + req: HttpRequest, + info: web::Path<(TeamId,)>, + pool: web::Data, + new_member: web::Json, + redis: web::Data, + session_queue: web::Data, +) -> Result { + // Returns NoContent, so we don't need to convert the response + v3::teams::add_team_member( + req, + info, + pool, + web::Json(v3::teams::NewTeamMember { + user_id: new_member.user_id, + role: new_member.role.clone(), + permissions: new_member.permissions, + organization_permissions: new_member.organization_permissions, + payouts_split: new_member.payouts_split, + ordering: new_member.ordering, + }), + redis, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error) +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct EditTeamMember { + pub permissions: Option, + pub organization_permissions: Option, + pub role: Option, + pub payouts_split: Option, + pub ordering: Option, +} + +#[patch("{id}/members/{user_id}")] +pub async fn edit_team_member( + req: HttpRequest, + info: web::Path<(TeamId, UserId)>, + pool: web::Data, + edit_member: web::Json, + redis: web::Data, + session_queue: web::Data, +) -> Result { + // Returns NoContent, so we don't need to convert the response + v3::teams::edit_team_member( + req, + info, + pool, + web::Json(v3::teams::EditTeamMember { + permissions: edit_member.permissions, + organization_permissions: edit_member.organization_permissions, + role: edit_member.role.clone(), + payouts_split: edit_member.payouts_split, + ordering: edit_member.ordering, + }), + redis, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error) +} + +#[derive(Deserialize)] +pub struct TransferOwnership { + pub user_id: UserId, +} + +#[patch("{id}/owner")] +pub async fn transfer_ownership( + req: HttpRequest, + info: web::Path<(TeamId,)>, + pool: web::Data, + new_owner: web::Json, + redis: web::Data, + session_queue: web::Data, +) -> Result { + // Returns NoContent, so we don't need to convert the response + v3::teams::transfer_ownership( + req, + info, + pool, + web::Json(v3::teams::TransferOwnership { + user_id: new_owner.user_id, + }), + redis, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error) +} + +#[delete("{id}/members/{user_id}")] +pub async fn remove_team_member( + req: HttpRequest, + info: web::Path<(TeamId, UserId)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + // Returns NoContent, so we don't need to convert the response + v3::teams::remove_team_member(req, info, pool, redis, session_queue) + .await + .or_else(v2_reroute::flatten_404_error) +} diff --git a/apps/labrinth/src/routes/v2/threads.rs b/apps/labrinth/src/routes/v2/threads.rs new file mode 100644 index 000000000..ab5e781a5 --- /dev/null +++ b/apps/labrinth/src/routes/v2/threads.rs @@ -0,0 +1,123 @@ +use std::sync::Arc; + +use crate::database::redis::RedisPool; +use crate::file_hosting::FileHost; +use crate::models::ids::ThreadMessageId; +use crate::models::threads::{MessageBody, Thread, ThreadId}; +use crate::models::v2::threads::LegacyThread; +use crate::queue::session::AuthQueue; +use crate::routes::{v2_reroute, v3, ApiError}; +use actix_web::{delete, get, post, web, HttpRequest, HttpResponse}; +use serde::Deserialize; +use sqlx::PgPool; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("thread") + .service(thread_get) + .service(thread_send_message), + ); + cfg.service(web::scope("message").service(message_delete)); + cfg.service(threads_get); +} + +#[get("{id}")] +pub async fn thread_get( + req: HttpRequest, + info: web::Path<(ThreadId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + v3::threads::thread_get(req, info, pool, redis, session_queue) + .await + .or_else(v2_reroute::flatten_404_error) +} + +#[derive(Deserialize)] +pub struct ThreadIds { + pub ids: String, +} + +#[get("threads")] +pub async fn threads_get( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let response = v3::threads::threads_get( + req, + web::Query(v3::threads::ThreadIds { ids: ids.ids }), + pool, + redis, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error)?; + + // Convert response to V2 format + match v2_reroute::extract_ok_json::>(response).await { + Ok(threads) => { + let threads = threads + .into_iter() + .map(LegacyThread::from) + .collect::>(); + Ok(HttpResponse::Ok().json(threads)) + } + Err(response) => Ok(response), + } +} + +#[derive(Deserialize)] +pub struct NewThreadMessage { + pub body: MessageBody, +} + +#[post("{id}")] +pub async fn thread_send_message( + req: HttpRequest, + info: web::Path<(ThreadId,)>, + pool: web::Data, + new_message: web::Json, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let new_message = new_message.into_inner(); + // Returns NoContent, so we don't need to convert the response + v3::threads::thread_send_message( + req, + info, + pool, + web::Json(v3::threads::NewThreadMessage { + body: new_message.body, + }), + redis, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error) +} + +#[delete("{id}")] +pub async fn message_delete( + req: HttpRequest, + info: web::Path<(ThreadMessageId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + file_host: web::Data>, +) -> Result { + // Returns NoContent, so we don't need to convert the response + v3::threads::message_delete( + req, + info, + pool, + redis, + session_queue, + file_host, + ) + .await + .or_else(v2_reroute::flatten_404_error) +} diff --git a/apps/labrinth/src/routes/v2/users.rs b/apps/labrinth/src/routes/v2/users.rs new file mode 100644 index 000000000..b7b30c22d --- /dev/null +++ b/apps/labrinth/src/routes/v2/users.rs @@ -0,0 +1,288 @@ +use crate::database::redis::RedisPool; +use crate::file_hosting::FileHost; +use crate::models::notifications::Notification; +use crate::models::projects::Project; +use crate::models::users::{Badges, Role, User}; +use crate::models::v2::notifications::LegacyNotification; +use crate::models::v2::projects::LegacyProject; +use crate::models::v2::user::LegacyUser; +use crate::queue::session::AuthQueue; +use crate::routes::{v2_reroute, v3, ApiError}; +use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse}; +use lazy_static::lazy_static; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use std::sync::Arc; +use validator::Validate; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(user_auth_get); + cfg.service(users_get); + + cfg.service( + web::scope("user") + .service(user_get) + .service(projects_list) + .service(user_delete) + .service(user_edit) + .service(user_icon_edit) + .service(user_notifications) + .service(user_follows), + ); +} + +#[get("user")] +pub async fn user_auth_get( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let response = v3::users::user_auth_get(req, pool, redis, session_queue) + .await + .or_else(v2_reroute::flatten_404_error)?; + + // Convert response to V2 format + match v2_reroute::extract_ok_json::(response).await { + Ok(user) => { + let user = LegacyUser::from(user); + Ok(HttpResponse::Ok().json(user)) + } + Err(response) => Ok(response), + } +} + +#[derive(Serialize, Deserialize)] +pub struct UserIds { + pub ids: String, +} + +#[get("users")] +pub async fn users_get( + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, +) -> Result { + let response = v3::users::users_get( + web::Query(v3::users::UserIds { ids: ids.ids }), + pool, + redis, + ) + .await + .or_else(v2_reroute::flatten_404_error)?; + + // Convert response to V2 format + match v2_reroute::extract_ok_json::>(response).await { + Ok(users) => { + let legacy_users: Vec = + users.into_iter().map(LegacyUser::from).collect(); + Ok(HttpResponse::Ok().json(legacy_users)) + } + Err(response) => Ok(response), + } +} + +#[get("{id}")] +pub async fn user_get( + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, +) -> Result { + let response = v3::users::user_get(info, pool, redis) + .await + .or_else(v2_reroute::flatten_404_error)?; + + // Convert response to V2 format + match v2_reroute::extract_ok_json::(response).await { + Ok(user) => { + let user = LegacyUser::from(user); + Ok(HttpResponse::Ok().json(user)) + } + Err(response) => Ok(response), + } +} + +#[get("{user_id}/projects")] +pub async fn projects_list( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let response = v3::users::projects_list( + req, + info, + pool.clone(), + redis.clone(), + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error)?; + + // Convert to V2 projects + match v2_reroute::extract_ok_json::>(response).await { + Ok(project) => { + let legacy_projects = + LegacyProject::from_many(project, &**pool, &redis).await?; + Ok(HttpResponse::Ok().json(legacy_projects)) + } + Err(response) => Ok(response), + } +} + +lazy_static! { + static ref RE_URL_SAFE: Regex = Regex::new(r"^[a-zA-Z0-9_-]*$").unwrap(); +} + +#[derive(Serialize, Deserialize, Validate)] +pub struct EditUser { + #[validate(length(min = 1, max = 39), regex = "RE_URL_SAFE")] + pub username: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate(length(min = 1, max = 64), regex = "RE_URL_SAFE")] + pub name: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate(length(max = 160))] + pub bio: Option>, + pub role: Option, + pub badges: Option, +} + +#[patch("{id}")] +pub async fn user_edit( + req: HttpRequest, + info: web::Path<(String,)>, + new_user: web::Json, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let new_user = new_user.into_inner(); + // Returns NoContent, so we don't need to convert to V2 + v3::users::user_edit( + req, + info, + web::Json(v3::users::EditUser { + username: new_user.username, + bio: new_user.bio, + role: new_user.role, + badges: new_user.badges, + venmo_handle: None, + }), + pool, + redis, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error) +} + +#[derive(Serialize, Deserialize)] +pub struct Extension { + pub ext: String, +} + +#[patch("{id}/icon")] +#[allow(clippy::too_many_arguments)] +pub async fn user_icon_edit( + web::Query(ext): web::Query, + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + payload: web::Payload, + session_queue: web::Data, +) -> Result { + // Returns NoContent, so we don't need to convert to V2 + v3::users::user_icon_edit( + web::Query(v3::users::Extension { ext: ext.ext }), + req, + info, + pool, + redis, + file_host, + payload, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error) +} + +#[delete("{id}")] +pub async fn user_delete( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + // Returns NoContent, so we don't need to convert to V2 + v3::users::user_delete(req, info, pool, redis, session_queue) + .await + .or_else(v2_reroute::flatten_404_error) +} + +#[get("{id}/follows")] +pub async fn user_follows( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let response = v3::users::user_follows( + req, + info, + pool.clone(), + redis.clone(), + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error)?; + + // Convert to V2 projects + match v2_reroute::extract_ok_json::>(response).await { + Ok(project) => { + let legacy_projects = + LegacyProject::from_many(project, &**pool, &redis).await?; + Ok(HttpResponse::Ok().json(legacy_projects)) + } + Err(response) => Ok(response), + } +} + +#[get("{id}/notifications")] +pub async fn user_notifications( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let response = + v3::users::user_notifications(req, info, pool, redis, session_queue) + .await + .or_else(v2_reroute::flatten_404_error)?; + // Convert response to V2 format + match v2_reroute::extract_ok_json::>(response).await { + Ok(notifications) => { + let legacy_notifications: Vec = notifications + .into_iter() + .map(LegacyNotification::from) + .collect(); + Ok(HttpResponse::Ok().json(legacy_notifications)) + } + Err(response) => Ok(response), + } +} diff --git a/apps/labrinth/src/routes/v2/version_creation.rs b/apps/labrinth/src/routes/v2/version_creation.rs new file mode 100644 index 000000000..ba8248db7 --- /dev/null +++ b/apps/labrinth/src/routes/v2/version_creation.rs @@ -0,0 +1,332 @@ +use crate::database::models::loader_fields::VersionField; +use crate::database::models::{project_item, version_item}; +use crate::database::redis::RedisPool; +use crate::file_hosting::FileHost; +use crate::models::ids::ImageId; +use crate::models::projects::{ + Dependency, FileType, Loader, ProjectId, Version, VersionId, VersionStatus, + VersionType, +}; +use crate::models::v2::projects::LegacyVersion; +use crate::queue::moderation::AutomatedModerationQueue; +use crate::queue::session::AuthQueue; +use crate::routes::v3::project_creation::CreateError; +use crate::routes::v3::version_creation; +use crate::routes::{v2_reroute, v3}; +use actix_multipart::Multipart; +use actix_web::http::header::ContentDisposition; +use actix_web::web::Data; +use actix_web::{post, web, HttpRequest, HttpResponse}; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sqlx::postgres::PgPool; +use std::collections::HashMap; +use std::sync::Arc; +use validator::Validate; + +pub fn default_requested_status() -> VersionStatus { + VersionStatus::Listed +} + +#[derive(Serialize, Deserialize, Validate, Clone)] +pub struct InitialVersionData { + #[serde(alias = "mod_id")] + pub project_id: Option, + #[validate(length(min = 1, max = 256))] + pub file_parts: Vec, + #[validate( + length(min = 1, max = 32), + regex = "crate::util::validate::RE_URL_SAFE" + )] + pub version_number: String, + #[validate( + length(min = 1, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + #[serde(alias = "name")] + pub version_title: String, + #[validate(length(max = 65536))] + #[serde(alias = "changelog")] + pub version_body: Option, + #[validate( + length(min = 0, max = 4096), + custom(function = "crate::util::validate::validate_deps") + )] + pub dependencies: Vec, + #[validate(length(min = 1))] + pub game_versions: Vec, + #[serde(alias = "version_type")] + pub release_channel: VersionType, + #[validate(length(min = 1))] + pub loaders: Vec, + pub featured: bool, + pub primary_file: Option, + #[serde(default = "default_requested_status")] + pub status: VersionStatus, + #[serde(default = "HashMap::new")] + pub file_types: HashMap>, + // Associations to uploaded images in changelog + #[validate(length(max = 10))] + #[serde(default)] + pub uploaded_images: Vec, + + // The ordering relative to other versions + pub ordering: Option, +} + +#[derive(Serialize, Deserialize, Clone)] +struct InitialFileData { + #[serde(default = "HashMap::new")] + pub file_types: HashMap>, +} + +// under `/api/v1/version` +#[post("version")] +pub async fn version_create( + req: HttpRequest, + payload: Multipart, + client: Data, + redis: Data, + file_host: Data>, + session_queue: Data, + moderation_queue: Data, +) -> Result { + let payload = v2_reroute::alter_actix_multipart( + payload, + req.headers().clone(), + |legacy_create: InitialVersionData, + content_dispositions: Vec| { + let client = client.clone(); + let redis = redis.clone(); + async move { + // Convert input data to V3 format + let mut fields = HashMap::new(); + fields.insert( + "game_versions".to_string(), + json!(legacy_create.game_versions), + ); + + // Get all possible side-types for loaders given- we will use these to check if we need to convert/apply singleplayer, etc. + let loaders = + match v3::tags::loader_list(client.clone(), redis.clone()) + .await + { + Ok(loader_response) => { + (v2_reroute::extract_ok_json::< + Vec, + >(loader_response) + .await) + .unwrap_or_default() + } + Err(_) => vec![], + }; + + let loader_fields_aggregate = loaders + .into_iter() + .filter_map(|loader| { + if legacy_create + .loaders + .contains(&Loader(loader.name.clone())) + { + Some(loader.supported_fields) + } else { + None + } + }) + .flatten() + .collect::>(); + + // Copies side types of another version of the project. + // If no version exists, defaults to all false. + // This is inherently lossy, but not much can be done about it, as side types are no longer associated with projects, + // so the 'missing' ones can't be easily accessed, and versions do need to have these fields explicitly set. + let side_type_loader_field_names = [ + "singleplayer", + "client_and_server", + "client_only", + "server_only", + ]; + + // Check if loader_fields_aggregate contains any of these side types + // We assume these four fields are linked together. + if loader_fields_aggregate + .iter() + .any(|f| side_type_loader_field_names.contains(&f.as_str())) + { + // If so, we get the fields of the example version of the project, and set the side types to match. + fields.extend( + side_type_loader_field_names + .iter() + .map(|f| (f.to_string(), json!(false))), + ); + if let Some(example_version_fields) = + get_example_version_fields( + legacy_create.project_id, + client, + &redis, + ) + .await? + { + fields.extend( + example_version_fields.into_iter().filter_map( + |f| { + if side_type_loader_field_names + .contains(&f.field_name.as_str()) + { + Some(( + f.field_name, + f.value.serialize_internal(), + )) + } else { + None + } + }, + ), + ); + } + } + // Handle project type via file extension prediction + let mut project_type = None; + for file_part in &legacy_create.file_parts { + if let Some(ext) = file_part.split('.').last() { + match ext { + "mrpack" | "mrpack-primary" => { + project_type = Some("modpack"); + break; + } + // No other type matters + _ => {} + } + break; + } + } + + // Similarly, check actual content disposition for mrpacks, in case file_parts is wrong + for content_disposition in content_dispositions { + // Uses version_create functions to get the file name and extension + let (_, file_extension) = + version_creation::get_name_ext(&content_disposition)?; + crate::util::ext::project_file_type(file_extension) + .ok_or_else(|| { + CreateError::InvalidFileType( + file_extension.to_string(), + ) + })?; + + if file_extension == "mrpack" { + project_type = Some("modpack"); + break; + } + } + + // Modpacks now use the "mrpack" loader, and loaders are converted to loader fields. + // Setting of 'project_type' directly is removed, it's loader-based now. + if project_type == Some("modpack") { + fields.insert( + "mrpack_loaders".to_string(), + json!(legacy_create.loaders), + ); + } + + let loaders = if project_type == Some("modpack") { + vec![Loader("mrpack".to_string())] + } else { + legacy_create.loaders + }; + + Ok(v3::version_creation::InitialVersionData { + project_id: legacy_create.project_id, + file_parts: legacy_create.file_parts, + version_number: legacy_create.version_number, + version_title: legacy_create.version_title, + version_body: legacy_create.version_body, + dependencies: legacy_create.dependencies, + release_channel: legacy_create.release_channel, + loaders, + featured: legacy_create.featured, + primary_file: legacy_create.primary_file, + status: legacy_create.status, + file_types: legacy_create.file_types, + uploaded_images: legacy_create.uploaded_images, + ordering: legacy_create.ordering, + fields, + }) + } + }, + ) + .await?; + + // Call V3 project creation + let response = v3::version_creation::version_create( + req, + payload, + client.clone(), + redis.clone(), + file_host, + session_queue, + moderation_queue, + ) + .await?; + + // Convert response to V2 format + match v2_reroute::extract_ok_json::(response).await { + Ok(version) => { + let v2_version = LegacyVersion::from(version); + Ok(HttpResponse::Ok().json(v2_version)) + } + Err(response) => Ok(response), + } +} + +// Gets version fields of an example version of a project, if one exists. +async fn get_example_version_fields( + project_id: Option, + pool: Data, + redis: &RedisPool, +) -> Result>, CreateError> { + let project_id = match project_id { + Some(project_id) => project_id, + None => return Ok(None), + }; + + let vid = + match project_item::Project::get_id(project_id.into(), &**pool, redis) + .await? + .and_then(|p| p.versions.first().cloned()) + { + Some(vid) => vid, + None => return Ok(None), + }; + + let example_version = + match version_item::Version::get(vid, &**pool, redis).await? { + Some(version) => version, + None => return Ok(None), + }; + Ok(Some(example_version.version_fields)) +} + +// under /api/v1/version/{version_id} +#[post("{version_id}/file")] +pub async fn upload_file_to_version( + req: HttpRequest, + url_data: web::Path<(VersionId,)>, + payload: Multipart, + client: Data, + redis: Data, + file_host: Data>, + session_queue: web::Data, +) -> Result { + // Returns NoContent, so no need to convert to V2 + let response = v3::version_creation::upload_file_to_version( + req, + url_data, + payload, + client.clone(), + redis.clone(), + file_host, + session_queue, + ) + .await?; + Ok(response) +} diff --git a/apps/labrinth/src/routes/v2/version_file.rs b/apps/labrinth/src/routes/v2/version_file.rs new file mode 100644 index 000000000..bb998b661 --- /dev/null +++ b/apps/labrinth/src/routes/v2/version_file.rs @@ -0,0 +1,390 @@ +use super::ApiError; +use crate::database::redis::RedisPool; +use crate::models::projects::{Project, Version, VersionType}; +use crate::models::v2::projects::{LegacyProject, LegacyVersion}; +use crate::queue::session::AuthQueue; +use crate::routes::v3::version_file::HashQuery; +use crate::routes::{v2_reroute, v3}; +use actix_web::{delete, get, post, web, HttpRequest, HttpResponse}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use std::collections::HashMap; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("version_file") + .service(delete_file) + .service(get_version_from_hash) + .service(download_version) + .service(get_update_from_hash) + .service(get_projects_from_hashes), + ); + + cfg.service( + web::scope("version_files") + .service(get_versions_from_hashes) + .service(update_files) + .service(update_individual_files), + ); +} + +// under /api/v1/version_file/{hash} +#[get("{version_id}")] +pub async fn get_version_from_hash( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + hash_query: web::Query, + session_queue: web::Data, +) -> Result { + let response = v3::version_file::get_version_from_hash( + req, + info, + pool, + redis, + hash_query, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error)?; + + // Convert response to V2 format + match v2_reroute::extract_ok_json::(response).await { + Ok(version) => { + let v2_version = LegacyVersion::from(version); + Ok(HttpResponse::Ok().json(v2_version)) + } + Err(response) => Ok(response), + } +} + +// under /api/v1/version_file/{hash}/download +#[get("{version_id}/download")] +pub async fn download_version( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + hash_query: web::Query, + session_queue: web::Data, +) -> Result { + // Returns TemporaryRedirect, so no need to convert to V2 + v3::version_file::download_version( + req, + info, + pool, + redis, + hash_query, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error) +} + +// under /api/v1/version_file/{hash} +#[delete("{version_id}")] +pub async fn delete_file( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + hash_query: web::Query, + session_queue: web::Data, +) -> Result { + // Returns NoContent, so no need to convert to V2 + v3::version_file::delete_file( + req, + info, + pool, + redis, + hash_query, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error) +} + +#[derive(Serialize, Deserialize)] +pub struct UpdateData { + pub loaders: Option>, + pub game_versions: Option>, + pub version_types: Option>, +} + +#[post("{version_id}/update")] +pub async fn get_update_from_hash( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + hash_query: web::Query, + update_data: web::Json, + session_queue: web::Data, +) -> Result { + let update_data = update_data.into_inner(); + let mut loader_fields = HashMap::new(); + let mut game_versions = vec![]; + for gv in update_data.game_versions.into_iter().flatten() { + game_versions.push(serde_json::json!(gv.clone())); + } + if !game_versions.is_empty() { + loader_fields.insert("game_versions".to_string(), game_versions); + } + let update_data = v3::version_file::UpdateData { + loaders: update_data.loaders.clone(), + version_types: update_data.version_types.clone(), + loader_fields: Some(loader_fields), + }; + + let response = v3::version_file::get_update_from_hash( + req, + info, + pool, + redis, + hash_query, + web::Json(update_data), + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error)?; + + // Convert response to V2 format + match v2_reroute::extract_ok_json::(response).await { + Ok(version) => { + let v2_version = LegacyVersion::from(version); + Ok(HttpResponse::Ok().json(v2_version)) + } + Err(response) => Ok(response), + } +} + +// Requests above with multiple versions below +#[derive(Deserialize)] +pub struct FileHashes { + pub algorithm: Option, + pub hashes: Vec, +} + +// under /api/v2/version_files +#[post("")] +pub async fn get_versions_from_hashes( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + file_data: web::Json, + session_queue: web::Data, +) -> Result { + let file_data = file_data.into_inner(); + let file_data = v3::version_file::FileHashes { + algorithm: file_data.algorithm, + hashes: file_data.hashes, + }; + let response = v3::version_file::get_versions_from_hashes( + req, + pool, + redis, + web::Json(file_data), + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error)?; + + // Convert to V2 + match v2_reroute::extract_ok_json::>(response) + .await + { + Ok(versions) => { + let v2_versions = versions + .into_iter() + .map(|(hash, version)| { + let v2_version = LegacyVersion::from(version); + (hash, v2_version) + }) + .collect::>(); + Ok(HttpResponse::Ok().json(v2_versions)) + } + Err(response) => Ok(response), + } +} + +#[post("project")] +pub async fn get_projects_from_hashes( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + file_data: web::Json, + session_queue: web::Data, +) -> Result { + let file_data = file_data.into_inner(); + let file_data = v3::version_file::FileHashes { + algorithm: file_data.algorithm, + hashes: file_data.hashes, + }; + let response = v3::version_file::get_projects_from_hashes( + req, + pool.clone(), + redis.clone(), + web::Json(file_data), + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error)?; + + // Convert to V2 + match v2_reroute::extract_ok_json::>(response) + .await + { + Ok(projects_hashes) => { + let hash_to_project_id = projects_hashes + .iter() + .map(|(hash, project)| { + let project_id = project.id; + (hash.clone(), project_id) + }) + .collect::>(); + let legacy_projects = LegacyProject::from_many( + projects_hashes.into_values().collect(), + &**pool, + &redis, + ) + .await?; + let legacy_projects_hashes = hash_to_project_id + .into_iter() + .filter_map(|(hash, project_id)| { + let legacy_project = legacy_projects + .iter() + .find(|x| x.id == project_id)? + .clone(); + Some((hash, legacy_project)) + }) + .collect::>(); + + Ok(HttpResponse::Ok().json(legacy_projects_hashes)) + } + Err(response) => Ok(response), + } +} + +#[derive(Deserialize)] +pub struct ManyUpdateData { + pub algorithm: Option, // Defaults to calculation based on size of hash + pub hashes: Vec, + pub loaders: Option>, + pub game_versions: Option>, + pub version_types: Option>, +} + +#[post("update")] +pub async fn update_files( + pool: web::Data, + redis: web::Data, + update_data: web::Json, +) -> Result { + let update_data = update_data.into_inner(); + let update_data = v3::version_file::ManyUpdateData { + loaders: update_data.loaders.clone(), + version_types: update_data.version_types.clone(), + game_versions: update_data.game_versions.clone(), + algorithm: update_data.algorithm, + hashes: update_data.hashes, + }; + + let response = + v3::version_file::update_files(pool, redis, web::Json(update_data)) + .await + .or_else(v2_reroute::flatten_404_error)?; + + // Convert response to V2 format + match v2_reroute::extract_ok_json::>(response) + .await + { + Ok(returned_versions) => { + let v3_versions = returned_versions + .into_iter() + .map(|(hash, version)| { + let v2_version = LegacyVersion::from(version); + (hash, v2_version) + }) + .collect::>(); + Ok(HttpResponse::Ok().json(v3_versions)) + } + Err(response) => Ok(response), + } +} + +#[derive(Serialize, Deserialize)] +pub struct FileUpdateData { + pub hash: String, + pub loaders: Option>, + pub game_versions: Option>, + pub version_types: Option>, +} + +#[derive(Deserialize)] +pub struct ManyFileUpdateData { + pub algorithm: Option, // Defaults to calculation based on size of hash + pub hashes: Vec, +} + +#[post("update_individual")] +pub async fn update_individual_files( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + update_data: web::Json, + session_queue: web::Data, +) -> Result { + let update_data = update_data.into_inner(); + let update_data = v3::version_file::ManyFileUpdateData { + algorithm: update_data.algorithm, + hashes: update_data + .hashes + .into_iter() + .map(|x| { + let mut loader_fields = HashMap::new(); + let mut game_versions = vec![]; + for gv in x.game_versions.into_iter().flatten() { + game_versions.push(serde_json::json!(gv.clone())); + } + if !game_versions.is_empty() { + loader_fields + .insert("game_versions".to_string(), game_versions); + } + v3::version_file::FileUpdateData { + hash: x.hash.clone(), + loaders: x.loaders.clone(), + loader_fields: Some(loader_fields), + version_types: x.version_types, + } + }) + .collect(), + }; + + let response = v3::version_file::update_individual_files( + req, + pool, + redis, + web::Json(update_data), + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error)?; + + // Convert response to V2 format + match v2_reroute::extract_ok_json::>(response) + .await + { + Ok(returned_versions) => { + let v3_versions = returned_versions + .into_iter() + .map(|(hash, version)| { + let v2_version = LegacyVersion::from(version); + (hash, v2_version) + }) + .collect::>(); + Ok(HttpResponse::Ok().json(v3_versions)) + } + Err(response) => Ok(response), + } +} diff --git a/apps/labrinth/src/routes/v2/versions.rs b/apps/labrinth/src/routes/v2/versions.rs new file mode 100644 index 000000000..4d642542c --- /dev/null +++ b/apps/labrinth/src/routes/v2/versions.rs @@ -0,0 +1,353 @@ +use std::collections::HashMap; + +use super::ApiError; +use crate::database::redis::RedisPool; +use crate::models; +use crate::models::ids::VersionId; +use crate::models::projects::{ + Dependency, FileType, Version, VersionStatus, VersionType, +}; +use crate::models::v2::projects::LegacyVersion; +use crate::queue::session::AuthQueue; +use crate::routes::{v2_reroute, v3}; +use crate::search::SearchConfig; +use actix_web::{delete, get, patch, web, HttpRequest, HttpResponse}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use validator::Validate; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service(versions_get); + cfg.service(super::version_creation::version_create); + + cfg.service( + web::scope("version") + .service(version_get) + .service(version_delete) + .service(version_edit) + .service(super::version_creation::upload_file_to_version), + ); +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct VersionListFilters { + pub game_versions: Option, + pub loaders: Option, + pub featured: Option, + pub version_type: Option, + pub limit: Option, + pub offset: Option, +} + +#[get("version")] +pub async fn version_list( + req: HttpRequest, + info: web::Path<(String,)>, + web::Query(filters): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let loaders = if let Some(loaders) = filters.loaders { + if let Ok(mut loaders) = serde_json::from_str::>(&loaders) { + loaders.push("mrpack".to_string()); + Some(loaders) + } else { + None + } + } else { + None + }; + + let loader_fields = if let Some(game_versions) = filters.game_versions { + // TODO: extract this logic which is similar to the other v2->v3 version_file functions + let mut loader_fields = HashMap::new(); + serde_json::from_str::>(&game_versions) + .ok() + .and_then(|versions| { + let mut game_versions: Vec = vec![]; + for gv in versions { + game_versions.push(serde_json::json!(gv.clone())); + } + loader_fields + .insert("game_versions".to_string(), game_versions); + + if let Some(ref loaders) = loaders { + loader_fields.insert( + "loaders".to_string(), + loaders + .iter() + .map(|x| serde_json::json!(x.clone())) + .collect(), + ); + } + + serde_json::to_string(&loader_fields).ok() + }) + } else { + None + }; + + let filters = v3::versions::VersionListFilters { + loader_fields, + loaders: loaders.and_then(|x| serde_json::to_string(&x).ok()), + featured: filters.featured, + version_type: filters.version_type, + limit: filters.limit, + offset: filters.offset, + }; + + let response = v3::versions::version_list( + req, + info, + web::Query(filters), + pool, + redis, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error)?; + + // Convert response to V2 format + match v2_reroute::extract_ok_json::>(response).await { + Ok(versions) => { + let v2_versions = versions + .into_iter() + .map(LegacyVersion::from) + .collect::>(); + Ok(HttpResponse::Ok().json(v2_versions)) + } + Err(response) => Ok(response), + } +} + +// Given a project ID/slug and a version slug +#[get("version/{slug}")] +pub async fn version_project_get( + req: HttpRequest, + info: web::Path<(String, String)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let id = info.into_inner(); + let response = v3::versions::version_project_get_helper( + req, + id, + pool, + redis, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error)?; + // Convert response to V2 format + match v2_reroute::extract_ok_json::(response).await { + Ok(version) => { + let v2_version = LegacyVersion::from(version); + Ok(HttpResponse::Ok().json(v2_version)) + } + Err(response) => Ok(response), + } +} + +#[derive(Serialize, Deserialize)] +pub struct VersionIds { + pub ids: String, +} + +#[get("versions")] +pub async fn versions_get( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let ids = v3::versions::VersionIds { ids: ids.ids }; + let response = v3::versions::versions_get( + req, + web::Query(ids), + pool, + redis, + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error)?; + + // Convert response to V2 format + match v2_reroute::extract_ok_json::>(response).await { + Ok(versions) => { + let v2_versions = versions + .into_iter() + .map(LegacyVersion::from) + .collect::>(); + Ok(HttpResponse::Ok().json(v2_versions)) + } + Err(response) => Ok(response), + } +} + +#[get("{version_id}")] +pub async fn version_get( + req: HttpRequest, + info: web::Path<(models::ids::VersionId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let id = info.into_inner().0; + let response = + v3::versions::version_get_helper(req, id, pool, redis, session_queue) + .await + .or_else(v2_reroute::flatten_404_error)?; + // Convert response to V2 format + match v2_reroute::extract_ok_json::(response).await { + Ok(version) => { + let v2_version = LegacyVersion::from(version); + Ok(HttpResponse::Ok().json(v2_version)) + } + Err(response) => Ok(response), + } +} + +#[derive(Serialize, Deserialize, Validate)] +pub struct EditVersion { + #[validate( + length(min = 1, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + pub name: Option, + #[validate( + length(min = 1, max = 32), + regex = "crate::util::validate::RE_URL_SAFE" + )] + pub version_number: Option, + #[validate(length(max = 65536))] + pub changelog: Option, + pub version_type: Option, + #[validate( + length(min = 0, max = 4096), + custom(function = "crate::util::validate::validate_deps") + )] + pub dependencies: Option>, + pub game_versions: Option>, + pub loaders: Option>, + pub featured: Option, + pub downloads: Option, + pub status: Option, + pub file_types: Option>, +} + +#[derive(Serialize, Deserialize)] +pub struct EditVersionFileType { + pub algorithm: String, + pub hash: String, + pub file_type: Option, +} + +#[patch("{id}")] +pub async fn version_edit( + req: HttpRequest, + info: web::Path<(VersionId,)>, + pool: web::Data, + redis: web::Data, + new_version: web::Json, + session_queue: web::Data, +) -> Result { + let new_version = new_version.into_inner(); + + let mut fields = HashMap::new(); + if new_version.game_versions.is_some() { + fields.insert( + "game_versions".to_string(), + serde_json::json!(new_version.game_versions), + ); + } + + // Get the older version to get info from + let old_version = v3::versions::version_get_helper( + req.clone(), + (*info).0, + pool.clone(), + redis.clone(), + session_queue.clone(), + ) + .await + .or_else(v2_reroute::flatten_404_error)?; + let old_version = + match v2_reroute::extract_ok_json::(old_version).await { + Ok(version) => version, + Err(response) => return Ok(response), + }; + + // If this has 'mrpack_loaders' as a loader field previously, this is a modpack. + // Therefore, if we are modifying the 'loader' field in this case, + // we are actually modifying the 'mrpack_loaders' loader field + let mut loaders = new_version.loaders.clone(); + if old_version.fields.contains_key("mrpack_loaders") + && new_version.loaders.is_some() + { + fields.insert( + "mrpack_loaders".to_string(), + serde_json::json!(new_version.loaders), + ); + loaders = None; + } + + let new_version = v3::versions::EditVersion { + name: new_version.name, + version_number: new_version.version_number, + changelog: new_version.changelog, + version_type: new_version.version_type, + dependencies: new_version.dependencies, + loaders, + featured: new_version.featured, + downloads: new_version.downloads, + status: new_version.status, + file_types: new_version.file_types.map(|v| { + v.into_iter() + .map(|evft| v3::versions::EditVersionFileType { + algorithm: evft.algorithm, + hash: evft.hash, + file_type: evft.file_type, + }) + .collect::>() + }), + ordering: None, + fields, + }; + + let response = v3::versions::version_edit( + req, + info, + pool, + redis, + web::Json(serde_json::to_value(new_version)?), + session_queue, + ) + .await + .or_else(v2_reroute::flatten_404_error)?; + Ok(response) +} + +#[delete("{version_id}")] +pub async fn version_delete( + req: HttpRequest, + info: web::Path<(VersionId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + search_config: web::Data, +) -> Result { + // Returns NoContent, so we don't need to convert the response + v3::versions::version_delete( + req, + info, + pool, + redis, + session_queue, + search_config, + ) + .await + .or_else(v2_reroute::flatten_404_error) +} diff --git a/apps/labrinth/src/routes/v2_reroute.rs b/apps/labrinth/src/routes/v2_reroute.rs new file mode 100644 index 000000000..665f11a7f --- /dev/null +++ b/apps/labrinth/src/routes/v2_reroute.rs @@ -0,0 +1,329 @@ +use std::collections::HashMap; + +use super::v3::project_creation::CreateError; +use super::ApiError; +use crate::models::v2::projects::LegacySideType; +use crate::util::actix::{ + generate_multipart, MultipartSegment, MultipartSegmentData, +}; +use actix_multipart::Multipart; +use actix_web::http::header::{ + ContentDisposition, HeaderMap, TryIntoHeaderPair, +}; +use actix_web::HttpResponse; +use futures::{stream, Future, StreamExt}; +use serde_json::{json, Value}; + +pub async fn extract_ok_json( + response: HttpResponse, +) -> Result +where + T: serde::de::DeserializeOwned, +{ + // If the response is StatusCode::OK, parse the json and return it + if response.status() == actix_web::http::StatusCode::OK { + let failure_http_response = || { + HttpResponse::InternalServerError().json(json!({ + "error": "reroute_error", + "description": "Could not parse response from V2 redirection of route." + })) + }; + // Takes json out of HttpResponse, mutates it, then regenerates the HttpResponse + let body = response.into_body(); + let bytes = actix_web::body::to_bytes(body) + .await + .map_err(|_| failure_http_response())?; + let json_value: T = serde_json::from_slice(&bytes) + .map_err(|_| failure_http_response())?; + Ok(json_value) + } else { + Err(response) + } +} + +// This only removes the body of 404 responses +// This should not be used on the fallback no-route-found handler +pub fn flatten_404_error(res: ApiError) -> Result { + match res { + ApiError::NotFound => Ok(HttpResponse::NotFound().body("")), + _ => Err(res), + } +} + +// Allows internal modification of an actix multipart file +// Expected: +// 1. A json segment +// 2. Any number of other binary segments +// 'closure' is called with the json value, and the content disposition of the other segments +pub async fn alter_actix_multipart( + mut multipart: Multipart, + mut headers: HeaderMap, + mut closure: impl FnMut(T, Vec) -> Fut, +) -> Result +where + T: serde::de::DeserializeOwned, + U: serde::Serialize, + Fut: Future>, +{ + let mut segments: Vec = Vec::new(); + + let mut json = None; + let mut json_segment = None; + let mut content_dispositions = Vec::new(); + + if let Some(field) = multipart.next().await { + let mut field = field?; + let content_disposition = field.content_disposition().clone(); + let field_name = content_disposition.get_name().unwrap_or(""); + let field_filename = content_disposition.get_filename(); + let field_content_type = field.content_type(); + let field_content_type = field_content_type.map(|ct| ct.to_string()); + + let mut buffer = Vec::new(); + while let Some(chunk) = field.next().await { + let data = chunk?; + buffer.extend_from_slice(&data); + } + + { + let json_value: T = serde_json::from_slice(&buffer)?; + json = Some(json_value); + } + + json_segment = Some(MultipartSegment { + name: field_name.to_string(), + filename: field_filename.map(|s| s.to_string()), + content_type: field_content_type, + data: MultipartSegmentData::Binary(vec![]), // Initialize to empty, will be finished after + }); + } + + while let Some(field) = multipart.next().await { + let mut field = field?; + let content_disposition = field.content_disposition().clone(); + let field_name = content_disposition.get_name().unwrap_or(""); + let field_filename = content_disposition.get_filename(); + let field_content_type = field.content_type(); + let field_content_type = field_content_type.map(|ct| ct.to_string()); + + let mut buffer = Vec::new(); + while let Some(chunk) = field.next().await { + let data = chunk?; + buffer.extend_from_slice(&data); + } + + content_dispositions.push(content_disposition.clone()); + segments.push(MultipartSegment { + name: field_name.to_string(), + filename: field_filename.map(|s| s.to_string()), + content_type: field_content_type, + data: MultipartSegmentData::Binary(buffer), + }) + } + + // Finishes the json segment, with aggregated content dispositions + { + let json_value = json.ok_or(CreateError::InvalidInput( + "No json segment found in multipart.".to_string(), + ))?; + let mut json_segment = + json_segment.ok_or(CreateError::InvalidInput( + "No json segment found in multipart.".to_string(), + ))?; + + // Call closure, with the json value and names of the other segments + let json_value: U = closure(json_value, content_dispositions).await?; + let buffer = serde_json::to_vec(&json_value)?; + json_segment.data = MultipartSegmentData::Binary(buffer); + + // Insert the json segment at the beginning + segments.insert(0, json_segment); + } + + let (boundary, payload) = generate_multipart(segments); + + match ( + "Content-Type", + format!("multipart/form-data; boundary={}", boundary).as_str(), + ) + .try_into_pair() + { + Ok((key, value)) => { + headers.insert(key, value); + } + Err(err) => { + CreateError::InvalidInput(format!( + "Error inserting test header: {:?}.", + err + )); + } + }; + + let new_multipart = + Multipart::new(&headers, stream::once(async { Ok(payload) })); + + Ok(new_multipart) +} + +// Converts a "client_side" and "server_side" pair into the new v3 corresponding fields +pub fn convert_side_types_v3( + client_side: LegacySideType, + server_side: LegacySideType, +) -> HashMap { + use LegacySideType::{Optional, Required}; + + let singleplayer = client_side == Required + || client_side == Optional + || server_side == Required + || server_side == Optional; + let client_and_server = singleplayer; + let client_only = (client_side == Required || client_side == Optional) + && server_side != Required; + let server_only = (server_side == Required || server_side == Optional) + && client_side != Required; + + let mut fields = HashMap::new(); + fields.insert("singleplayer".to_string(), json!(singleplayer)); + fields.insert("client_and_server".to_string(), json!(client_and_server)); + fields.insert("client_only".to_string(), json!(client_only)); + fields.insert("server_only".to_string(), json!(server_only)); + fields +} + +// Converts plugin loaders from v2 to v3, for search facets +// Within every 1st and 2nd level (the ones allowed in v2), we convert every instance of: +// "project_type:mod" to "project_type:plugin" OR "project_type:mod" +pub fn convert_plugin_loader_facets_v3( + facets: Vec>, +) -> Vec> { + facets + .into_iter() + .map(|inner_facets| { + if inner_facets == ["project_type:mod"] { + vec![ + "project_type:plugin".to_string(), + "project_type:datapack".to_string(), + "project_type:mod".to_string(), + ] + } else { + inner_facets + } + }) + .collect::>() +} + +// Convert search facets from V3 back to v2 +// this is not lossless. (See tests) +pub fn convert_side_types_v2( + side_types: &HashMap, + project_type: Option<&str>, +) -> (LegacySideType, LegacySideType) { + let client_and_server = side_types + .get("client_and_server") + .and_then(|x| x.as_bool()) + .unwrap_or(false); + let singleplayer = side_types + .get("singleplayer") + .and_then(|x| x.as_bool()) + .unwrap_or(client_and_server); + let client_only = side_types + .get("client_only") + .and_then(|x| x.as_bool()) + .unwrap_or(false); + let server_only = side_types + .get("server_only") + .and_then(|x| x.as_bool()) + .unwrap_or(false); + + convert_side_types_v2_bools( + Some(singleplayer), + client_only, + server_only, + Some(client_and_server), + project_type, + ) +} + +// Client side, server side +pub fn convert_side_types_v2_bools( + singleplayer: Option, + client_only: bool, + server_only: bool, + client_and_server: Option, + project_type: Option<&str>, +) -> (LegacySideType, LegacySideType) { + use LegacySideType::{Optional, Required, Unknown, Unsupported}; + + match project_type { + Some("plugin") => (Unsupported, Required), + Some("datapack") => (Optional, Required), + Some("shader") => (Required, Unsupported), + Some("resourcepack") => (Required, Unsupported), + _ => { + let singleplayer = + singleplayer.or(client_and_server).unwrap_or(false); + + match (singleplayer, client_only, server_only) { + // Only singleplayer + (true, false, false) => (Required, Required), + + // Client only and not server only + (false, true, false) => (Required, Unsupported), + (true, true, false) => (Required, Unsupported), + + // Server only and not client only + (false, false, true) => (Unsupported, Required), + (true, false, true) => (Unsupported, Required), + + // Both server only and client only + (true, true, true) => (Optional, Optional), + (false, true, true) => (Optional, Optional), + + // Bad type + (false, false, false) => (Unknown, Unknown), + } + } + } +} + +pub fn capitalize_first(input: &str) -> String { + let mut result = input.to_owned(); + if let Some(first_char) = result.get_mut(0..1) { + first_char.make_ascii_uppercase(); + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::models::v2::projects::LegacySideType::{ + Optional, Required, Unsupported, + }; + + #[test] + fn convert_types() { + // Converting types from V2 to V3 and back should be idempotent- for certain pairs + let lossy_pairs = [ + (Optional, Unsupported), + (Unsupported, Optional), + (Required, Optional), + (Optional, Required), + (Unsupported, Unsupported), + ]; + + for client_side in [Required, Optional, Unsupported] { + for server_side in [Required, Optional, Unsupported] { + if lossy_pairs.contains(&(client_side, server_side)) { + continue; + } + let side_types = + convert_side_types_v3(client_side, server_side); + let (client_side2, server_side2) = + convert_side_types_v2(&side_types, None); + assert_eq!(client_side, client_side2); + assert_eq!(server_side, server_side2); + } + } + } +} diff --git a/apps/labrinth/src/routes/v3/analytics_get.rs b/apps/labrinth/src/routes/v3/analytics_get.rs new file mode 100644 index 000000000..a31e753b4 --- /dev/null +++ b/apps/labrinth/src/routes/v3/analytics_get.rs @@ -0,0 +1,663 @@ +use super::ApiError; +use crate::database; +use crate::database::redis::RedisPool; +use crate::models::teams::ProjectPermissions; +use crate::{ + auth::get_user_from_headers, + database::models::user_item, + models::{ + ids::{base62_impl::to_base62, ProjectId, VersionId}, + pats::Scopes, + }, + queue::session::AuthQueue, +}; +use actix_web::{web, HttpRequest, HttpResponse}; +use chrono::{DateTime, Duration, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::postgres::types::PgInterval; +use sqlx::PgPool; +use std::collections::HashMap; +use std::convert::TryInto; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("analytics") + .route("playtime", web::get().to(playtimes_get)) + .route("views", web::get().to(views_get)) + .route("downloads", web::get().to(downloads_get)) + .route("revenue", web::get().to(revenue_get)) + .route( + "countries/downloads", + web::get().to(countries_downloads_get), + ) + .route("countries/views", web::get().to(countries_views_get)), + ); +} + +/// The json data to be passed to fetch analytic data +/// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. +/// start_date and end_date are optional, and default to two weeks ago, and the maximum date respectively. +/// resolution_minutes is optional. This refers to the window by which we are looking (every day, every minute, etc) and defaults to 1440 (1 day) +#[derive(Serialize, Deserialize, Clone, Debug)] +pub struct GetData { + // only one of project_ids or version_ids should be used + // if neither are provided, all projects the user has access to will be used + pub project_ids: Option, + + pub start_date: Option>, // defaults to 2 weeks ago + pub end_date: Option>, // defaults to now + + pub resolution_minutes: Option, // defaults to 1 day. Ignored in routes that do not aggregate over a resolution (eg: /countries) +} + +/// Get playtime data for a set of projects or versions +/// Data is returned as a hashmap of project/version ids to a hashmap of days to playtime data +/// eg: +/// { +/// "4N1tEhnO": { +/// "20230824": 23 +/// } +///} +/// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. +#[derive(Serialize, Deserialize, Clone)] +pub struct FetchedPlaytime { + pub time: u64, + pub total_seconds: u64, + pub loader_seconds: HashMap, + pub game_version_seconds: HashMap, + pub parent_seconds: HashMap, +} +pub async fn playtimes_get( + req: HttpRequest, + clickhouse: web::Data, + data: web::Query, + session_queue: web::Data, + pool: web::Data, + redis: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ANALYTICS]), + ) + .await + .map(|x| x.1)?; + + let project_ids = data + .project_ids + .as_ref() + .map(|ids| serde_json::from_str::>(ids)) + .transpose()?; + + let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); + let end_date = data.end_date.unwrap_or(Utc::now()); + let resolution_minutes = data.resolution_minutes.unwrap_or(60 * 24); + + // Convert String list to list of ProjectIds or VersionIds + // - Filter out unauthorized projects/versions + // - If no project_ids or version_ids are provided, we default to all projects the user has access to + let project_ids = + filter_allowed_ids(project_ids, user, &pool, &redis, None).await?; + + // Get the views + let playtimes = crate::clickhouse::fetch_playtimes( + project_ids.unwrap_or_default(), + start_date, + end_date, + resolution_minutes, + clickhouse.into_inner(), + ) + .await?; + + let mut hm = HashMap::new(); + for playtime in playtimes { + let id_string = to_base62(playtime.id); + if !hm.contains_key(&id_string) { + hm.insert(id_string.clone(), HashMap::new()); + } + if let Some(hm) = hm.get_mut(&id_string) { + hm.insert(playtime.time, playtime.total); + } + } + + Ok(HttpResponse::Ok().json(hm)) +} + +/// Get view data for a set of projects or versions +/// Data is returned as a hashmap of project/version ids to a hashmap of days to views +/// eg: +/// { +/// "4N1tEhnO": { +/// "20230824": 1090 +/// } +///} +/// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. +pub async fn views_get( + req: HttpRequest, + clickhouse: web::Data, + data: web::Query, + session_queue: web::Data, + pool: web::Data, + redis: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ANALYTICS]), + ) + .await + .map(|x| x.1)?; + + let project_ids = data + .project_ids + .as_ref() + .map(|ids| serde_json::from_str::>(ids)) + .transpose()?; + + let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); + let end_date = data.end_date.unwrap_or(Utc::now()); + let resolution_minutes = data.resolution_minutes.unwrap_or(60 * 24); + + // Convert String list to list of ProjectIds or VersionIds + // - Filter out unauthorized projects/versions + // - If no project_ids or version_ids are provided, we default to all projects the user has access to + let project_ids = + filter_allowed_ids(project_ids, user, &pool, &redis, None).await?; + + // Get the views + let views = crate::clickhouse::fetch_views( + project_ids.unwrap_or_default(), + start_date, + end_date, + resolution_minutes, + clickhouse.into_inner(), + ) + .await?; + + let mut hm = HashMap::new(); + for views in views { + let id_string = to_base62(views.id); + if !hm.contains_key(&id_string) { + hm.insert(id_string.clone(), HashMap::new()); + } + if let Some(hm) = hm.get_mut(&id_string) { + hm.insert(views.time, views.total); + } + } + + Ok(HttpResponse::Ok().json(hm)) +} + +/// Get download data for a set of projects or versions +/// Data is returned as a hashmap of project/version ids to a hashmap of days to downloads +/// eg: +/// { +/// "4N1tEhnO": { +/// "20230824": 32 +/// } +///} +/// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. +pub async fn downloads_get( + req: HttpRequest, + clickhouse: web::Data, + data: web::Query, + session_queue: web::Data, + pool: web::Data, + redis: web::Data, +) -> Result { + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ANALYTICS]), + ) + .await + .map(|x| x.1)?; + + let project_ids = data + .project_ids + .as_ref() + .map(|ids| serde_json::from_str::>(ids)) + .transpose()?; + + let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); + let end_date = data.end_date.unwrap_or(Utc::now()); + let resolution_minutes = data.resolution_minutes.unwrap_or(60 * 24); + + // Convert String list to list of ProjectIds or VersionIds + // - Filter out unauthorized projects/versions + // - If no project_ids or version_ids are provided, we default to all projects the user has access to + let project_ids = + filter_allowed_ids(project_ids, user_option, &pool, &redis, None) + .await?; + + // Get the downloads + let downloads = crate::clickhouse::fetch_downloads( + project_ids.unwrap_or_default(), + start_date, + end_date, + resolution_minutes, + clickhouse.into_inner(), + ) + .await?; + + let mut hm = HashMap::new(); + for downloads in downloads { + let id_string = to_base62(downloads.id); + if !hm.contains_key(&id_string) { + hm.insert(id_string.clone(), HashMap::new()); + } + if let Some(hm) = hm.get_mut(&id_string) { + hm.insert(downloads.time, downloads.total); + } + } + + Ok(HttpResponse::Ok().json(hm)) +} + +/// Get payout data for a set of projects +/// Data is returned as a hashmap of project ids to a hashmap of days to amount earned per day +/// eg: +/// { +/// "4N1tEhnO": { +/// "20230824": 0.001 +/// } +///} +/// ONLY project IDs can be used. Unauthorized projects will be filtered out. +pub async fn revenue_get( + req: HttpRequest, + data: web::Query, + session_queue: web::Data, + pool: web::Data, + redis: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PAYOUTS_READ]), + ) + .await + .map(|x| x.1)?; + + let project_ids = data + .project_ids + .as_ref() + .map(|ids| serde_json::from_str::>(ids)) + .transpose()?; + + let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); + let end_date = data.end_date.unwrap_or(Utc::now()); + let resolution_minutes = data.resolution_minutes.unwrap_or(60 * 24); + + // Round up/down to nearest duration as we are using pgadmin, does not have rounding in the fetch command + // Round start_date down to nearest resolution + let diff = start_date.timestamp() % (resolution_minutes as i64 * 60); + let start_date = start_date - Duration::seconds(diff); + + // Round end_date up to nearest resolution + let diff = end_date.timestamp() % (resolution_minutes as i64 * 60); + let end_date = + end_date + Duration::seconds((resolution_minutes as i64 * 60) - diff); + + // Convert String list to list of ProjectIds or VersionIds + // - Filter out unauthorized projects/versions + // - If no project_ids or version_ids are provided, we default to all projects the user has access to + let project_ids = filter_allowed_ids( + project_ids, + user.clone(), + &pool, + &redis, + Some(true), + ) + .await?; + + let duration: PgInterval = Duration::minutes(resolution_minutes as i64) + .try_into() + .map_err(|_| { + ApiError::InvalidInput("Invalid resolution_minutes".to_string()) + })?; + // Get the revenue data + let project_ids = project_ids.unwrap_or_default(); + + struct PayoutValue { + mod_id: Option, + amount_sum: Option, + interval_start: Option>, + } + + let payouts_values = if project_ids.is_empty() { + sqlx::query!( + " + SELECT mod_id, SUM(amount) amount_sum, DATE_BIN($4::interval, created, TIMESTAMP '2001-01-01') AS interval_start + FROM payouts_values + WHERE user_id = $1 AND created BETWEEN $2 AND $3 + GROUP by mod_id, interval_start ORDER BY interval_start + ", + user.id.0 as i64, + start_date, + end_date, + duration, + ) + .fetch_all(&**pool) + .await?.into_iter().map(|x| PayoutValue { + mod_id: x.mod_id, + amount_sum: x.amount_sum, + interval_start: x.interval_start, + }).collect::>() + } else { + sqlx::query!( + " + SELECT mod_id, SUM(amount) amount_sum, DATE_BIN($4::interval, created, TIMESTAMP '2001-01-01') AS interval_start + FROM payouts_values + WHERE mod_id = ANY($1) AND created BETWEEN $2 AND $3 + GROUP by mod_id, interval_start ORDER BY interval_start + ", + &project_ids.iter().map(|x| x.0 as i64).collect::>(), + start_date, + end_date, + duration, + ) + .fetch_all(&**pool) + .await?.into_iter().map(|x| PayoutValue { + mod_id: x.mod_id, + amount_sum: x.amount_sum, + interval_start: x.interval_start, + }).collect::>() + }; + + let mut hm: HashMap<_, _> = project_ids + .into_iter() + .map(|x| (x.to_string(), HashMap::new())) + .collect::>(); + for value in payouts_values { + if let Some(mod_id) = value.mod_id { + if let Some(amount) = value.amount_sum { + if let Some(interval_start) = value.interval_start { + let id_string = to_base62(mod_id as u64); + if !hm.contains_key(&id_string) { + hm.insert(id_string.clone(), HashMap::new()); + } + if let Some(hm) = hm.get_mut(&id_string) { + hm.insert(interval_start.timestamp(), amount); + } + } + } + } + } + + Ok(HttpResponse::Ok().json(hm)) +} + +/// Get country data for a set of projects or versions +/// Data is returned as a hashmap of project/version ids to a hashmap of coutnry to downloads. +/// Unknown countries are labeled "". +/// This is usuable to see significant performing countries per project +/// eg: +/// { +/// "4N1tEhnO": { +/// "CAN": 22 +/// } +///} +/// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. +/// For this endpoint, provided dates are a range to aggregate over, not specific days to fetch +pub async fn countries_downloads_get( + req: HttpRequest, + clickhouse: web::Data, + data: web::Query, + session_queue: web::Data, + pool: web::Data, + redis: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ANALYTICS]), + ) + .await + .map(|x| x.1)?; + + let project_ids = data + .project_ids + .as_ref() + .map(|ids| serde_json::from_str::>(ids)) + .transpose()?; + + let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); + let end_date = data.end_date.unwrap_or(Utc::now()); + + // Convert String list to list of ProjectIds or VersionIds + // - Filter out unauthorized projects/versions + // - If no project_ids or version_ids are provided, we default to all projects the user has access to + let project_ids = + filter_allowed_ids(project_ids, user, &pool, &redis, None).await?; + + // Get the countries + let countries = crate::clickhouse::fetch_countries_downloads( + project_ids.unwrap_or_default(), + start_date, + end_date, + clickhouse.into_inner(), + ) + .await?; + + let mut hm = HashMap::new(); + for views in countries { + let id_string = to_base62(views.id); + if !hm.contains_key(&id_string) { + hm.insert(id_string.clone(), HashMap::new()); + } + if let Some(hm) = hm.get_mut(&id_string) { + hm.insert(views.country, views.total); + } + } + + let hm: HashMap> = hm + .into_iter() + .map(|(key, value)| (key, condense_countries(value))) + .collect(); + + Ok(HttpResponse::Ok().json(hm)) +} + +/// Get country data for a set of projects or versions +/// Data is returned as a hashmap of project/version ids to a hashmap of coutnry to views. +/// Unknown countries are labeled "". +/// This is usuable to see significant performing countries per project +/// eg: +/// { +/// "4N1tEhnO": { +/// "CAN": 56165 +/// } +///} +/// Either a list of project_ids or version_ids can be used, but not both. Unauthorized projects/versions will be filtered out. +/// For this endpoint, provided dates are a range to aggregate over, not specific days to fetch +pub async fn countries_views_get( + req: HttpRequest, + clickhouse: web::Data, + data: web::Query, + session_queue: web::Data, + pool: web::Data, + redis: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ANALYTICS]), + ) + .await + .map(|x| x.1)?; + + let project_ids = data + .project_ids + .as_ref() + .map(|ids| serde_json::from_str::>(ids)) + .transpose()?; + + let start_date = data.start_date.unwrap_or(Utc::now() - Duration::weeks(2)); + let end_date = data.end_date.unwrap_or(Utc::now()); + + // Convert String list to list of ProjectIds or VersionIds + // - Filter out unauthorized projects/versions + // - If no project_ids or version_ids are provided, we default to all projects the user has access to + let project_ids = + filter_allowed_ids(project_ids, user, &pool, &redis, None).await?; + + // Get the countries + let countries = crate::clickhouse::fetch_countries_views( + project_ids.unwrap_or_default(), + start_date, + end_date, + clickhouse.into_inner(), + ) + .await?; + + let mut hm = HashMap::new(); + for views in countries { + let id_string = to_base62(views.id); + if !hm.contains_key(&id_string) { + hm.insert(id_string.clone(), HashMap::new()); + } + if let Some(hm) = hm.get_mut(&id_string) { + hm.insert(views.country, views.total); + } + } + + let hm: HashMap> = hm + .into_iter() + .map(|(key, value)| (key, condense_countries(value))) + .collect(); + + Ok(HttpResponse::Ok().json(hm)) +} + +fn condense_countries(countries: HashMap) -> HashMap { + // Every country under '15' (view or downloads) should be condensed into 'XX' + let mut hm = HashMap::new(); + for (mut country, count) in countries { + if count < 50 { + country = "XX".to_string(); + } + if !hm.contains_key(&country) { + hm.insert(country.to_string(), 0); + } + if let Some(hm) = hm.get_mut(&country) { + *hm += count; + } + } + hm +} + +async fn filter_allowed_ids( + mut project_ids: Option>, + user: crate::models::users::User, + pool: &web::Data, + redis: &RedisPool, + remove_defaults: Option, +) -> Result>, ApiError> { + // If no project_ids or version_ids are provided, we default to all projects the user has *public* access to + if project_ids.is_none() && !remove_defaults.unwrap_or(false) { + project_ids = Some( + user_item::User::get_projects(user.id.into(), &***pool, redis) + .await? + .into_iter() + .map(|x| ProjectId::from(x).to_string()) + .collect(), + ); + } + + // Convert String list to list of ProjectIds or VersionIds + // - Filter out unauthorized projects/versions + let project_ids = if let Some(project_strings) = project_ids { + let projects_data = database::models::Project::get_many( + &project_strings, + &***pool, + redis, + ) + .await?; + + let team_ids = projects_data + .iter() + .map(|x| x.inner.team_id) + .collect::>(); + let team_members = + database::models::TeamMember::get_from_team_full_many( + &team_ids, &***pool, redis, + ) + .await?; + + let organization_ids = projects_data + .iter() + .filter_map(|x| x.inner.organization_id) + .collect::>(); + let organizations = database::models::Organization::get_many_ids( + &organization_ids, + &***pool, + redis, + ) + .await?; + + let organization_team_ids = organizations + .iter() + .map(|x| x.team_id) + .collect::>(); + let organization_team_members = + database::models::TeamMember::get_from_team_full_many( + &organization_team_ids, + &***pool, + redis, + ) + .await?; + + let ids = projects_data + .into_iter() + .filter(|project| { + let team_member = team_members.iter().find(|x| { + x.team_id == project.inner.team_id + && x.user_id == user.id.into() + }); + + let organization = project + .inner + .organization_id + .and_then(|oid| organizations.iter().find(|x| x.id == oid)); + + let organization_team_member = + if let Some(organization) = organization { + organization_team_members.iter().find(|x| { + x.team_id == organization.team_id + && x.user_id == user.id.into() + }) + } else { + None + }; + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member.cloned(), + &organization_team_member.cloned(), + ) + .unwrap_or_default(); + + permissions.contains(ProjectPermissions::VIEW_ANALYTICS) + }) + .map(|x| x.inner.id.into()) + .collect::>(); + + Some(ids) + } else { + None + }; + // Only one of project_ids or version_ids will be Some + Ok(project_ids) +} diff --git a/apps/labrinth/src/routes/v3/collections.rs b/apps/labrinth/src/routes/v3/collections.rs new file mode 100644 index 000000000..6a9f19e39 --- /dev/null +++ b/apps/labrinth/src/routes/v3/collections.rs @@ -0,0 +1,570 @@ +use crate::auth::checks::is_visible_collection; +use crate::auth::{filter_visible_collections, get_user_from_headers}; +use crate::database::models::{ + collection_item, generate_collection_id, project_item, +}; +use crate::database::redis::RedisPool; +use crate::file_hosting::FileHost; +use crate::models::collections::{Collection, CollectionStatus}; +use crate::models::ids::base62_impl::parse_base62; +use crate::models::ids::{CollectionId, ProjectId}; +use crate::models::pats::Scopes; +use crate::queue::session::AuthQueue; +use crate::routes::v3::project_creation::CreateError; +use crate::routes::ApiError; +use crate::util::img::delete_old_images; +use crate::util::routes::read_from_payload; +use crate::util::validate::validation_errors_to_string; +use crate::{database, models}; +use actix_web::web::Data; +use actix_web::{web, HttpRequest, HttpResponse}; +use chrono::Utc; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use std::sync::Arc; +use validator::Validate; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.route("collections", web::get().to(collections_get)); + cfg.route("collection", web::post().to(collection_create)); + + cfg.service( + web::scope("collection") + .route("{id}", web::get().to(collection_get)) + .route("{id}", web::delete().to(collection_delete)) + .route("{id}", web::patch().to(collection_edit)) + .route("{id}/icon", web::patch().to(collection_icon_edit)) + .route("{id}/icon", web::delete().to(delete_collection_icon)), + ); +} + +#[derive(Serialize, Deserialize, Validate, Clone)] +pub struct CollectionCreateData { + #[validate( + length(min = 3, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + /// The title or name of the project. + pub name: String, + #[validate(length(min = 3, max = 255))] + /// A short description of the collection. + pub description: Option, + #[validate(length(max = 32))] + #[serde(default = "Vec::new")] + /// A list of initial projects to use with the created collection + pub projects: Vec, +} + +pub async fn collection_create( + req: HttpRequest, + collection_create_data: web::Json, + client: Data, + redis: Data, + session_queue: Data, +) -> Result { + let collection_create_data = collection_create_data.into_inner(); + + // The currently logged in user + let current_user = get_user_from_headers( + &req, + &**client, + &redis, + &session_queue, + Some(&[Scopes::COLLECTION_CREATE]), + ) + .await? + .1; + + collection_create_data.validate().map_err(|err| { + CreateError::InvalidInput(validation_errors_to_string(err, None)) + })?; + + let mut transaction = client.begin().await?; + + let collection_id: CollectionId = + generate_collection_id(&mut transaction).await?.into(); + + let initial_project_ids = project_item::Project::get_many( + &collection_create_data.projects, + &mut *transaction, + &redis, + ) + .await? + .into_iter() + .map(|x| x.inner.id.into()) + .collect::>(); + + let collection_builder_actual = collection_item::CollectionBuilder { + collection_id: collection_id.into(), + user_id: current_user.id.into(), + name: collection_create_data.name, + description: collection_create_data.description, + status: CollectionStatus::Listed, + projects: initial_project_ids + .iter() + .copied() + .map(|x| x.into()) + .collect(), + }; + let collection_builder = collection_builder_actual.clone(); + + let now = Utc::now(); + collection_builder_actual.insert(&mut transaction).await?; + + let response = crate::models::collections::Collection { + id: collection_id, + user: collection_builder.user_id.into(), + name: collection_builder.name.clone(), + description: collection_builder.description.clone(), + created: now, + updated: now, + icon_url: None, + color: None, + status: collection_builder.status, + projects: initial_project_ids, + }; + transaction.commit().await?; + + Ok(HttpResponse::Ok().json(response)) +} + +#[derive(Serialize, Deserialize)] +pub struct CollectionIds { + pub ids: String, +} +pub async fn collections_get( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let ids = serde_json::from_str::>(&ids.ids)?; + let ids = ids + .into_iter() + .map(|x| { + parse_base62(x).map(|x| database::models::CollectionId(x as i64)) + }) + .collect::, _>>()?; + + let collections_data = + database::models::Collection::get_many(&ids, &**pool, &redis).await?; + + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::COLLECTION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let collections = + filter_visible_collections(collections_data, &user_option).await?; + + Ok(HttpResponse::Ok().json(collections)) +} + +pub async fn collection_get( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let string = info.into_inner().0; + + let id = database::models::CollectionId(parse_base62(&string)? as i64); + let collection_data = + database::models::Collection::get(id, &**pool, &redis).await?; + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::COLLECTION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + if let Some(data) = collection_data { + if is_visible_collection(&data, &user_option).await? { + return Ok(HttpResponse::Ok().json(Collection::from(data))); + } + } + Err(ApiError::NotFound) +} + +#[derive(Deserialize, Validate)] +pub struct EditCollection { + #[validate( + length(min = 3, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + pub name: Option, + #[validate(length(min = 3, max = 256))] + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + pub description: Option>, + pub status: Option, + #[validate(length(max = 1024))] + pub new_projects: Option>, +} + +pub async fn collection_edit( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + new_collection: web::Json, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::COLLECTION_WRITE]), + ) + .await? + .1; + + new_collection.validate().map_err(|err| { + ApiError::Validation(validation_errors_to_string(err, None)) + })?; + + let string = info.into_inner().0; + let id = database::models::CollectionId(parse_base62(&string)? as i64); + let result = database::models::Collection::get(id, &**pool, &redis).await?; + + if let Some(collection_item) = result { + if !can_modify_collection(&collection_item, &user) { + return Ok(HttpResponse::Unauthorized().body("")); + } + + let id = collection_item.id; + + let mut transaction = pool.begin().await?; + + if let Some(name) = &new_collection.name { + sqlx::query!( + " + UPDATE collections + SET name = $1 + WHERE (id = $2) + ", + name.trim(), + id as database::models::ids::CollectionId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(description) = &new_collection.description { + sqlx::query!( + " + UPDATE collections + SET description = $1 + WHERE (id = $2) + ", + description.as_ref(), + id as database::models::ids::CollectionId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(status) = &new_collection.status { + if !(user.role.is_mod() + || collection_item.status.is_approved() + && status.can_be_requested()) + { + return Err(ApiError::CustomAuthentication( + "You don't have permission to set this status!".to_string(), + )); + } + + sqlx::query!( + " + UPDATE collections + SET status = $1 + WHERE (id = $2) + ", + status.to_string(), + id as database::models::ids::CollectionId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(new_project_ids) = &new_collection.new_projects { + // Delete all existing projects + sqlx::query!( + " + DELETE FROM collections_mods + WHERE collection_id = $1 + ", + collection_item.id as database::models::ids::CollectionId, + ) + .execute(&mut *transaction) + .await?; + + let collection_item_ids = new_project_ids + .iter() + .map(|_| collection_item.id.0) + .collect_vec(); + let mut validated_project_ids = Vec::new(); + for project_id in new_project_ids { + let project = + database::models::Project::get(project_id, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput(format!( + "The specified project {project_id} does not exist!" + )) + })?; + validated_project_ids.push(project.inner.id.0); + } + // Insert- don't throw an error if it already exists + sqlx::query!( + " + INSERT INTO collections_mods (collection_id, mod_id) + SELECT * FROM UNNEST ($1::int8[], $2::int8[]) + ON CONFLICT DO NOTHING + ", + &collection_item_ids[..], + &validated_project_ids[..], + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + " + UPDATE collections + SET updated = NOW() + WHERE id = $1 + ", + collection_item.id as database::models::ids::CollectionId, + ) + .execute(&mut *transaction) + .await?; + } + + transaction.commit().await?; + database::models::Collection::clear_cache(collection_item.id, &redis) + .await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::NotFound) + } +} + +#[derive(Serialize, Deserialize)] +pub struct Extension { + pub ext: String, +} + +#[allow(clippy::too_many_arguments)] +pub async fn collection_icon_edit( + web::Query(ext): web::Query, + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + mut payload: web::Payload, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::COLLECTION_WRITE]), + ) + .await? + .1; + + let string = info.into_inner().0; + let id = database::models::CollectionId(parse_base62(&string)? as i64); + let collection_item = + database::models::Collection::get(id, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The specified collection does not exist!".to_string(), + ) + })?; + + if !can_modify_collection(&collection_item, &user) { + return Ok(HttpResponse::Unauthorized().body("")); + } + + delete_old_images( + collection_item.icon_url, + collection_item.raw_icon_url, + &***file_host, + ) + .await?; + + let bytes = read_from_payload( + &mut payload, + 262144, + "Icons must be smaller than 256KiB", + ) + .await?; + + let collection_id: CollectionId = collection_item.id.into(); + let upload_result = crate::util::img::upload_image_optimized( + &format!("data/{}", collection_id), + bytes.freeze(), + &ext.ext, + Some(96), + Some(1.0), + &***file_host, + ) + .await?; + + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE collections + SET icon_url = $1, raw_icon_url = $2, color = $3 + WHERE (id = $4) + ", + upload_result.url, + upload_result.raw_url, + upload_result.color.map(|x| x as i32), + collection_item.id as database::models::ids::CollectionId, + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + database::models::Collection::clear_cache(collection_item.id, &redis) + .await?; + + Ok(HttpResponse::NoContent().body("")) +} + +pub async fn delete_collection_icon( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::COLLECTION_WRITE]), + ) + .await? + .1; + + let string = info.into_inner().0; + let id = database::models::CollectionId(parse_base62(&string)? as i64); + let collection_item = + database::models::Collection::get(id, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The specified collection does not exist!".to_string(), + ) + })?; + if !can_modify_collection(&collection_item, &user) { + return Ok(HttpResponse::Unauthorized().body("")); + } + + delete_old_images( + collection_item.icon_url, + collection_item.raw_icon_url, + &***file_host, + ) + .await?; + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE collections + SET icon_url = NULL, raw_icon_url = NULL, color = NULL + WHERE (id = $1) + ", + collection_item.id as database::models::ids::CollectionId, + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + database::models::Collection::clear_cache(collection_item.id, &redis) + .await?; + + Ok(HttpResponse::NoContent().body("")) +} + +pub async fn collection_delete( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::COLLECTION_DELETE]), + ) + .await? + .1; + + let string = info.into_inner().0; + let id = database::models::CollectionId(parse_base62(&string)? as i64); + let collection = database::models::Collection::get(id, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The specified collection does not exist!".to_string(), + ) + })?; + if !can_modify_collection(&collection, &user) { + return Ok(HttpResponse::Unauthorized().body("")); + } + let mut transaction = pool.begin().await?; + + let result = database::models::Collection::remove( + collection.id, + &mut transaction, + &redis, + ) + .await?; + + transaction.commit().await?; + database::models::Collection::clear_cache(collection.id, &redis).await?; + + if result.is_some() { + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::NotFound) + } +} + +fn can_modify_collection( + collection: &database::models::Collection, + user: &models::users::User, +) -> bool { + collection.user_id == user.id.into() || user.role.is_mod() +} diff --git a/apps/labrinth/src/routes/v3/images.rs b/apps/labrinth/src/routes/v3/images.rs new file mode 100644 index 000000000..a1c2c841c --- /dev/null +++ b/apps/labrinth/src/routes/v3/images.rs @@ -0,0 +1,259 @@ +use std::sync::Arc; + +use super::threads::is_authorized_thread; +use crate::auth::checks::{is_team_member_project, is_team_member_version}; +use crate::auth::get_user_from_headers; +use crate::database; +use crate::database::models::{ + project_item, report_item, thread_item, version_item, +}; +use crate::database::redis::RedisPool; +use crate::file_hosting::FileHost; +use crate::models::ids::{ThreadMessageId, VersionId}; +use crate::models::images::{Image, ImageContext}; +use crate::models::reports::ReportId; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use crate::util::img::upload_image_optimized; +use crate::util::routes::read_from_payload; +use actix_web::{web, HttpRequest, HttpResponse}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.route("image", web::post().to(images_add)); +} + +#[derive(Serialize, Deserialize)] +pub struct ImageUpload { + pub ext: String, + + // Context must be an allowed context + // currently: project, version, thread_message, report + pub context: String, + + // Optional context id to associate with + pub project_id: Option, // allow slug or id + pub version_id: Option, + pub thread_message_id: Option, + pub report_id: Option, +} + +pub async fn images_add( + req: HttpRequest, + web::Query(data): web::Query, + file_host: web::Data>, + mut payload: web::Payload, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let mut context = ImageContext::from_str(&data.context, None); + + let scopes = vec![context.relevant_scope()]; + + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&scopes), + ) + .await? + .1; + + // Attempt to associated a supplied id with the context + // If the context cannot be found, or the user is not authorized to upload images for the context, return an error + match &mut context { + ImageContext::Project { project_id } => { + if let Some(id) = data.project_id { + let project = + project_item::Project::get(&id, &**pool, &redis).await?; + if let Some(project) = project { + if is_team_member_project( + &project.inner, + &Some(user.clone()), + &pool, + ) + .await? + { + *project_id = Some(project.inner.id.into()); + } else { + return Err(ApiError::CustomAuthentication( + "You are not authorized to upload images for this project".to_string(), + )); + } + } else { + return Err(ApiError::InvalidInput( + "The project could not be found.".to_string(), + )); + } + } + } + ImageContext::Version { version_id } => { + if let Some(id) = data.version_id { + let version = + version_item::Version::get(id.into(), &**pool, &redis) + .await?; + if let Some(version) = version { + if is_team_member_version( + &version.inner, + &Some(user.clone()), + &pool, + &redis, + ) + .await? + { + *version_id = Some(version.inner.id.into()); + } else { + return Err(ApiError::CustomAuthentication( + "You are not authorized to upload images for this version".to_string(), + )); + } + } else { + return Err(ApiError::InvalidInput( + "The version could not be found.".to_string(), + )); + } + } + } + ImageContext::ThreadMessage { thread_message_id } => { + if let Some(id) = data.thread_message_id { + let thread_message = + thread_item::ThreadMessage::get(id.into(), &**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The thread message could not found." + .to_string(), + ) + })?; + let thread = thread_item::Thread::get(thread_message.thread_id, &**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The thread associated with the thread message could not be found" + .to_string(), + ) + })?; + if is_authorized_thread(&thread, &user, &pool).await? { + *thread_message_id = Some(thread_message.id.into()); + } else { + return Err(ApiError::CustomAuthentication( + "You are not authorized to upload images for this thread message" + .to_string(), + )); + } + } + } + ImageContext::Report { report_id } => { + if let Some(id) = data.report_id { + let report = report_item::Report::get(id.into(), &**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The report could not be found.".to_string(), + ) + })?; + let thread = thread_item::Thread::get(report.thread_id, &**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The thread associated with the report could not be found.".to_string(), + ) + })?; + if is_authorized_thread(&thread, &user, &pool).await? { + *report_id = Some(report.id.into()); + } else { + return Err(ApiError::CustomAuthentication( + "You are not authorized to upload images for this report".to_string(), + )); + } + } + } + ImageContext::Unknown => { + return Err(ApiError::InvalidInput( + "Context must be one of: project, version, thread_message, report".to_string(), + )); + } + } + + // Upload the image to the file host + let bytes = read_from_payload( + &mut payload, + 1_048_576, + "Icons must be smaller than 1MiB", + ) + .await?; + + let content_length = bytes.len(); + let upload_result = upload_image_optimized( + "data/cached_images", + bytes.freeze(), + &data.ext, + None, + None, + &***file_host, + ) + .await?; + + let mut transaction = pool.begin().await?; + + let db_image: database::models::Image = database::models::Image { + id: database::models::generate_image_id(&mut transaction).await?, + url: upload_result.url, + raw_url: upload_result.raw_url, + size: content_length as u64, + created: chrono::Utc::now(), + owner_id: database::models::UserId::from(user.id), + context: context.context_as_str().to_string(), + project_id: if let ImageContext::Project { + project_id: Some(id), + } = context + { + Some(crate::database::models::ProjectId::from(id)) + } else { + None + }, + version_id: if let ImageContext::Version { + version_id: Some(id), + } = context + { + Some(database::models::VersionId::from(id)) + } else { + None + }, + thread_message_id: if let ImageContext::ThreadMessage { + thread_message_id: Some(id), + } = context + { + Some(database::models::ThreadMessageId::from(id)) + } else { + None + }, + report_id: if let ImageContext::Report { + report_id: Some(id), + } = context + { + Some(database::models::ReportId::from(id)) + } else { + None + }, + }; + + // Insert + db_image.insert(&mut transaction).await?; + + let image = Image { + id: db_image.id.into(), + url: db_image.url, + size: db_image.size, + created: db_image.created, + owner_id: db_image.owner_id.into(), + context, + }; + + transaction.commit().await?; + + Ok(HttpResponse::Ok().json(image)) +} diff --git a/apps/labrinth/src/routes/v3/mod.rs b/apps/labrinth/src/routes/v3/mod.rs new file mode 100644 index 000000000..23a92a00b --- /dev/null +++ b/apps/labrinth/src/routes/v3/mod.rs @@ -0,0 +1,53 @@ +pub use super::ApiError; +use crate::util::cors::default_cors; +use actix_web::{web, HttpResponse}; +use serde_json::json; + +pub mod analytics_get; +pub mod collections; +pub mod images; +pub mod notifications; +pub mod organizations; +pub mod payouts; +pub mod project_creation; +pub mod projects; +pub mod reports; +pub mod statistics; +pub mod tags; +pub mod teams; +pub mod threads; +pub mod users; +pub mod version_creation; +pub mod version_file; +pub mod versions; + +pub mod oauth_clients; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("v3") + .wrap(default_cors()) + .configure(analytics_get::config) + .configure(collections::config) + .configure(images::config) + .configure(notifications::config) + .configure(organizations::config) + .configure(project_creation::config) + .configure(projects::config) + .configure(reports::config) + .configure(statistics::config) + .configure(tags::config) + .configure(teams::config) + .configure(threads::config) + .configure(users::config) + .configure(version_file::config) + .configure(payouts::config) + .configure(versions::config), + ); +} + +pub async fn hello_world() -> Result { + Ok(HttpResponse::Ok().json(json!({ + "hello": "world", + }))) +} diff --git a/apps/labrinth/src/routes/v3/notifications.rs b/apps/labrinth/src/routes/v3/notifications.rs new file mode 100644 index 000000000..abff2e587 --- /dev/null +++ b/apps/labrinth/src/routes/v3/notifications.rs @@ -0,0 +1,315 @@ +use crate::auth::get_user_from_headers; +use crate::database; +use crate::database::redis::RedisPool; +use crate::models::ids::NotificationId; +use crate::models::notifications::Notification; +use crate::models::pats::Scopes; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use actix_web::{web, HttpRequest, HttpResponse}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.route("notifications", web::get().to(notifications_get)); + cfg.route("notifications", web::patch().to(notifications_read)); + cfg.route("notifications", web::delete().to(notifications_delete)); + + cfg.service( + web::scope("notification") + .route("{id}", web::get().to(notification_get)) + .route("{id}", web::patch().to(notification_read)) + .route("{id}", web::delete().to(notification_delete)), + ); +} + +#[derive(Serialize, Deserialize)] +pub struct NotificationIds { + pub ids: String, +} + +pub async fn notifications_get( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::NOTIFICATION_READ]), + ) + .await? + .1; + + use database::models::notification_item::Notification as DBNotification; + use database::models::NotificationId as DBNotificationId; + + let notification_ids: Vec = + serde_json::from_str::>(ids.ids.as_str())? + .into_iter() + .map(DBNotificationId::from) + .collect(); + + let notifications_data: Vec = + database::models::notification_item::Notification::get_many( + ¬ification_ids, + &**pool, + ) + .await?; + + let notifications: Vec = notifications_data + .into_iter() + .filter(|n| n.user_id == user.id.into() || user.role.is_admin()) + .map(Notification::from) + .collect(); + + Ok(HttpResponse::Ok().json(notifications)) +} + +pub async fn notification_get( + req: HttpRequest, + info: web::Path<(NotificationId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::NOTIFICATION_READ]), + ) + .await? + .1; + + let id = info.into_inner().0; + + let notification_data = + database::models::notification_item::Notification::get( + id.into(), + &**pool, + ) + .await?; + + if let Some(data) = notification_data { + if user.id == data.user_id.into() || user.role.is_admin() { + Ok(HttpResponse::Ok().json(Notification::from(data))) + } else { + Err(ApiError::NotFound) + } + } else { + Err(ApiError::NotFound) + } +} + +pub async fn notification_read( + req: HttpRequest, + info: web::Path<(NotificationId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::NOTIFICATION_WRITE]), + ) + .await? + .1; + + let id = info.into_inner().0; + + let notification_data = + database::models::notification_item::Notification::get( + id.into(), + &**pool, + ) + .await?; + + if let Some(data) = notification_data { + if data.user_id == user.id.into() || user.role.is_admin() { + let mut transaction = pool.begin().await?; + + database::models::notification_item::Notification::read( + id.into(), + &mut transaction, + &redis, + ) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::CustomAuthentication( + "You are not authorized to read this notification!".to_string(), + )) + } + } else { + Err(ApiError::NotFound) + } +} + +pub async fn notification_delete( + req: HttpRequest, + info: web::Path<(NotificationId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::NOTIFICATION_WRITE]), + ) + .await? + .1; + + let id = info.into_inner().0; + + let notification_data = + database::models::notification_item::Notification::get( + id.into(), + &**pool, + ) + .await?; + + if let Some(data) = notification_data { + if data.user_id == user.id.into() || user.role.is_admin() { + let mut transaction = pool.begin().await?; + + database::models::notification_item::Notification::remove( + id.into(), + &mut transaction, + &redis, + ) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::CustomAuthentication( + "You are not authorized to delete this notification!" + .to_string(), + )) + } + } else { + Err(ApiError::NotFound) + } +} + +pub async fn notifications_read( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::NOTIFICATION_WRITE]), + ) + .await? + .1; + + let notification_ids = + serde_json::from_str::>(&ids.ids)? + .into_iter() + .map(|x| x.into()) + .collect::>(); + + let mut transaction = pool.begin().await?; + + let notifications_data = + database::models::notification_item::Notification::get_many( + ¬ification_ids, + &**pool, + ) + .await?; + + let mut notifications: Vec = + Vec::new(); + + for notification in notifications_data { + if notification.user_id == user.id.into() || user.role.is_admin() { + notifications.push(notification.id); + } + } + + database::models::notification_item::Notification::read_many( + ¬ifications, + &mut transaction, + &redis, + ) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) +} + +pub async fn notifications_delete( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::NOTIFICATION_WRITE]), + ) + .await? + .1; + + let notification_ids = + serde_json::from_str::>(&ids.ids)? + .into_iter() + .map(|x| x.into()) + .collect::>(); + + let mut transaction = pool.begin().await?; + + let notifications_data = + database::models::notification_item::Notification::get_many( + ¬ification_ids, + &**pool, + ) + .await?; + + let mut notifications: Vec = + Vec::new(); + + for notification in notifications_data { + if notification.user_id == user.id.into() || user.role.is_admin() { + notifications.push(notification.id); + } + } + + database::models::notification_item::Notification::remove_many( + ¬ifications, + &mut transaction, + &redis, + ) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) +} diff --git a/apps/labrinth/src/routes/v3/oauth_clients.rs b/apps/labrinth/src/routes/v3/oauth_clients.rs new file mode 100644 index 000000000..a65dcc75d --- /dev/null +++ b/apps/labrinth/src/routes/v3/oauth_clients.rs @@ -0,0 +1,596 @@ +use std::{collections::HashSet, fmt::Display, sync::Arc}; + +use actix_web::{ + delete, get, patch, post, + web::{self, scope}, + HttpRequest, HttpResponse, +}; +use chrono::Utc; +use itertools::Itertools; +use rand::{distributions::Alphanumeric, Rng, SeedableRng}; +use rand_chacha::ChaCha20Rng; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use validator::Validate; + +use super::ApiError; +use crate::{ + auth::{checks::ValidateAuthorized, get_user_from_headers}, + database::{ + models::{ + generate_oauth_client_id, generate_oauth_redirect_id, + oauth_client_authorization_item::OAuthClientAuthorization, + oauth_client_item::{OAuthClient, OAuthRedirectUri}, + DatabaseError, OAuthClientId, User, + }, + redis::RedisPool, + }, + models::{ + self, + oauth_clients::{GetOAuthClientsRequest, OAuthClientCreationResult}, + pats::Scopes, + }, + queue::session::AuthQueue, + routes::v3::project_creation::CreateError, + util::validate::validation_errors_to_string, +}; +use crate::{ + file_hosting::FileHost, + models::{ + ids::base62_impl::parse_base62, + oauth_clients::DeleteOAuthClientQueryParam, + }, + util::routes::read_from_payload, +}; + +use crate::database::models::oauth_client_item::OAuthClient as DBOAuthClient; +use crate::models::ids::OAuthClientId as ApiOAuthClientId; +use crate::util::img::{delete_old_images, upload_image_optimized}; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service( + scope("oauth") + .configure(crate::auth::oauth::config) + .service(revoke_oauth_authorization) + .service(oauth_client_create) + .service(oauth_client_edit) + .service(oauth_client_delete) + .service(oauth_client_icon_edit) + .service(oauth_client_icon_delete) + .service(get_client) + .service(get_clients) + .service(get_user_oauth_authorizations), + ); +} + +pub async fn get_user_clients( + req: HttpRequest, + info: web::Path, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::SESSION_ACCESS]), + ) + .await? + .1; + + let target_user = User::get(&info.into_inner(), &**pool, &redis).await?; + + if let Some(target_user) = target_user { + if target_user.id != current_user.id.into() + && !current_user.role.is_admin() + { + return Err(ApiError::CustomAuthentication( + "You do not have permission to see the OAuth clients of this user!".to_string(), + )); + } + + let clients = + OAuthClient::get_all_user_clients(target_user.id, &**pool).await?; + + let response = clients + .into_iter() + .map(models::oauth_clients::OAuthClient::from) + .collect_vec(); + + Ok(HttpResponse::Ok().json(response)) + } else { + Err(ApiError::NotFound) + } +} + +#[get("app/{id}")] +pub async fn get_client( + id: web::Path, + pool: web::Data, +) -> Result { + let clients = get_clients_inner(&[id.into_inner()], pool).await?; + if let Some(client) = clients.into_iter().next() { + Ok(HttpResponse::Ok().json(client)) + } else { + Err(ApiError::NotFound) + } +} + +#[get("apps")] +pub async fn get_clients( + info: web::Query, + pool: web::Data, +) -> Result { + let ids: Vec<_> = info + .ids + .iter() + .map(|id| parse_base62(id).map(ApiOAuthClientId)) + .collect::>()?; + + let clients = get_clients_inner(&ids, pool).await?; + + Ok(HttpResponse::Ok().json(clients)) +} + +#[derive(Deserialize, Validate)] +pub struct NewOAuthApp { + #[validate( + custom(function = "crate::util::validate::validate_name"), + length(min = 3, max = 255) + )] + pub name: String, + + #[validate(custom( + function = "crate::util::validate::validate_no_restricted_scopes" + ))] + pub max_scopes: Scopes, + + pub redirect_uris: Vec, + + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 255) + )] + pub url: Option, + + #[validate(length(max = 255))] + pub description: Option, +} + +#[post("app")] +pub async fn oauth_client_create<'a>( + req: HttpRequest, + new_oauth_app: web::Json, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::SESSION_ACCESS]), + ) + .await? + .1; + + new_oauth_app.validate().map_err(|e| { + CreateError::ValidationError(validation_errors_to_string(e, None)) + })?; + + let mut transaction = pool.begin().await?; + + let client_id = generate_oauth_client_id(&mut transaction).await?; + + let client_secret = generate_oauth_client_secret(); + let client_secret_hash = DBOAuthClient::hash_secret(&client_secret); + + let redirect_uris = create_redirect_uris( + &new_oauth_app.redirect_uris, + client_id, + &mut transaction, + ) + .await?; + + let client = OAuthClient { + id: client_id, + icon_url: None, + raw_icon_url: None, + max_scopes: new_oauth_app.max_scopes, + name: new_oauth_app.name.clone(), + redirect_uris, + created: Utc::now(), + created_by: current_user.id.into(), + url: new_oauth_app.url.clone(), + description: new_oauth_app.description.clone(), + secret_hash: client_secret_hash, + }; + client.clone().insert(&mut transaction).await?; + + transaction.commit().await?; + + let client = models::oauth_clients::OAuthClient::from(client); + + Ok(HttpResponse::Ok().json(OAuthClientCreationResult { + client, + client_secret, + })) +} + +#[delete("app/{id}")] +pub async fn oauth_client_delete<'a>( + req: HttpRequest, + client_id: web::Path, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::SESSION_ACCESS]), + ) + .await? + .1; + + let client = + OAuthClient::get(client_id.into_inner().into(), &**pool).await?; + if let Some(client) = client { + client.validate_authorized(Some(¤t_user))?; + OAuthClient::remove(client.id, &**pool).await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::NotFound) + } +} + +#[derive(Serialize, Deserialize, Validate)] +pub struct OAuthClientEdit { + #[validate( + custom(function = "crate::util::validate::validate_name"), + length(min = 3, max = 255) + )] + pub name: Option, + + #[validate(custom( + function = "crate::util::validate::validate_no_restricted_scopes" + ))] + pub max_scopes: Option, + + #[validate(length(min = 1))] + pub redirect_uris: Option>, + + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 255) + )] + pub url: Option>, + + #[validate(length(max = 255))] + pub description: Option>, +} + +#[patch("app/{id}")] +pub async fn oauth_client_edit( + req: HttpRequest, + client_id: web::Path, + client_updates: web::Json, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::SESSION_ACCESS]), + ) + .await? + .1; + + client_updates.validate().map_err(|e| { + ApiError::Validation(validation_errors_to_string(e, None)) + })?; + + if let Some(existing_client) = + OAuthClient::get(client_id.into_inner().into(), &**pool).await? + { + existing_client.validate_authorized(Some(¤t_user))?; + + let mut updated_client = existing_client.clone(); + let OAuthClientEdit { + name, + max_scopes, + redirect_uris, + url, + description, + } = client_updates.into_inner(); + if let Some(name) = name { + updated_client.name = name; + } + + if let Some(max_scopes) = max_scopes { + updated_client.max_scopes = max_scopes; + } + + if let Some(url) = url { + updated_client.url = url; + } + + if let Some(description) = description { + updated_client.description = description; + } + + let mut transaction = pool.begin().await?; + updated_client + .update_editable_fields(&mut *transaction) + .await?; + + if let Some(redirects) = redirect_uris { + edit_redirects(redirects, &existing_client, &mut transaction) + .await?; + } + + transaction.commit().await?; + + Ok(HttpResponse::Ok().body("")) + } else { + Err(ApiError::NotFound) + } +} + +#[derive(Serialize, Deserialize)] +pub struct Extension { + pub ext: String, +} + +#[patch("app/{id}/icon")] +#[allow(clippy::too_many_arguments)] +pub async fn oauth_client_icon_edit( + web::Query(ext): web::Query, + req: HttpRequest, + client_id: web::Path, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + mut payload: web::Payload, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::SESSION_ACCESS]), + ) + .await? + .1; + + let client = OAuthClient::get((*client_id).into(), &**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The specified client does not exist!".to_string(), + ) + })?; + + client.validate_authorized(Some(&user))?; + + delete_old_images( + client.icon_url.clone(), + client.raw_icon_url.clone(), + &***file_host, + ) + .await?; + + let bytes = read_from_payload( + &mut payload, + 262144, + "Icons must be smaller than 256KiB", + ) + .await?; + let upload_result = upload_image_optimized( + &format!("data/{}", client_id), + bytes.freeze(), + &ext.ext, + Some(96), + Some(1.0), + &***file_host, + ) + .await?; + + let mut transaction = pool.begin().await?; + + let mut editable_client = client.clone(); + editable_client.icon_url = Some(upload_result.url); + editable_client.raw_icon_url = Some(upload_result.raw_url); + + editable_client + .update_editable_fields(&mut *transaction) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) +} + +#[delete("app/{id}/icon")] +pub async fn oauth_client_icon_delete( + req: HttpRequest, + client_id: web::Path, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::SESSION_ACCESS]), + ) + .await? + .1; + + let client = OAuthClient::get((*client_id).into(), &**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The specified client does not exist!".to_string(), + ) + })?; + client.validate_authorized(Some(&user))?; + + delete_old_images( + client.icon_url.clone(), + client.raw_icon_url.clone(), + &***file_host, + ) + .await?; + + let mut transaction = pool.begin().await?; + + let mut editable_client = client.clone(); + editable_client.icon_url = None; + editable_client.raw_icon_url = None; + + editable_client + .update_editable_fields(&mut *transaction) + .await?; + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) +} + +#[get("authorizations")] +pub async fn get_user_oauth_authorizations( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::SESSION_ACCESS]), + ) + .await? + .1; + + let authorizations = OAuthClientAuthorization::get_all_for_user( + current_user.id.into(), + &**pool, + ) + .await?; + + let mapped: Vec = + authorizations.into_iter().map(|a| a.into()).collect_vec(); + + Ok(HttpResponse::Ok().json(mapped)) +} + +#[delete("authorizations")] +pub async fn revoke_oauth_authorization( + req: HttpRequest, + info: web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::SESSION_ACCESS]), + ) + .await? + .1; + + OAuthClientAuthorization::remove( + info.client_id.into(), + current_user.id.into(), + &**pool, + ) + .await?; + + Ok(HttpResponse::Ok().body("")) +} + +fn generate_oauth_client_secret() -> String { + ChaCha20Rng::from_entropy() + .sample_iter(&Alphanumeric) + .take(32) + .map(char::from) + .collect::() +} + +async fn create_redirect_uris( + uri_strings: impl IntoIterator, + client_id: OAuthClientId, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result, DatabaseError> { + let mut redirect_uris = vec![]; + for uri in uri_strings.into_iter() { + let id = generate_oauth_redirect_id(transaction).await?; + redirect_uris.push(OAuthRedirectUri { + id, + client_id, + uri: uri.to_string(), + }); + } + + Ok(redirect_uris) +} + +async fn edit_redirects( + redirects: Vec, + existing_client: &OAuthClient, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result<(), DatabaseError> { + let updated_redirects: HashSet = redirects.into_iter().collect(); + let original_redirects: HashSet = existing_client + .redirect_uris + .iter() + .map(|r| r.uri.to_string()) + .collect(); + + let redirects_to_add = create_redirect_uris( + updated_redirects.difference(&original_redirects), + existing_client.id, + &mut *transaction, + ) + .await?; + OAuthClient::insert_redirect_uris(&redirects_to_add, &mut **transaction) + .await?; + + let mut redirects_to_remove = existing_client.redirect_uris.clone(); + redirects_to_remove.retain(|r| !updated_redirects.contains(&r.uri)); + OAuthClient::remove_redirect_uris( + redirects_to_remove.iter().map(|r| r.id), + &mut **transaction, + ) + .await?; + + Ok(()) +} + +pub async fn get_clients_inner( + ids: &[ApiOAuthClientId], + pool: web::Data, +) -> Result, ApiError> { + let ids: Vec = ids.iter().map(|i| (*i).into()).collect(); + let clients = OAuthClient::get_many(&ids, &**pool).await?; + + Ok(clients.into_iter().map(|c| c.into()).collect_vec()) +} diff --git a/apps/labrinth/src/routes/v3/organizations.rs b/apps/labrinth/src/routes/v3/organizations.rs new file mode 100644 index 000000000..0307341b3 --- /dev/null +++ b/apps/labrinth/src/routes/v3/organizations.rs @@ -0,0 +1,1216 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use super::ApiError; +use crate::auth::{filter_visible_projects, get_user_from_headers}; +use crate::database::models::team_item::TeamMember; +use crate::database::models::{ + generate_organization_id, team_item, Organization, +}; +use crate::database::redis::RedisPool; +use crate::file_hosting::FileHost; +use crate::models::ids::base62_impl::parse_base62; +use crate::models::ids::UserId; +use crate::models::organizations::OrganizationId; +use crate::models::pats::Scopes; +use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; +use crate::queue::session::AuthQueue; +use crate::routes::v3::project_creation::CreateError; +use crate::util::img::delete_old_images; +use crate::util::routes::read_from_payload; +use crate::util::validate::validation_errors_to_string; +use crate::{database, models}; +use actix_web::{web, HttpRequest, HttpResponse}; +use futures::TryStreamExt; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use validator::Validate; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.route("organizations", web::get().to(organizations_get)); + cfg.service( + web::scope("organization") + .route("", web::post().to(organization_create)) + .route("{id}/projects", web::get().to(organization_projects_get)) + .route("{id}", web::get().to(organization_get)) + .route("{id}", web::patch().to(organizations_edit)) + .route("{id}", web::delete().to(organization_delete)) + .route("{id}/projects", web::post().to(organization_projects_add)) + .route( + "{id}/projects/{project_id}", + web::delete().to(organization_projects_remove), + ) + .route("{id}/icon", web::patch().to(organization_icon_edit)) + .route("{id}/icon", web::delete().to(delete_organization_icon)) + .route( + "{id}/members", + web::get().to(super::teams::team_members_get_organization), + ), + ); +} + +pub async fn organization_projects_get( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let info = info.into_inner().0; + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_READ, Scopes::PROJECT_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let possible_organization_id: Option = parse_base62(&info).ok(); + + let project_ids = sqlx::query!( + " + SELECT m.id FROM organizations o + INNER JOIN mods m ON m.organization_id = o.id + WHERE (o.id = $1 AND $1 IS NOT NULL) OR (o.slug = $2 AND $2 IS NOT NULL) + ", + possible_organization_id.map(|x| x as i64), + info + ) + .fetch(&**pool) + .map_ok(|m| database::models::ProjectId(m.id)) + .try_collect::>() + .await?; + + let projects_data = crate::database::models::Project::get_many_ids( + &project_ids, + &**pool, + &redis, + ) + .await?; + + let projects = + filter_visible_projects(projects_data, ¤t_user, &pool, true) + .await?; + Ok(HttpResponse::Ok().json(projects)) +} + +#[derive(Deserialize, Validate)] +pub struct NewOrganization { + #[validate( + length(min = 3, max = 64), + regex = "crate::util::validate::RE_URL_SAFE" + )] + pub slug: String, + // Title of the organization + #[validate(length(min = 3, max = 64))] + pub name: String, + #[validate(length(min = 3, max = 256))] + pub description: String, +} + +pub async fn organization_create( + req: HttpRequest, + new_organization: web::Json, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_CREATE]), + ) + .await? + .1; + + new_organization.validate().map_err(|err| { + CreateError::ValidationError(validation_errors_to_string(err, None)) + })?; + + let mut transaction = pool.begin().await?; + + // Try title + let name_organization_id_option: Option = + serde_json::from_str(&format!("\"{}\"", new_organization.slug)).ok(); + let mut organization_strings = vec![]; + if let Some(name_organization_id) = name_organization_id_option { + organization_strings.push(name_organization_id.to_string()); + } + organization_strings.push(new_organization.slug.clone()); + let results = Organization::get_many( + &organization_strings, + &mut *transaction, + &redis, + ) + .await?; + if !results.is_empty() { + return Err(CreateError::SlugCollision); + } + + let organization_id = generate_organization_id(&mut transaction).await?; + + // Create organization managerial team + let team = team_item::TeamBuilder { + members: vec![team_item::TeamMemberBuilder { + user_id: current_user.id.into(), + role: crate::models::teams::DEFAULT_ROLE.to_owned(), + is_owner: true, + permissions: ProjectPermissions::all(), + organization_permissions: Some(OrganizationPermissions::all()), + accepted: true, + payouts_split: Decimal::ONE_HUNDRED, + ordering: 0, + }], + }; + let team_id = team.insert(&mut transaction).await?; + + // Create organization + let organization = Organization { + id: organization_id, + slug: new_organization.slug.clone(), + name: new_organization.name.clone(), + description: new_organization.description.clone(), + team_id, + icon_url: None, + raw_icon_url: None, + color: None, + }; + organization.clone().insert(&mut transaction).await?; + transaction.commit().await?; + + // Only member is the owner, the logged in one + let member_data = TeamMember::get_from_team_full(team_id, &**pool, &redis) + .await? + .into_iter() + .next(); + let members_data = if let Some(member_data) = member_data { + vec![crate::models::teams::TeamMember::from_model( + member_data, + current_user.clone(), + false, + )] + } else { + return Err(CreateError::InvalidInput( + "Failed to get created team.".to_owned(), // should never happen + )); + }; + + let organization = + models::organizations::Organization::from(organization, members_data); + + Ok(HttpResponse::Ok().json(organization)) +} + +pub async fn organization_get( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let id = info.into_inner().0; + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + let user_id = current_user.as_ref().map(|x| x.id.into()); + + let organization_data = Organization::get(&id, &**pool, &redis).await?; + if let Some(data) = organization_data { + let members_data = + TeamMember::get_from_team_full(data.team_id, &**pool, &redis) + .await?; + + let users = crate::database::models::User::get_many_ids( + &members_data.iter().map(|x| x.user_id).collect::>(), + &**pool, + &redis, + ) + .await?; + let logged_in = current_user + .as_ref() + .and_then(|user| { + members_data + .iter() + .find(|x| x.user_id == user.id.into() && x.accepted) + }) + .is_some(); + let team_members: Vec<_> = members_data + .into_iter() + .filter(|x| { + logged_in + || x.accepted + || user_id + .map(|y: crate::database::models::UserId| { + y == x.user_id + }) + .unwrap_or(false) + }) + .flat_map(|data| { + users.iter().find(|x| x.id == data.user_id).map(|user| { + crate::models::teams::TeamMember::from( + data, + user.clone(), + !logged_in, + ) + }) + }) + .collect(); + + let organization = + models::organizations::Organization::from(data, team_members); + return Ok(HttpResponse::Ok().json(organization)); + } + Err(ApiError::NotFound) +} + +#[derive(Deserialize)] +pub struct OrganizationIds { + pub ids: String, +} + +pub async fn organizations_get( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let ids = serde_json::from_str::>(&ids.ids)?; + let organizations_data = + Organization::get_many(&ids, &**pool, &redis).await?; + let team_ids = organizations_data + .iter() + .map(|x| x.team_id) + .collect::>(); + + let teams_data = + TeamMember::get_from_team_full_many(&team_ids, &**pool, &redis).await?; + let users = crate::database::models::User::get_many_ids( + &teams_data.iter().map(|x| x.user_id).collect::>(), + &**pool, + &redis, + ) + .await?; + + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + let user_id = current_user.as_ref().map(|x| x.id.into()); + + let mut organizations = vec![]; + + let mut team_groups = HashMap::new(); + for item in teams_data { + team_groups.entry(item.team_id).or_insert(vec![]).push(item); + } + + for data in organizations_data { + let members_data = team_groups.remove(&data.team_id).unwrap_or(vec![]); + let logged_in = current_user + .as_ref() + .and_then(|user| { + members_data + .iter() + .find(|x| x.user_id == user.id.into() && x.accepted) + }) + .is_some(); + + let team_members: Vec<_> = members_data + .into_iter() + .filter(|x| { + logged_in + || x.accepted + || user_id + .map(|y: crate::database::models::UserId| { + y == x.user_id + }) + .unwrap_or(false) + }) + .flat_map(|data| { + users.iter().find(|x| x.id == data.user_id).map(|user| { + crate::models::teams::TeamMember::from( + data, + user.clone(), + !logged_in, + ) + }) + }) + .collect(); + + let organization = + models::organizations::Organization::from(data, team_members); + organizations.push(organization); + } + + Ok(HttpResponse::Ok().json(organizations)) +} + +#[derive(Serialize, Deserialize, Validate)] +pub struct OrganizationEdit { + #[validate(length(min = 3, max = 256))] + pub description: Option, + #[validate( + length(min = 3, max = 64), + regex = "crate::util::validate::RE_URL_SAFE" + )] + pub slug: Option, + #[validate(length(min = 3, max = 64))] + pub name: Option, +} + +pub async fn organizations_edit( + req: HttpRequest, + info: web::Path<(String,)>, + new_organization: web::Json, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_WRITE]), + ) + .await? + .1; + + new_organization.validate().map_err(|err| { + ApiError::Validation(validation_errors_to_string(err, None)) + })?; + + let string = info.into_inner().0; + let result = + database::models::Organization::get(&string, &**pool, &redis).await?; + if let Some(organization_item) = result { + let id = organization_item.id; + + let team_member = database::models::TeamMember::get_from_user_id( + organization_item.team_id, + user.id.into(), + &**pool, + ) + .await?; + + let permissions = OrganizationPermissions::get_permissions_by_role( + &user.role, + &team_member, + ); + + if let Some(perms) = permissions { + let mut transaction = pool.begin().await?; + if let Some(description) = &new_organization.description { + if !perms.contains(OrganizationPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the description of this organization!" + .to_string(), + )); + } + sqlx::query!( + " + UPDATE organizations + SET description = $1 + WHERE (id = $2) + ", + description, + id as database::models::ids::OrganizationId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(name) = &new_organization.name { + if !perms.contains(OrganizationPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the name of this organization!" + .to_string(), + )); + } + sqlx::query!( + " + UPDATE organizations + SET name = $1 + WHERE (id = $2) + ", + name, + id as database::models::ids::OrganizationId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(slug) = &new_organization.slug { + if !perms.contains(OrganizationPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the slug of this organization!" + .to_string(), + )); + } + + let name_organization_id_option: Option = + parse_base62(slug).ok(); + if let Some(name_organization_id) = name_organization_id_option + { + let results = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM organizations WHERE id=$1) + ", + name_organization_id as i64 + ) + .fetch_one(&mut *transaction) + .await?; + + if results.exists.unwrap_or(true) { + return Err(ApiError::InvalidInput( + "slug collides with other organization's id!" + .to_string(), + )); + } + } + + // Make sure the new name is different from the old one + // We are able to unwrap here because the name is always set + if !slug.eq(&organization_item.slug.clone()) { + let results = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM organizations WHERE LOWER(slug) = LOWER($1)) + ", + slug + ) + .fetch_one(&mut *transaction) + .await?; + + if results.exists.unwrap_or(true) { + return Err(ApiError::InvalidInput( + "slug collides with other organization's id!" + .to_string(), + )); + } + } + + sqlx::query!( + " + UPDATE organizations + SET slug = $1 + WHERE (id = $2) + ", + Some(slug), + id as database::models::ids::OrganizationId, + ) + .execute(&mut *transaction) + .await?; + } + + transaction.commit().await?; + database::models::Organization::clear_cache( + organization_item.id, + Some(organization_item.slug), + &redis, + ) + .await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::CustomAuthentication( + "You do not have permission to edit this organization!" + .to_string(), + )) + } + } else { + Err(ApiError::NotFound) + } +} + +pub async fn organization_delete( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_DELETE]), + ) + .await? + .1; + let string = info.into_inner().0; + + let organization = + database::models::Organization::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The specified organization does not exist!".to_string(), + ) + })?; + + if !user.role.is_admin() { + let team_member = + database::models::TeamMember::get_from_user_id_organization( + organization.id, + user.id.into(), + false, + &**pool, + ) + .await + .map_err(ApiError::Database)? + .ok_or_else(|| { + ApiError::InvalidInput( + "The specified organization does not exist!".to_string(), + ) + })?; + + let permissions = OrganizationPermissions::get_permissions_by_role( + &user.role, + &Some(team_member), + ) + .unwrap_or_default(); + + if !permissions.contains(OrganizationPermissions::DELETE_ORGANIZATION) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to delete this organization!" + .to_string(), + )); + } + } + + let owner_id = sqlx::query!( + " + SELECT user_id FROM team_members + WHERE team_id = $1 AND is_owner = TRUE + ", + organization.team_id as database::models::ids::TeamId + ) + .fetch_one(&**pool) + .await? + .user_id; + let owner_id = database::models::ids::UserId(owner_id); + + let mut transaction = pool.begin().await?; + + // Handle projects- every project that is in this organization needs to have its owner changed the organization owner + // Now, no project should have an owner if it is in an organization, and also + // the owner of an organization should not be a team member in any project + let organization_project_teams = sqlx::query!( + " + SELECT t.id FROM organizations o + INNER JOIN mods m ON m.organization_id = o.id + INNER JOIN teams t ON t.id = m.team_id + WHERE o.id = $1 AND $1 IS NOT NULL + ", + organization.id as database::models::ids::OrganizationId + ) + .fetch(&mut *transaction) + .map_ok(|c| database::models::TeamId(c.id)) + .try_collect::>() + .await?; + + for organization_project_team in organization_project_teams.iter() { + let new_id = crate::database::models::ids::generate_team_member_id( + &mut transaction, + ) + .await?; + let member = TeamMember { + id: new_id, + team_id: *organization_project_team, + user_id: owner_id, + role: "Inherited Owner".to_string(), + is_owner: true, + permissions: ProjectPermissions::all(), + organization_permissions: None, + accepted: true, + payouts_split: Decimal::ZERO, + ordering: 0, + }; + member.insert(&mut transaction).await?; + } + // Safely remove the organization + let result = database::models::Organization::remove( + organization.id, + &mut transaction, + &redis, + ) + .await?; + + transaction.commit().await?; + + database::models::Organization::clear_cache( + organization.id, + Some(organization.slug), + &redis, + ) + .await?; + + for team_id in organization_project_teams { + database::models::TeamMember::clear_cache(team_id, &redis).await?; + } + + if result.is_some() { + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::NotFound) + } +} + +#[derive(Deserialize)] +pub struct OrganizationProjectAdd { + pub project_id: String, // Also allow name/slug +} +pub async fn organization_projects_add( + req: HttpRequest, + info: web::Path<(String,)>, + project_info: web::Json, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let info = info.into_inner().0; + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE, Scopes::ORGANIZATION_WRITE]), + ) + .await? + .1; + + let organization = + database::models::Organization::get(&info, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The specified organization does not exist!".to_string(), + ) + })?; + + let project_item = database::models::Project::get( + &project_info.project_id, + &**pool, + &redis, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The specified project does not exist!".to_string(), + ) + })?; + if project_item.inner.organization_id.is_some() { + return Err(ApiError::InvalidInput( + "The specified project is already owned by an organization!" + .to_string(), + )); + } + + let project_team_member = + database::models::TeamMember::get_from_user_id_project( + project_item.inner.id, + current_user.id.into(), + false, + &**pool, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "You are not a member of this project!".to_string(), + ) + })?; + let organization_team_member = + database::models::TeamMember::get_from_user_id_organization( + organization.id, + current_user.id.into(), + false, + &**pool, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "You are not a member of this organization!".to_string(), + ) + })?; + + // Require ownership of a project to add it to an organization + if !current_user.role.is_admin() && !project_team_member.is_owner { + return Err(ApiError::CustomAuthentication( + "You need to be an owner of a project to add it to an organization!".to_string(), + )); + } + + let permissions = OrganizationPermissions::get_permissions_by_role( + ¤t_user.role, + &Some(organization_team_member), + ) + .unwrap_or_default(); + if permissions.contains(OrganizationPermissions::ADD_PROJECT) { + let mut transaction = pool.begin().await?; + sqlx::query!( + " + UPDATE mods + SET organization_id = $1 + WHERE (id = $2) + ", + organization.id as database::models::OrganizationId, + project_item.inner.id as database::models::ids::ProjectId + ) + .execute(&mut *transaction) + .await?; + + // The former owner is no longer an owner (as it is now 'owned' by the organization, 'given' to them) + // The former owner is still a member of the project, but not an owner + // When later removed from the organization, the project will be owned by whoever is specified as the new owner there + + let organization_owner_user_id = sqlx::query!( + " + SELECT u.id + FROM team_members + INNER JOIN users u ON u.id = team_members.user_id + WHERE team_id = $1 AND is_owner = TRUE + ", + organization.team_id as database::models::ids::TeamId + ) + .fetch_one(&mut *transaction) + .await?; + let organization_owner_user_id = + database::models::ids::UserId(organization_owner_user_id.id); + + sqlx::query!( + " + DELETE FROM team_members + WHERE team_id = $1 AND (is_owner = TRUE OR user_id = $2) + ", + project_item.inner.team_id as database::models::ids::TeamId, + organization_owner_user_id as database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + database::models::User::clear_project_cache( + &[current_user.id.into()], + &redis, + ) + .await?; + database::models::TeamMember::clear_cache( + project_item.inner.team_id, + &redis, + ) + .await?; + database::models::Project::clear_cache( + project_item.inner.id, + project_item.inner.slug, + None, + &redis, + ) + .await?; + } else { + return Err(ApiError::CustomAuthentication( + "You do not have permission to add projects to this organization!" + .to_string(), + )); + } + Ok(HttpResponse::Ok().finish()) +} + +#[derive(Deserialize)] +pub struct OrganizationProjectRemoval { + // A new owner must be supplied for the project. + // That user must be a member of the organization, but not necessarily a member of the project. + pub new_owner: UserId, +} + +pub async fn organization_projects_remove( + req: HttpRequest, + info: web::Path<(String, String)>, + pool: web::Data, + data: web::Json, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let (organization_id, project_id) = info.into_inner(); + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE, Scopes::ORGANIZATION_WRITE]), + ) + .await? + .1; + + let organization = + database::models::Organization::get(&organization_id, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The specified organization does not exist!".to_string(), + ) + })?; + + let project_item = + database::models::Project::get(&project_id, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The specified project does not exist!".to_string(), + ) + })?; + + if !project_item + .inner + .organization_id + .eq(&Some(organization.id)) + { + return Err(ApiError::InvalidInput( + "The specified project is not owned by this organization!" + .to_string(), + )); + } + + let organization_team_member = + database::models::TeamMember::get_from_user_id_organization( + organization.id, + current_user.id.into(), + false, + &**pool, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "You are not a member of this organization!".to_string(), + ) + })?; + + let permissions = OrganizationPermissions::get_permissions_by_role( + ¤t_user.role, + &Some(organization_team_member), + ) + .unwrap_or_default(); + if permissions.contains(OrganizationPermissions::REMOVE_PROJECT) { + // Now that permissions are confirmed, we confirm the veracity of the new user as an org member + database::models::TeamMember::get_from_user_id_organization( + organization.id, + data.new_owner.into(), + false, + &**pool, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The specified user is not a member of this organization!" + .to_string(), + ) + })?; + + // Then, we get the team member of the project and that user (if it exists) + // We use the team member get directly + let new_owner = database::models::TeamMember::get_from_user_id_project( + project_item.inner.id, + data.new_owner.into(), + true, + &**pool, + ) + .await?; + + let mut transaction = pool.begin().await?; + + // If the user is not a member of the project, we add them + let new_owner = match new_owner { + Some(new_owner) => new_owner, + None => { + let new_id = + crate::database::models::ids::generate_team_member_id( + &mut transaction, + ) + .await?; + let member = TeamMember { + id: new_id, + team_id: project_item.inner.team_id, + user_id: data.new_owner.into(), + role: "Inherited Owner".to_string(), + is_owner: false, + permissions: ProjectPermissions::all(), + organization_permissions: None, + accepted: true, + payouts_split: Decimal::ZERO, + ordering: 0, + }; + member.insert(&mut transaction).await?; + member + } + }; + + // Set the new owner to fit owner + sqlx::query!( + " + UPDATE team_members + SET + is_owner = TRUE, + accepted = TRUE, + permissions = $2, + organization_permissions = NULL, + role = 'Inherited Owner' + WHERE (id = $1) + ", + new_owner.id as database::models::ids::TeamMemberId, + ProjectPermissions::all().bits() as i64 + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + " + UPDATE mods + SET organization_id = NULL + WHERE (id = $1) + ", + project_item.inner.id as database::models::ids::ProjectId + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + database::models::User::clear_project_cache( + &[current_user.id.into()], + &redis, + ) + .await?; + database::models::TeamMember::clear_cache( + project_item.inner.team_id, + &redis, + ) + .await?; + database::models::Project::clear_cache( + project_item.inner.id, + project_item.inner.slug, + None, + &redis, + ) + .await?; + } else { + return Err(ApiError::CustomAuthentication( + "You do not have permission to add projects to this organization!" + .to_string(), + )); + } + Ok(HttpResponse::Ok().finish()) +} + +#[derive(Serialize, Deserialize)] +pub struct Extension { + pub ext: String, +} + +#[allow(clippy::too_many_arguments)] +pub async fn organization_icon_edit( + web::Query(ext): web::Query, + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + mut payload: web::Payload, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_WRITE]), + ) + .await? + .1; + let string = info.into_inner().0; + + let organization_item = + database::models::Organization::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The specified organization does not exist!".to_string(), + ) + })?; + + if !user.role.is_mod() { + let team_member = database::models::TeamMember::get_from_user_id( + organization_item.team_id, + user.id.into(), + &**pool, + ) + .await + .map_err(ApiError::Database)?; + + let permissions = OrganizationPermissions::get_permissions_by_role( + &user.role, + &team_member, + ) + .unwrap_or_default(); + + if !permissions.contains(OrganizationPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit this organization's icon." + .to_string(), + )); + } + } + + delete_old_images( + organization_item.icon_url, + organization_item.raw_icon_url, + &***file_host, + ) + .await?; + + let bytes = read_from_payload( + &mut payload, + 262144, + "Icons must be smaller than 256KiB", + ) + .await?; + + let organization_id: OrganizationId = organization_item.id.into(); + let upload_result = crate::util::img::upload_image_optimized( + &format!("data/{}", organization_id), + bytes.freeze(), + &ext.ext, + Some(96), + Some(1.0), + &***file_host, + ) + .await?; + + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE organizations + SET icon_url = $1, raw_icon_url = $2, color = $3 + WHERE (id = $4) + ", + upload_result.url, + upload_result.raw_url, + upload_result.color.map(|x| x as i32), + organization_item.id as database::models::ids::OrganizationId, + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + database::models::Organization::clear_cache( + organization_item.id, + Some(organization_item.slug), + &redis, + ) + .await?; + + Ok(HttpResponse::NoContent().body("")) +} + +pub async fn delete_organization_icon( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_WRITE]), + ) + .await? + .1; + let string = info.into_inner().0; + + let organization_item = + database::models::Organization::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The specified organization does not exist!".to_string(), + ) + })?; + + if !user.role.is_mod() { + let team_member = database::models::TeamMember::get_from_user_id( + organization_item.team_id, + user.id.into(), + &**pool, + ) + .await + .map_err(ApiError::Database)?; + + let permissions = OrganizationPermissions::get_permissions_by_role( + &user.role, + &team_member, + ) + .unwrap_or_default(); + + if !permissions.contains(OrganizationPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit this organization's icon." + .to_string(), + )); + } + } + + delete_old_images( + organization_item.icon_url, + organization_item.raw_icon_url, + &***file_host, + ) + .await?; + + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE organizations + SET icon_url = NULL, raw_icon_url = NULL, color = NULL + WHERE (id = $1) + ", + organization_item.id as database::models::ids::OrganizationId, + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + database::models::Organization::clear_cache( + organization_item.id, + Some(organization_item.slug), + &redis, + ) + .await?; + + Ok(HttpResponse::NoContent().body("")) +} diff --git a/apps/labrinth/src/routes/v3/payouts.rs b/apps/labrinth/src/routes/v3/payouts.rs new file mode 100644 index 000000000..ad5636de1 --- /dev/null +++ b/apps/labrinth/src/routes/v3/payouts.rs @@ -0,0 +1,997 @@ +use crate::auth::validate::get_user_record_from_bearer_token; +use crate::auth::{get_user_from_headers, AuthenticationError}; +use crate::database::models::generate_payout_id; +use crate::database::redis::RedisPool; +use crate::models::ids::PayoutId; +use crate::models::pats::Scopes; +use crate::models::payouts::{PayoutMethodType, PayoutStatus}; +use crate::queue::payouts::{make_aditude_request, PayoutsQueue}; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use actix_web::{delete, get, post, web, HttpRequest, HttpResponse}; +use chrono::{Datelike, Duration, TimeZone, Utc, Weekday}; +use hex::ToHex; +use hmac::{Hmac, Mac, NewMac}; +use reqwest::Method; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sha2::Sha256; +use sqlx::PgPool; +use std::collections::HashMap; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("payout") + .service(paypal_webhook) + .service(tremendous_webhook) + .service(user_payouts) + .service(create_payout) + .service(cancel_payout) + .service(payment_methods) + .service(get_balance) + .service(platform_revenue), + ); +} + +#[post("_paypal")] +pub async fn paypal_webhook( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + payouts: web::Data, + body: String, +) -> Result { + let auth_algo = req + .headers() + .get("PAYPAL-AUTH-ALGO") + .and_then(|x| x.to_str().ok()) + .ok_or_else(|| { + ApiError::InvalidInput("missing auth algo".to_string()) + })?; + let cert_url = req + .headers() + .get("PAYPAL-CERT-URL") + .and_then(|x| x.to_str().ok()) + .ok_or_else(|| { + ApiError::InvalidInput("missing cert url".to_string()) + })?; + let transmission_id = req + .headers() + .get("PAYPAL-TRANSMISSION-ID") + .and_then(|x| x.to_str().ok()) + .ok_or_else(|| { + ApiError::InvalidInput("missing transmission ID".to_string()) + })?; + let transmission_sig = req + .headers() + .get("PAYPAL-TRANSMISSION-SIG") + .and_then(|x| x.to_str().ok()) + .ok_or_else(|| { + ApiError::InvalidInput("missing transmission sig".to_string()) + })?; + let transmission_time = req + .headers() + .get("PAYPAL-TRANSMISSION-TIME") + .and_then(|x| x.to_str().ok()) + .ok_or_else(|| { + ApiError::InvalidInput("missing transmission time".to_string()) + })?; + + #[derive(Deserialize)] + struct WebHookResponse { + verification_status: String, + } + + let webhook_res = payouts + .make_paypal_request::<(), WebHookResponse>( + Method::POST, + "notifications/verify-webhook-signature", + None, + // This is needed as serde re-orders fields, which causes the validation to fail for PayPal. + Some(format!( + "{{ + \"auth_algo\": \"{auth_algo}\", + \"cert_url\": \"{cert_url}\", + \"transmission_id\": \"{transmission_id}\", + \"transmission_sig\": \"{transmission_sig}\", + \"transmission_time\": \"{transmission_time}\", + \"webhook_id\": \"{}\", + \"webhook_event\": {body} + }}", + dotenvy::var("PAYPAL_WEBHOOK_ID")? + )), + None, + ) + .await?; + + if &webhook_res.verification_status != "SUCCESS" { + return Err(ApiError::InvalidInput( + "Invalid webhook signature".to_string(), + )); + } + + #[derive(Deserialize)] + struct PayPalResource { + pub payout_item_id: String, + } + + #[derive(Deserialize)] + struct PayPalWebhook { + pub event_type: String, + pub resource: PayPalResource, + } + + let webhook = serde_json::from_str::(&body)?; + + match &*webhook.event_type { + "PAYMENT.PAYOUTS-ITEM.BLOCKED" + | "PAYMENT.PAYOUTS-ITEM.DENIED" + | "PAYMENT.PAYOUTS-ITEM.REFUNDED" + | "PAYMENT.PAYOUTS-ITEM.RETURNED" + | "PAYMENT.PAYOUTS-ITEM.CANCELED" => { + let mut transaction = pool.begin().await?; + + let result = sqlx::query!( + "SELECT user_id, amount, fee FROM payouts WHERE platform_id = $1 AND status = $2", + webhook.resource.payout_item_id, + PayoutStatus::InTransit.as_str() + ) + .fetch_optional(&mut *transaction) + .await?; + + if let Some(result) = result { + sqlx::query!( + " + UPDATE payouts + SET status = $1 + WHERE platform_id = $2 + ", + if &*webhook.event_type == "PAYMENT.PAYOUTS-ITEM.CANCELED" { + PayoutStatus::Cancelled + } else { + PayoutStatus::Failed + } + .as_str(), + webhook.resource.payout_item_id + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + crate::database::models::user_item::User::clear_caches( + &[(crate::database::models::UserId(result.user_id), None)], + &redis, + ) + .await?; + } + } + "PAYMENT.PAYOUTS-ITEM.SUCCEEDED" => { + let mut transaction = pool.begin().await?; + sqlx::query!( + " + UPDATE payouts + SET status = $1 + WHERE platform_id = $2 + ", + PayoutStatus::Success.as_str(), + webhook.resource.payout_item_id + ) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + } + _ => {} + } + + Ok(HttpResponse::NoContent().finish()) +} + +#[post("_tremendous")] +pub async fn tremendous_webhook( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + body: String, +) -> Result { + let signature = req + .headers() + .get("Tremendous-Webhook-Signature") + .and_then(|x| x.to_str().ok()) + .and_then(|x| x.split('=').next_back()) + .ok_or_else(|| { + ApiError::InvalidInput("missing webhook signature".to_string()) + })?; + + let mut mac: Hmac = Hmac::new_from_slice( + dotenvy::var("TREMENDOUS_PRIVATE_KEY")?.as_bytes(), + ) + .map_err(|_| ApiError::Payments("error initializing HMAC".to_string()))?; + mac.update(body.as_bytes()); + let request_signature = mac.finalize().into_bytes().encode_hex::(); + + if &*request_signature != signature { + return Err(ApiError::InvalidInput( + "Invalid webhook signature".to_string(), + )); + } + + #[derive(Deserialize)] + pub struct TremendousResource { + pub id: String, + } + + #[derive(Deserialize)] + struct TremendousPayload { + pub resource: TremendousResource, + } + + #[derive(Deserialize)] + struct TremendousWebhook { + pub event: String, + pub payload: TremendousPayload, + } + + let webhook = serde_json::from_str::(&body)?; + + match &*webhook.event { + "REWARDS.CANCELED" | "REWARDS.DELIVERY.FAILED" => { + let mut transaction = pool.begin().await?; + + let result = sqlx::query!( + "SELECT user_id, amount, fee FROM payouts WHERE platform_id = $1 AND status = $2", + webhook.payload.resource.id, + PayoutStatus::InTransit.as_str() + ) + .fetch_optional(&mut *transaction) + .await?; + + if let Some(result) = result { + sqlx::query!( + " + UPDATE payouts + SET status = $1 + WHERE platform_id = $2 + ", + if &*webhook.event == "REWARDS.CANCELED" { + PayoutStatus::Cancelled + } else { + PayoutStatus::Failed + } + .as_str(), + webhook.payload.resource.id + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + crate::database::models::user_item::User::clear_caches( + &[(crate::database::models::UserId(result.user_id), None)], + &redis, + ) + .await?; + } + } + "REWARDS.DELIVERY.SUCCEEDED" => { + let mut transaction = pool.begin().await?; + sqlx::query!( + " + UPDATE payouts + SET status = $1 + WHERE platform_id = $2 + ", + PayoutStatus::Success.as_str(), + webhook.payload.resource.id + ) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + } + _ => {} + } + + Ok(HttpResponse::NoContent().finish()) +} + +#[get("")] +pub async fn user_payouts( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PAYOUTS_READ]), + ) + .await? + .1; + + let payout_ids = + crate::database::models::payout_item::Payout::get_all_for_user( + user.id.into(), + &**pool, + ) + .await?; + let payouts = crate::database::models::payout_item::Payout::get_many( + &payout_ids, + &**pool, + ) + .await?; + + Ok(HttpResponse::Ok().json( + payouts + .into_iter() + .map(crate::models::payouts::Payout::from) + .collect::>(), + )) +} + +#[derive(Deserialize)] +pub struct Withdrawal { + #[serde(with = "rust_decimal::serde::float")] + amount: Decimal, + method: PayoutMethodType, + method_id: String, +} + +#[post("")] +pub async fn create_payout( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + body: web::Json, + session_queue: web::Data, + payouts_queue: web::Data, +) -> Result { + let (scopes, user) = get_user_record_from_bearer_token( + &req, + None, + &**pool, + &redis, + &session_queue, + ) + .await? + .ok_or_else(|| { + ApiError::Authentication(AuthenticationError::InvalidCredentials) + })?; + + if !scopes.contains(Scopes::PAYOUTS_WRITE) { + return Err(ApiError::Authentication( + AuthenticationError::InvalidCredentials, + )); + } + + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + SELECT balance FROM users WHERE id = $1 FOR UPDATE + ", + user.id.0 + ) + .fetch_optional(&mut *transaction) + .await?; + + let balance = get_user_balance(user.id, &pool).await?; + if balance.available < body.amount || body.amount < Decimal::ZERO { + return Err(ApiError::InvalidInput( + "You do not have enough funds to make this payout!".to_string(), + )); + } + + let payout_method = payouts_queue + .get_payout_methods() + .await? + .into_iter() + .find(|x| x.id == body.method_id) + .ok_or_else(|| { + ApiError::InvalidInput( + "Invalid payment method specified!".to_string(), + ) + })?; + + let fee = std::cmp::min( + std::cmp::max( + payout_method.fee.min, + payout_method.fee.percentage * body.amount, + ), + payout_method.fee.max.unwrap_or(Decimal::MAX), + ); + + let transfer = (body.amount - fee).round_dp(2); + if transfer <= Decimal::ZERO { + return Err(ApiError::InvalidInput( + "You need to withdraw more to cover the fee!".to_string(), + )); + } + + let payout_id = generate_payout_id(&mut transaction).await?; + + let payout_item = match body.method { + PayoutMethodType::Venmo | PayoutMethodType::PayPal => { + let (wallet, wallet_type, address, display_address) = if body.method + == PayoutMethodType::Venmo + { + if let Some(venmo) = user.venmo_handle { + ("Venmo", "user_handle", venmo.clone(), venmo) + } else { + return Err(ApiError::InvalidInput( + "Venmo address has not been set for account!" + .to_string(), + )); + } + } else if let Some(paypal_id) = user.paypal_id { + if let Some(paypal_country) = user.paypal_country { + if &*paypal_country == "US" + && &*body.method_id != "paypal_us" + { + return Err(ApiError::InvalidInput( + "Please use the US PayPal transfer option!" + .to_string(), + )); + } else if &*paypal_country != "US" + && &*body.method_id == "paypal_us" + { + return Err(ApiError::InvalidInput( + "Please use the International PayPal transfer option!".to_string(), + )); + } + + ( + "PayPal", + "paypal_id", + paypal_id.clone(), + user.paypal_email.unwrap_or(paypal_id), + ) + } else { + return Err(ApiError::InvalidInput( + "Please re-link your PayPal account!".to_string(), + )); + } + } else { + return Err(ApiError::InvalidInput( + "You have not linked a PayPal account!".to_string(), + )); + }; + + #[derive(Deserialize)] + struct PayPalLink { + href: String, + } + + #[derive(Deserialize)] + struct PayoutsResponse { + pub links: Vec, + } + + let mut payout_item = + crate::database::models::payout_item::Payout { + id: payout_id, + user_id: user.id, + created: Utc::now(), + status: PayoutStatus::InTransit, + amount: transfer, + fee: Some(fee), + method: Some(body.method), + method_address: Some(display_address), + platform_id: None, + }; + + let res: PayoutsResponse = payouts_queue.make_paypal_request( + Method::POST, + "payments/payouts", + Some( + json! ({ + "sender_batch_header": { + "sender_batch_id": format!("{}-payouts", Utc::now().to_rfc3339()), + "email_subject": "You have received a payment from Modrinth!", + "email_message": "Thank you for creating projects on Modrinth. Please claim this payment within 30 days.", + }, + "items": [{ + "amount": { + "currency": "USD", + "value": transfer.to_string() + }, + "receiver": address, + "note": "Payment from Modrinth creator monetization program", + "recipient_type": wallet_type, + "recipient_wallet": wallet, + "sender_item_id": crate::models::ids::PayoutId::from(payout_id), + }] + }) + ), + None, + None + ).await?; + + if let Some(link) = res.links.first() { + #[derive(Deserialize)] + struct PayoutItem { + pub payout_item_id: String, + } + + #[derive(Deserialize)] + struct PayoutData { + pub items: Vec, + } + + if let Ok(res) = payouts_queue + .make_paypal_request::<(), PayoutData>( + Method::GET, + &link.href, + None, + None, + Some(true), + ) + .await + { + if let Some(data) = res.items.first() { + payout_item.platform_id = + Some(data.payout_item_id.clone()); + } + } + } + + payout_item + } + PayoutMethodType::Tremendous => { + if let Some(email) = user.email { + if user.email_verified { + let mut payout_item = + crate::database::models::payout_item::Payout { + id: payout_id, + user_id: user.id, + created: Utc::now(), + status: PayoutStatus::InTransit, + amount: transfer, + fee: Some(fee), + method: Some(PayoutMethodType::Tremendous), + method_address: Some(email.clone()), + platform_id: None, + }; + + #[derive(Deserialize)] + struct Reward { + pub id: String, + } + + #[derive(Deserialize)] + struct Order { + pub rewards: Vec, + } + + #[derive(Deserialize)] + struct TremendousResponse { + pub order: Order, + } + + let res: TremendousResponse = payouts_queue + .make_tremendous_request( + Method::POST, + "orders", + Some(json! ({ + "payment": { + "funding_source_id": "BALANCE", + }, + "rewards": [{ + "value": { + "denomination": transfer + }, + "delivery": { + "method": "EMAIL" + }, + "recipient": { + "name": user.username, + "email": email + }, + "products": [ + &body.method_id, + ], + "campaign_id": dotenvy::var("TREMENDOUS_CAMPAIGN_ID")?, + }] + })), + ) + .await?; + + if let Some(reward) = res.order.rewards.first() { + payout_item.platform_id = Some(reward.id.clone()) + } + + payout_item + } else { + return Err(ApiError::InvalidInput( + "You must verify your account email to proceed!" + .to_string(), + )); + } + } else { + return Err(ApiError::InvalidInput( + "You must add an email to your account to proceed!" + .to_string(), + )); + } + } + PayoutMethodType::Unknown => { + return Err(ApiError::Payments( + "Invalid payment method specified!".to_string(), + )) + } + }; + + payout_item.insert(&mut transaction).await?; + + transaction.commit().await?; + crate::database::models::User::clear_caches(&[(user.id, None)], &redis) + .await?; + + Ok(HttpResponse::NoContent().finish()) +} + +#[delete("{id}")] +pub async fn cancel_payout( + info: web::Path<(PayoutId,)>, + req: HttpRequest, + pool: web::Data, + redis: web::Data, + payouts: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PAYOUTS_WRITE]), + ) + .await? + .1; + + let id = info.into_inner().0; + let payout = + crate::database::models::payout_item::Payout::get(id.into(), &**pool) + .await?; + + if let Some(payout) = payout { + if payout.user_id != user.id.into() && !user.role.is_admin() { + return Ok(HttpResponse::NotFound().finish()); + } + + if let Some(platform_id) = payout.platform_id { + if let Some(method) = payout.method { + if payout.status != PayoutStatus::InTransit { + return Err(ApiError::InvalidInput( + "Payout cannot be cancelled!".to_string(), + )); + } + + match method { + PayoutMethodType::Venmo | PayoutMethodType::PayPal => { + payouts + .make_paypal_request::<(), ()>( + Method::POST, + &format!( + "payments/payouts-item/{}/cancel", + platform_id + ), + None, + None, + None, + ) + .await?; + } + PayoutMethodType::Tremendous => { + payouts + .make_tremendous_request::<(), ()>( + Method::POST, + &format!("rewards/{}/cancel", platform_id), + None, + ) + .await?; + } + PayoutMethodType::Unknown => { + return Err(ApiError::InvalidInput( + "Payout cannot be cancelled!".to_string(), + )) + } + } + + let mut transaction = pool.begin().await?; + sqlx::query!( + " + UPDATE payouts + SET status = $1 + WHERE platform_id = $2 + ", + PayoutStatus::Cancelling.as_str(), + platform_id + ) + .execute(&mut *transaction) + .await?; + transaction.commit().await?; + + Ok(HttpResponse::NoContent().finish()) + } else { + Err(ApiError::InvalidInput( + "Payout cannot be cancelled!".to_string(), + )) + } + } else { + Err(ApiError::InvalidInput( + "Payout cannot be cancelled!".to_string(), + )) + } + } else { + Ok(HttpResponse::NotFound().finish()) + } +} + +#[derive(Deserialize)] +pub struct MethodFilter { + pub country: Option, +} + +#[get("methods")] +pub async fn payment_methods( + payouts_queue: web::Data, + filter: web::Query, +) -> Result { + let methods = payouts_queue + .get_payout_methods() + .await? + .into_iter() + .filter(|x| { + let mut val = true; + + if let Some(country) = &filter.country { + val &= x.supported_countries.contains(country); + } + + val + }) + .collect::>(); + + Ok(HttpResponse::Ok().json(methods)) +} + +#[derive(Serialize)] +pub struct UserBalance { + pub available: Decimal, + pub pending: Decimal, +} + +#[get("balance")] +pub async fn get_balance( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PAYOUTS_READ]), + ) + .await? + .1; + + let balance = get_user_balance(user.id.into(), &pool).await?; + + Ok(HttpResponse::Ok().json(balance)) +} + +async fn get_user_balance( + user_id: crate::database::models::ids::UserId, + pool: &PgPool, +) -> Result { + let available = sqlx::query!( + " + SELECT SUM(amount) + FROM payouts_values + WHERE user_id = $1 AND date_available <= NOW() + ", + user_id.0 + ) + .fetch_optional(pool) + .await?; + + let pending = sqlx::query!( + " + SELECT SUM(amount) + FROM payouts_values + WHERE user_id = $1 AND date_available > NOW() + ", + user_id.0 + ) + .fetch_optional(pool) + .await?; + + let withdrawn = sqlx::query!( + " + SELECT SUM(amount) amount, SUM(fee) fee + FROM payouts + WHERE user_id = $1 AND (status = 'success' OR status = 'in-transit') + ", + user_id.0 + ) + .fetch_optional(pool) + .await?; + + let available = available + .map(|x| x.sum.unwrap_or(Decimal::ZERO)) + .unwrap_or(Decimal::ZERO); + let pending = pending + .map(|x| x.sum.unwrap_or(Decimal::ZERO)) + .unwrap_or(Decimal::ZERO); + let (withdrawn, fees) = withdrawn + .map(|x| { + ( + x.amount.unwrap_or(Decimal::ZERO), + x.fee.unwrap_or(Decimal::ZERO), + ) + }) + .unwrap_or((Decimal::ZERO, Decimal::ZERO)); + + Ok(UserBalance { + available: available.round_dp(16) + - withdrawn.round_dp(16) + - fees.round_dp(16), + pending, + }) +} + +#[derive(Serialize, Deserialize)] +pub struct RevenueResponse { + pub all_time: Decimal, + pub data: Vec, +} + +#[derive(Serialize, Deserialize)] +pub struct RevenueData { + pub time: u64, + pub revenue: Decimal, + pub creator_revenue: Decimal, +} + +#[get("platform_revenue")] +pub async fn platform_revenue( + pool: web::Data, + redis: web::Data, +) -> Result { + let mut redis = redis.connect().await?; + + const PLATFORM_REVENUE_NAMESPACE: &str = "platform_revenue"; + + let res: Option = redis + .get_deserialized_from_json(PLATFORM_REVENUE_NAMESPACE, "0") + .await?; + + if let Some(res) = res { + return Ok(HttpResponse::Ok().json(res)); + } + + let all_time_payouts = sqlx::query!( + " + SELECT SUM(amount) from payouts_values + ", + ) + .fetch_optional(&**pool) + .await? + .and_then(|x| x.sum) + .unwrap_or(Decimal::ZERO); + + let points = make_aditude_request( + &["METRIC_REVENUE", "METRIC_IMPRESSIONS"], + "30d", + "1d", + ) + .await?; + + let mut points_map = HashMap::new(); + + for point in points { + for point in point.points_list { + let entry = + points_map.entry(point.time.seconds).or_insert((None, None)); + + if let Some(revenue) = point.metric.revenue { + entry.0 = Some(revenue); + } + + if let Some(impressions) = point.metric.impressions { + entry.1 = Some(impressions); + } + } + } + + let mut revenue_data = Vec::new(); + let now = Utc::now(); + + for i in 1..=30 { + let time = now - Duration::days(i); + let start = time + .date_naive() + .and_hms_opt(0, 0, 0) + .unwrap() + .and_utc() + .timestamp(); + + if let Some((revenue, impressions)) = points_map.remove(&(start as u64)) + { + // Before 9/5/24, when legacy payouts were in effect. + if start >= 1725494400 { + let revenue = revenue.unwrap_or(Decimal::ZERO); + let impressions = impressions.unwrap_or(0); + + // Modrinth's share of ad revenue + let modrinth_cut = Decimal::from(1) / Decimal::from(4); + // Clean.io fee (ad antimalware). Per 1000 impressions. + let clean_io_fee = Decimal::from(8) / Decimal::from(1000); + + let net_revenue = revenue + - (clean_io_fee * Decimal::from(impressions) + / Decimal::from(1000)); + + let payout = net_revenue * (Decimal::from(1) - modrinth_cut); + + revenue_data.push(RevenueData { + time: start as u64, + revenue: net_revenue, + creator_revenue: payout, + }); + + continue; + } + } + + revenue_data.push(get_legacy_data_point(start as u64)); + } + + let res = RevenueResponse { + all_time: all_time_payouts, + data: revenue_data, + }; + + redis + .set_serialized_to_json( + PLATFORM_REVENUE_NAMESPACE, + 0, + &res, + Some(60 * 60), + ) + .await?; + + Ok(HttpResponse::Ok().json(res)) +} + +fn get_legacy_data_point(timestamp: u64) -> RevenueData { + let start = Utc.timestamp_opt(timestamp as i64, 0).unwrap(); + + let old_payouts_budget = Decimal::from(10_000); + + let days = Decimal::from(28); + let weekdays = Decimal::from(20); + let weekend_bonus = Decimal::from(5) / Decimal::from(4); + + let weekday_amount = + old_payouts_budget / (weekdays + (weekend_bonus) * (days - weekdays)); + let weekend_amount = weekday_amount * weekend_bonus; + + let payout = match start.weekday() { + Weekday::Sat | Weekday::Sun => weekend_amount, + _ => weekday_amount, + }; + + RevenueData { + time: timestamp, + revenue: payout, + creator_revenue: payout * (Decimal::from(9) / Decimal::from(10)), + } +} diff --git a/apps/labrinth/src/routes/v3/project_creation.rs b/apps/labrinth/src/routes/v3/project_creation.rs new file mode 100644 index 000000000..31d751205 --- /dev/null +++ b/apps/labrinth/src/routes/v3/project_creation.rs @@ -0,0 +1,1044 @@ +use super::version_creation::{try_create_version_fields, InitialVersionData}; +use crate::auth::{get_user_from_headers, AuthenticationError}; +use crate::database::models::loader_fields::{ + Loader, LoaderField, LoaderFieldEnumValue, +}; +use crate::database::models::thread_item::ThreadBuilder; +use crate::database::models::{self, image_item, User}; +use crate::database::redis::RedisPool; +use crate::file_hosting::{FileHost, FileHostingError}; +use crate::models::error::ApiError; +use crate::models::ids::base62_impl::to_base62; +use crate::models::ids::{ImageId, OrganizationId}; +use crate::models::images::{Image, ImageContext}; +use crate::models::pats::Scopes; +use crate::models::projects::{ + License, Link, MonetizationStatus, ProjectId, ProjectStatus, VersionId, + VersionStatus, +}; +use crate::models::teams::{OrganizationPermissions, ProjectPermissions}; +use crate::models::threads::ThreadType; +use crate::models::users::UserId; +use crate::queue::session::AuthQueue; +use crate::search::indexing::IndexingError; +use crate::util::img::upload_image_optimized; +use crate::util::routes::read_from_field; +use crate::util::validate::validation_errors_to_string; +use actix_multipart::{Field, Multipart}; +use actix_web::http::StatusCode; +use actix_web::web::{self, Data}; +use actix_web::{HttpRequest, HttpResponse}; +use chrono::Utc; +use futures::stream::StreamExt; +use image::ImageError; +use itertools::Itertools; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use sqlx::postgres::PgPool; +use std::collections::HashMap; +use std::sync::Arc; +use thiserror::Error; +use validator::Validate; + +pub fn config(cfg: &mut actix_web::web::ServiceConfig) { + cfg.route("project", web::post().to(project_create)); +} + +#[derive(Error, Debug)] +pub enum CreateError { + #[error("Environment Error")] + EnvError(#[from] dotenvy::Error), + #[error("An unknown database error occurred")] + SqlxDatabaseError(#[from] sqlx::Error), + #[error("Database Error: {0}")] + DatabaseError(#[from] models::DatabaseError), + #[error("Indexing Error: {0}")] + IndexingError(#[from] IndexingError), + #[error("Error while parsing multipart payload: {0}")] + MultipartError(#[from] actix_multipart::MultipartError), + #[error("Error while parsing JSON: {0}")] + SerDeError(#[from] serde_json::Error), + #[error("Error while validating input: {0}")] + ValidationError(String), + #[error("Error while uploading file: {0}")] + FileHostingError(#[from] FileHostingError), + #[error("Error while validating uploaded file: {0}")] + FileValidationError(#[from] crate::validate::ValidationError), + #[error("{}", .0)] + MissingValueError(String), + #[error("Invalid format for image: {0}")] + 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), + #[error("Invalid file type for version file: {0}")] + InvalidFileType(String), + #[error("Slug is already taken!")] + SlugCollision, + #[error("Authentication Error: {0}")] + Unauthorized(#[from] AuthenticationError), + #[error("Authentication Error: {0}")] + CustomAuthenticationError(String), + #[error("Image Parsing Error: {0}")] + ImageError(#[from] ImageError), + #[error("Reroute Error: {0}")] + RerouteError(#[from] reqwest::Error), +} + +impl actix_web::ResponseError for CreateError { + fn status_code(&self) -> StatusCode { + match self { + CreateError::EnvError(..) => StatusCode::INTERNAL_SERVER_ERROR, + CreateError::SqlxDatabaseError(..) => { + StatusCode::INTERNAL_SERVER_ERROR + } + CreateError::DatabaseError(..) => StatusCode::INTERNAL_SERVER_ERROR, + CreateError::IndexingError(..) => StatusCode::INTERNAL_SERVER_ERROR, + CreateError::FileHostingError(..) => { + StatusCode::INTERNAL_SERVER_ERROR + } + CreateError::SerDeError(..) => StatusCode::BAD_REQUEST, + CreateError::MultipartError(..) => StatusCode::BAD_REQUEST, + 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, + CreateError::InvalidFileType(..) => StatusCode::BAD_REQUEST, + CreateError::Unauthorized(..) => StatusCode::UNAUTHORIZED, + CreateError::CustomAuthenticationError(..) => { + StatusCode::UNAUTHORIZED + } + CreateError::SlugCollision => StatusCode::BAD_REQUEST, + CreateError::ValidationError(..) => StatusCode::BAD_REQUEST, + CreateError::FileValidationError(..) => StatusCode::BAD_REQUEST, + CreateError::ImageError(..) => StatusCode::BAD_REQUEST, + CreateError::RerouteError(..) => StatusCode::INTERNAL_SERVER_ERROR, + } + } + + fn error_response(&self) -> HttpResponse { + HttpResponse::build(self.status_code()).json(ApiError { + error: match self { + CreateError::EnvError(..) => "environment_error", + CreateError::SqlxDatabaseError(..) => "database_error", + CreateError::DatabaseError(..) => "database_error", + CreateError::IndexingError(..) => "indexing_error", + CreateError::FileHostingError(..) => "file_hosting_error", + CreateError::SerDeError(..) => "invalid_input", + CreateError::MultipartError(..) => "invalid_input", + CreateError::MissingValueError(..) => "invalid_input", + CreateError::InvalidIconFormat(..) => "invalid_input", + CreateError::InvalidInput(..) => "invalid_input", + CreateError::InvalidGameVersion(..) => "invalid_input", + CreateError::InvalidLoader(..) => "invalid_input", + CreateError::InvalidCategory(..) => "invalid_input", + CreateError::InvalidFileType(..) => "invalid_input", + CreateError::Unauthorized(..) => "unauthorized", + CreateError::CustomAuthenticationError(..) => "unauthorized", + CreateError::SlugCollision => "invalid_input", + CreateError::ValidationError(..) => "invalid_input", + CreateError::FileValidationError(..) => "invalid_input", + CreateError::ImageError(..) => "invalid_image", + CreateError::RerouteError(..) => "reroute_error", + }, + description: self.to_string(), + }) + } +} + +pub fn default_project_type() -> String { + "mod".to_string() +} + +fn default_requested_status() -> ProjectStatus { + ProjectStatus::Approved +} + +#[derive(Serialize, Deserialize, Validate, Clone)] +pub struct ProjectCreateData { + #[validate( + length(min = 3, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + #[serde(alias = "mod_name")] + /// The title or name of the project. + pub name: String, + #[validate( + length(min = 3, max = 64), + regex = "crate::util::validate::RE_URL_SAFE" + )] + #[serde(alias = "mod_slug")] + /// The slug of a project, used for vanity URLs + pub slug: String, + #[validate(length(min = 3, max = 255))] + #[serde(alias = "mod_description")] + /// A short description of the project. + pub summary: String, + #[validate(length(max = 65536))] + #[serde(alias = "mod_body")] + /// A long description of the project, in markdown. + pub description: String, + + #[validate(length(max = 32))] + #[validate] + /// A list of initial versions to upload with the created project + pub initial_versions: Vec, + #[validate(length(max = 3))] + /// A list of the categories that the project is in. + pub categories: Vec, + #[validate(length(max = 256))] + #[serde(default = "Vec::new")] + /// A list of the categories that the project is in. + pub additional_categories: Vec, + + /// An optional link to the project's license page + pub license_url: Option, + /// An optional list of all donation links the project has + #[validate(custom( + function = "crate::util::validate::validate_url_hashmap_values" + ))] + #[serde(default)] + pub link_urls: HashMap, + + /// An optional boolean. If true, the project will be created as a draft. + pub is_draft: Option, + + /// The license id that the project follows + pub license_id: String, + + #[validate(length(max = 64))] + #[validate] + /// The multipart names of the gallery items to upload + pub gallery_items: Option>, + #[serde(default = "default_requested_status")] + /// The status of the mod to be set once it is approved + pub requested_status: ProjectStatus, + + // Associations to uploaded images in body/description + #[validate(length(max = 10))] + #[serde(default)] + pub uploaded_images: Vec, + + /// The id of the organization to create the project in + pub organization_id: Option, +} + +#[derive(Serialize, Deserialize, Validate, Clone)] +pub struct NewGalleryItem { + /// The name of the multipart item where the gallery media is located + pub item: String, + /// Whether the gallery item should show in search or not + pub featured: bool, + #[validate(length(min = 1, max = 2048))] + /// The title of the gallery item + pub name: Option, + #[validate(length(min = 1, max = 2048))] + /// The description of the gallery item + pub description: Option, + pub ordering: i64, +} + +pub struct UploadedFile { + pub file_id: String, + pub file_name: String, +} + +pub async fn undo_uploads( + file_host: &dyn FileHost, + uploaded_files: &[UploadedFile], +) -> Result<(), CreateError> { + for file in uploaded_files { + file_host + .delete_file_version(&file.file_id, &file.file_name) + .await?; + } + Ok(()) +} + +pub async fn project_create( + req: HttpRequest, + mut payload: Multipart, + client: Data, + redis: Data, + file_host: Data>, + session_queue: Data, +) -> Result { + let mut transaction = client.begin().await?; + let mut uploaded_files = Vec::new(); + + let result = project_create_inner( + req, + &mut payload, + &mut transaction, + &***file_host, + &mut uploaded_files, + &client, + &redis, + &session_queue, + ) + .await; + + if result.is_err() { + let undo_result = undo_uploads(&***file_host, &uploaded_files).await; + let rollback_result = transaction.rollback().await; + + undo_result?; + if let Err(e) = rollback_result { + return Err(e.into()); + } + } else { + transaction.commit().await?; + } + + result +} +/* + +Project Creation Steps: +Get logged in user + Must match the author in the version creation + +1. Data + - Gets "data" field from multipart form; must be first + - Verification: string lengths + - Create versions + - Some shared logic with version creation + - Create list of VersionBuilders + - Create ProjectBuilder + +2. Upload + - Icon: check file format & size + - Upload to backblaze & record URL + - Project files + - Check for matching version + - File size limits? + - Check file type + - Eventually, malware scan + - Upload to backblaze & create VersionFileBuilder + - + +3. Creation + - Database stuff + - Add project data to indexing queue +*/ + +#[allow(clippy::too_many_arguments)] +async fn project_create_inner( + req: HttpRequest, + payload: &mut Multipart, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + file_host: &dyn FileHost, + uploaded_files: &mut Vec, + pool: &PgPool, + redis: &RedisPool, + session_queue: &AuthQueue, +) -> Result { + // The base URL for files uploaded to backblaze + let cdn_url = dotenvy::var("CDN_URL")?; + + // The currently logged in user + let current_user = get_user_from_headers( + &req, + pool, + redis, + session_queue, + Some(&[Scopes::PROJECT_CREATE]), + ) + .await? + .1; + + let project_id: ProjectId = + models::generate_project_id(transaction).await?.into(); + let all_loaders = + models::loader_fields::Loader::list(&mut **transaction, redis).await?; + + let project_create_data: ProjectCreateData; + let mut versions; + let mut versions_map = std::collections::HashMap::new(); + let mut gallery_urls = Vec::new(); + { + // The first multipart field must be named "data" and contain a + // JSON `ProjectCreateData` object. + + let mut field = payload + .next() + .await + .map(|m| m.map_err(CreateError::MultipartError)) + .unwrap_or_else(|| { + Err(CreateError::MissingValueError(String::from( + "No `data` field in multipart upload", + ))) + })?; + + let content_disposition = field.content_disposition(); + let name = content_disposition.get_name().ok_or_else(|| { + CreateError::MissingValueError(String::from("Missing content name")) + })?; + + if name != "data" { + return Err(CreateError::InvalidInput(String::from( + "`data` field must come before file fields", + ))); + } + + let mut data = Vec::new(); + while let Some(chunk) = field.next().await { + data.extend_from_slice( + &chunk.map_err(CreateError::MultipartError)?, + ); + } + let create_data: ProjectCreateData = serde_json::from_slice(&data)?; + + create_data.validate().map_err(|err| { + CreateError::InvalidInput(validation_errors_to_string(err, None)) + })?; + + let slug_project_id_option: Option = + serde_json::from_str(&format!("\"{}\"", create_data.slug)).ok(); + + if let Some(slug_project_id) = slug_project_id_option { + let slug_project_id: models::ids::ProjectId = + slug_project_id.into(); + let results = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1) + ", + slug_project_id as models::ids::ProjectId + ) + .fetch_one(&mut **transaction) + .await + .map_err(|e| CreateError::DatabaseError(e.into()))?; + + if results.exists.unwrap_or(false) { + return Err(CreateError::SlugCollision); + } + } + + { + let results = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM mods WHERE slug = LOWER($1)) + ", + create_data.slug + ) + .fetch_one(&mut **transaction) + .await + .map_err(|e| CreateError::DatabaseError(e.into()))?; + + if results.exists.unwrap_or(false) { + return Err(CreateError::SlugCollision); + } + } + + // Create VersionBuilders for the versions specified in `initial_versions` + versions = Vec::with_capacity(create_data.initial_versions.len()); + for (i, data) in create_data.initial_versions.iter().enumerate() { + // Create a map of multipart field names to version indices + for name in &data.file_parts { + if versions_map.insert(name.to_owned(), i).is_some() { + // If the name is already used + return Err(CreateError::InvalidInput(String::from( + "Duplicate multipart field name", + ))); + } + } + versions.push( + create_initial_version( + data, + project_id, + current_user.id, + &all_loaders, + transaction, + redis, + ) + .await?, + ); + } + + project_create_data = create_data; + } + + let mut icon_data = None; + + let mut error = None; + while let Some(item) = payload.next().await { + let mut field: Field = item?; + + if error.is_some() { + continue; + } + + let result = async { + let content_disposition = field.content_disposition().clone(); + + let name = content_disposition.get_name().ok_or_else(|| { + CreateError::MissingValueError("Missing content name".to_string()) + })?; + + let (file_name, file_extension) = + super::version_creation::get_name_ext(&content_disposition)?; + + if name == "icon" { + if icon_data.is_some() { + return Err(CreateError::InvalidInput(String::from( + "Projects can only have one icon", + ))); + } + // Upload the icon to the cdn + icon_data = Some( + process_icon_upload( + uploaded_files, + project_id.0, + file_extension, + file_host, + field, + ) + .await?, + ); + return Ok(()); + } + if let Some(gallery_items) = &project_create_data.gallery_items { + if gallery_items.iter().filter(|a| a.featured).count() > 1 { + return Err(CreateError::InvalidInput(String::from( + "Only one gallery image can be featured.", + ))); + } + if let Some(item) = gallery_items.iter().find(|x| x.item == name) { + let data = read_from_field( + &mut field, + 2 * (1 << 20), + "Gallery image exceeds the maximum of 2MiB.", + ) + .await?; + + let (_, file_extension) = + super::version_creation::get_name_ext(&content_disposition)?; + + let url = format!("data/{project_id}/images"); + let upload_result = upload_image_optimized( + &url, + data.freeze(), + file_extension, + Some(350), + Some(1.0), + file_host, + ) + .await + .map_err(|e| CreateError::InvalidIconFormat(e.to_string()))?; + + uploaded_files.push(UploadedFile { + file_id: upload_result.raw_url_path.clone(), + file_name: upload_result.raw_url_path, + }); + gallery_urls.push(crate::models::projects::GalleryItem { + url: upload_result.url, + raw_url: upload_result.raw_url, + featured: item.featured, + name: item.name.clone(), + description: item.description.clone(), + created: Utc::now(), + ordering: item.ordering, + }); + + return Ok(()); + } + } + let index = if let Some(i) = versions_map.get(name) { + *i + } else { + return Err(CreateError::InvalidInput(format!( + "File `{file_name}` (field {name}) isn't specified in the versions data" + ))); + }; + // `index` is always valid for these lists + let created_version = versions.get_mut(index).unwrap(); + let version_data = project_create_data.initial_versions.get(index).unwrap(); + // TODO: maybe redundant is this calculation done elsewhere? + + let existing_file_names = created_version + .files + .iter() + .map(|x| x.filename.clone()) + .collect(); + // Upload the new jar file + super::version_creation::upload_file( + &mut field, + file_host, + version_data.file_parts.len(), + uploaded_files, + &mut created_version.files, + &mut created_version.dependencies, + &cdn_url, + &content_disposition, + project_id, + created_version.version_id.into(), + &created_version.version_fields, + version_data.loaders.clone(), + version_data.primary_file.is_some(), + version_data.primary_file.as_deref() == Some(name), + None, + existing_file_names, + transaction, + redis, + ) + .await?; + + Ok(()) + } + .await; + + if result.is_err() { + error = result.err(); + } + } + + if let Some(error) = error { + return Err(error); + } + + { + // Check to make sure that all specified files were uploaded + for (version_data, builder) in project_create_data + .initial_versions + .iter() + .zip(versions.iter()) + { + if version_data.file_parts.len() != builder.files.len() { + return Err(CreateError::InvalidInput(String::from( + "Some files were specified in initial_versions but not uploaded", + ))); + } + } + + // Convert the list of category names to actual categories + let mut categories = + Vec::with_capacity(project_create_data.categories.len()); + for category in &project_create_data.categories { + let ids = models::categories::Category::get_ids( + category, + &mut **transaction, + ) + .await?; + if ids.is_empty() { + return Err(CreateError::InvalidCategory(category.clone())); + } + + // TODO: We should filter out categories that don't match the project type of any of the versions + // ie: if mod and modpack both share a name this should only have modpack if it only has a modpack as a version + categories.extend(ids.values()); + } + + let mut additional_categories = + Vec::with_capacity(project_create_data.additional_categories.len()); + for category in &project_create_data.additional_categories { + let ids = models::categories::Category::get_ids( + category, + &mut **transaction, + ) + .await?; + if ids.is_empty() { + return Err(CreateError::InvalidCategory(category.clone())); + } + // TODO: We should filter out categories that don't match the project type of any of the versions + // ie: if mod and modpack both share a name this should only have modpack if it only has a modpack as a version + additional_categories.extend(ids.values()); + } + + let mut members = vec![]; + + if let Some(organization_id) = project_create_data.organization_id { + let org = models::Organization::get_id( + organization_id.into(), + pool, + redis, + ) + .await? + .ok_or_else(|| { + CreateError::InvalidInput( + "Invalid organization ID specified!".to_string(), + ) + })?; + + let team_member = models::TeamMember::get_from_user_id( + org.team_id, + current_user.id.into(), + pool, + ) + .await?; + + let perms = OrganizationPermissions::get_permissions_by_role( + ¤t_user.role, + &team_member, + ); + + if !perms + .map(|x| x.contains(OrganizationPermissions::ADD_PROJECT)) + .unwrap_or(false) + { + return Err(CreateError::CustomAuthenticationError( + "You do not have the permissions to create projects in this organization!" + .to_string(), + )); + } + } else { + members.push(models::team_item::TeamMemberBuilder { + user_id: current_user.id.into(), + role: crate::models::teams::DEFAULT_ROLE.to_owned(), + is_owner: true, + permissions: ProjectPermissions::all(), + organization_permissions: None, + accepted: true, + payouts_split: Decimal::ONE_HUNDRED, + ordering: 0, + }) + } + let team = models::team_item::TeamBuilder { members }; + + let team_id = team.insert(&mut *transaction).await?; + + let status; + if project_create_data.is_draft.unwrap_or(false) { + status = ProjectStatus::Draft; + } else { + status = ProjectStatus::Processing; + if project_create_data.initial_versions.is_empty() { + return Err(CreateError::InvalidInput(String::from( + "Project submitted for review with no initial versions", + ))); + } + } + + let license_id = spdx::Expression::parse( + &project_create_data.license_id, + ) + .map_err(|err| { + CreateError::InvalidInput(format!( + "Invalid SPDX license identifier: {err}" + )) + })?; + + let mut link_urls = vec![]; + + let link_platforms = + models::categories::LinkPlatform::list(&mut **transaction, redis) + .await?; + for (platform, url) in &project_create_data.link_urls { + let platform_id = models::categories::LinkPlatform::get_id( + platform, + &mut **transaction, + ) + .await? + .ok_or_else(|| { + CreateError::InvalidInput(format!( + "Link platform {} does not exist.", + platform.clone() + )) + })?; + let link_platform = link_platforms + .iter() + .find(|x| x.id == platform_id) + .ok_or_else(|| { + CreateError::InvalidInput(format!( + "Link platform {} does not exist.", + platform.clone() + )) + })?; + link_urls.push(models::project_item::LinkUrl { + platform_id, + platform_name: link_platform.name.clone(), + url: url.clone(), + donation: link_platform.donation, + }) + } + + let project_builder_actual = models::project_item::ProjectBuilder { + project_id: project_id.into(), + team_id, + organization_id: project_create_data + .organization_id + .map(|x| x.into()), + name: project_create_data.name, + summary: project_create_data.summary, + description: project_create_data.description, + icon_url: icon_data.clone().map(|x| x.0), + raw_icon_url: icon_data.clone().map(|x| x.1), + + license_url: project_create_data.license_url, + categories, + additional_categories, + initial_versions: versions, + status, + requested_status: Some(project_create_data.requested_status), + license: license_id.to_string(), + slug: Some(project_create_data.slug), + link_urls, + gallery_items: gallery_urls + .iter() + .map(|x| models::project_item::GalleryItem { + image_url: x.url.clone(), + raw_image_url: x.raw_url.clone(), + featured: x.featured, + name: x.name.clone(), + description: x.description.clone(), + created: x.created, + ordering: x.ordering, + }) + .collect(), + color: icon_data.and_then(|x| x.2), + monetization_status: MonetizationStatus::Monetized, + }; + let project_builder = project_builder_actual.clone(); + + let now = Utc::now(); + + let id = project_builder_actual.insert(&mut *transaction).await?; + User::clear_project_cache(&[current_user.id.into()], redis).await?; + + for image_id in project_create_data.uploaded_images { + if let Some(db_image) = image_item::Image::get( + image_id.into(), + &mut **transaction, + redis, + ) + .await? + { + let image: Image = db_image.into(); + if !matches!(image.context, ImageContext::Project { .. }) + || image.context.inner_id().is_some() + { + return Err(CreateError::InvalidInput(format!( + "Image {} is not unused and in the 'project' context", + image_id + ))); + } + + sqlx::query!( + " + UPDATE uploaded_images + SET mod_id = $1 + WHERE id = $2 + ", + id as models::ids::ProjectId, + image_id.0 as i64 + ) + .execute(&mut **transaction) + .await?; + + image_item::Image::clear_cache(image.id.into(), redis).await?; + } else { + return Err(CreateError::InvalidInput(format!( + "Image {} does not exist", + image_id + ))); + } + } + + let thread_id = ThreadBuilder { + type_: ThreadType::Project, + members: vec![], + project_id: Some(id), + report_id: None, + } + .insert(&mut *transaction) + .await?; + + let loaders = project_builder + .initial_versions + .iter() + .flat_map(|v| v.loaders.clone()) + .unique() + .collect::>(); + let (project_types, games) = Loader::list(&mut **transaction, redis) + .await? + .into_iter() + .fold( + (Vec::new(), Vec::new()), + |(mut project_types, mut games), loader| { + if loaders.contains(&loader.id) { + project_types.extend(loader.supported_project_types); + games.extend(loader.supported_games); + } + (project_types, games) + }, + ); + + let response = crate::models::projects::Project { + id: project_id, + slug: project_builder.slug.clone(), + project_types, + games, + team_id: team_id.into(), + organization: project_create_data.organization_id, + name: project_builder.name.clone(), + summary: project_builder.summary.clone(), + description: project_builder.description.clone(), + published: now, + updated: now, + approved: None, + queued: None, + status, + requested_status: project_builder.requested_status, + moderator_message: None, + license: License { + id: project_create_data.license_id.clone(), + name: "".to_string(), + url: project_builder.license_url.clone(), + }, + downloads: 0, + followers: 0, + categories: project_create_data.categories, + additional_categories: project_create_data.additional_categories, + loaders: vec![], + versions: project_builder + .initial_versions + .iter() + .map(|v| v.version_id.into()) + .collect::>(), + icon_url: project_builder.icon_url.clone(), + link_urls: project_builder + .link_urls + .clone() + .into_iter() + .map(|x| (x.platform_name.clone(), Link::from(x))) + .collect(), + gallery: gallery_urls, + color: project_builder.color, + thread_id: thread_id.into(), + monetization_status: MonetizationStatus::Monetized, + fields: HashMap::new(), // Fields instantiate to empty + }; + + Ok(HttpResponse::Ok().json(response)) + } +} + +async fn create_initial_version( + version_data: &InitialVersionData, + project_id: ProjectId, + author: UserId, + all_loaders: &[models::loader_fields::Loader], + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &RedisPool, +) -> Result { + if version_data.project_id.is_some() { + return Err(CreateError::InvalidInput(String::from( + "Found project id in initial version for new project", + ))); + } + + version_data.validate().map_err(|err| { + CreateError::ValidationError(validation_errors_to_string(err, None)) + })?; + + // Randomly generate a new id to be used for the version + let version_id: VersionId = + models::generate_version_id(transaction).await?.into(); + + let loaders = version_data + .loaders + .iter() + .map(|x| { + all_loaders + .iter() + .find(|y| y.loader == x.0) + .ok_or_else(|| CreateError::InvalidLoader(x.0.clone())) + .map(|y| y.id) + }) + .collect::, CreateError>>()?; + + let loader_fields = + LoaderField::get_fields(&loaders, &mut **transaction, redis).await?; + let mut loader_field_enum_values = + LoaderFieldEnumValue::list_many_loader_fields( + &loader_fields, + &mut **transaction, + redis, + ) + .await?; + + let version_fields = try_create_version_fields( + version_id, + &version_data.fields, + &loader_fields, + &mut loader_field_enum_values, + )?; + + let dependencies = version_data + .dependencies + .iter() + .map(|d| models::version_item::DependencyBuilder { + version_id: d.version_id.map(|x| x.into()), + project_id: d.project_id.map(|x| x.into()), + dependency_type: d.dependency_type.to_string(), + file_name: None, + }) + .collect::>(); + + let version = models::version_item::VersionBuilder { + version_id: version_id.into(), + project_id: project_id.into(), + author_id: author.into(), + name: version_data.version_title.clone(), + version_number: version_data.version_number.clone(), + changelog: version_data.version_body.clone().unwrap_or_default(), + files: Vec::new(), + dependencies, + loaders, + version_fields, + featured: version_data.featured, + status: VersionStatus::Listed, + version_type: version_data.release_channel.to_string(), + requested_status: None, + ordering: version_data.ordering, + }; + + Ok(version) +} + +async fn process_icon_upload( + uploaded_files: &mut Vec, + id: u64, + file_extension: &str, + file_host: &dyn FileHost, + mut field: Field, +) -> Result<(String, String, Option), CreateError> { + let data = read_from_field( + &mut field, + 262144, + "Icons must be smaller than 256KiB", + ) + .await?; + let upload_result = crate::util::img::upload_image_optimized( + &format!("data/{}", to_base62(id)), + data.freeze(), + file_extension, + Some(96), + Some(1.0), + file_host, + ) + .await + .map_err(|e| CreateError::InvalidIconFormat(e.to_string()))?; + + uploaded_files.push(UploadedFile { + file_id: upload_result.raw_url_path.clone(), + file_name: upload_result.raw_url_path, + }); + + uploaded_files.push(UploadedFile { + file_id: upload_result.url_path.clone(), + file_name: upload_result.url_path, + }); + + Ok(( + upload_result.url, + upload_result.raw_url, + upload_result.color, + )) +} diff --git a/apps/labrinth/src/routes/v3/projects.rs b/apps/labrinth/src/routes/v3/projects.rs new file mode 100644 index 000000000..5e7277ec3 --- /dev/null +++ b/apps/labrinth/src/routes/v3/projects.rs @@ -0,0 +1,2411 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use crate::auth::checks::{filter_visible_versions, is_visible_project}; +use crate::auth::{filter_visible_projects, get_user_from_headers}; +use crate::database::models::notification_item::NotificationBuilder; +use crate::database::models::project_item::{GalleryItem, ModCategory}; +use crate::database::models::thread_item::ThreadMessageBuilder; +use crate::database::models::{ids as db_ids, image_item, TeamMember}; +use crate::database::redis::RedisPool; +use crate::database::{self, models as db_models}; +use crate::file_hosting::FileHost; +use crate::models; +use crate::models::ids::base62_impl::parse_base62; +use crate::models::images::ImageContext; +use crate::models::notifications::NotificationBody; +use crate::models::pats::Scopes; +use crate::models::projects::{ + MonetizationStatus, Project, ProjectId, ProjectStatus, SearchRequest, +}; +use crate::models::teams::ProjectPermissions; +use crate::models::threads::MessageBody; +use crate::queue::moderation::AutomatedModerationQueue; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use crate::search::indexing::remove_documents; +use crate::search::{search_for_project, SearchConfig, SearchError}; +use crate::util::img; +use crate::util::img::{delete_old_images, upload_image_optimized}; +use crate::util::routes::read_from_payload; +use crate::util::validate::validation_errors_to_string; +use actix_web::{web, HttpRequest, HttpResponse}; +use chrono::Utc; +use futures::TryStreamExt; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use sqlx::PgPool; +use validator::Validate; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.route("search", web::get().to(project_search)); + cfg.route("projects", web::get().to(projects_get)); + cfg.route("projects", web::patch().to(projects_edit)); + cfg.route("projects_random", web::get().to(random_projects_get)); + + cfg.service( + web::scope("project") + .route("{id}", web::get().to(project_get)) + .route("{id}/check", web::get().to(project_get_check)) + .route("{id}", web::delete().to(project_delete)) + .route("{id}", web::patch().to(project_edit)) + .route("{id}/icon", web::patch().to(project_icon_edit)) + .route("{id}/icon", web::delete().to(delete_project_icon)) + .route("{id}/gallery", web::post().to(add_gallery_item)) + .route("{id}/gallery", web::patch().to(edit_gallery_item)) + .route("{id}/gallery", web::delete().to(delete_gallery_item)) + .route("{id}/follow", web::post().to(project_follow)) + .route("{id}/follow", web::delete().to(project_unfollow)) + .route("{id}/organization", web::get().to(project_get_organization)) + .service( + web::scope("{project_id}") + .route( + "members", + web::get().to(super::teams::team_members_get_project), + ) + .route( + "version", + web::get().to(super::versions::version_list), + ) + .route( + "version/{slug}", + web::get().to(super::versions::version_project_get), + ) + .route("dependencies", web::get().to(dependency_list)), + ), + ); +} + +#[derive(Deserialize, Validate)] +pub struct RandomProjects { + #[validate(range(min = 1, max = 100))] + pub count: u32, +} + +pub async fn random_projects_get( + web::Query(count): web::Query, + pool: web::Data, + redis: web::Data, +) -> Result { + count.validate().map_err(|err| { + ApiError::Validation(validation_errors_to_string(err, None)) + })?; + + let project_ids = sqlx::query!( + " + SELECT id FROM mods TABLESAMPLE SYSTEM_ROWS($1) WHERE status = ANY($2) + ", + count.count as i32, + &*crate::models::projects::ProjectStatus::iterator() + .filter(|x| x.is_searchable()) + .map(|x| x.to_string()) + .collect::>(), + ) + .fetch(&**pool) + .map_ok(|m| db_ids::ProjectId(m.id)) + .try_collect::>() + .await?; + + let projects_data = + db_models::Project::get_many_ids(&project_ids, &**pool, &redis) + .await? + .into_iter() + .map(Project::from) + .collect::>(); + + Ok(HttpResponse::Ok().json(projects_data)) +} + +#[derive(Serialize, Deserialize)] +pub struct ProjectIds { + pub ids: String, +} + +pub async fn projects_get( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let ids = serde_json::from_str::>(&ids.ids)?; + let projects_data = + db_models::Project::get_many(&ids, &**pool, &redis).await?; + + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let projects = + filter_visible_projects(projects_data, &user_option, &pool, false) + .await?; + + Ok(HttpResponse::Ok().json(projects)) +} + +pub async fn project_get( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let string = info.into_inner().0; + + let project_data = + db_models::Project::get(&string, &**pool, &redis).await?; + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + if let Some(data) = project_data { + if is_visible_project(&data.inner, &user_option, &pool, false).await? { + return Ok(HttpResponse::Ok().json(Project::from(data))); + } + } + Err(ApiError::NotFound) +} + +#[derive(Serialize, Deserialize, Validate)] +pub struct EditProject { + #[validate( + length(min = 3, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + pub name: Option, + #[validate(length(min = 3, max = 256))] + pub summary: Option, + #[validate(length(max = 65536))] + pub description: Option, + #[validate(length(max = 3))] + pub categories: Option>, + #[validate(length(max = 256))] + pub additional_categories: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate( + custom(function = "crate::util::validate::validate_url"), + length(max = 2048) + )] + pub license_url: Option>, + #[validate(custom( + function = "crate::util::validate::validate_url_hashmap_optional_values" + ))] + // (leave url empty to delete) + pub link_urls: Option>>, + pub license_id: Option, + #[validate( + length(min = 3, max = 64), + regex = "crate::util::validate::RE_URL_SAFE" + )] + pub slug: Option, + pub status: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + pub requested_status: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate(length(max = 2000))] + pub moderation_message: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate(length(max = 65536))] + pub moderation_message_body: Option>, + pub monetization_status: Option, +} + +#[allow(clippy::too_many_arguments)] +pub async fn project_edit( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + search_config: web::Data, + new_project: web::Json, + redis: web::Data, + session_queue: web::Data, + moderation_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE]), + ) + .await? + .1; + + new_project.validate().map_err(|err| { + ApiError::Validation(validation_errors_to_string(err, None)) + })?; + + let string = info.into_inner().0; + let result = db_models::Project::get(&string, &**pool, &redis).await?; + if let Some(project_item) = result { + let id = project_item.inner.id; + + let (team_member, organization_team_member) = + db_models::TeamMember::get_for_project_permissions( + &project_item.inner, + user.id.into(), + &**pool, + ) + .await?; + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ); + + if let Some(perms) = permissions { + let mut transaction = pool.begin().await?; + + if let Some(name) = &new_project.name { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the name of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET name = $1 + WHERE (id = $2) + ", + name.trim(), + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(summary) = &new_project.summary { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the summary of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET summary = $1 + WHERE (id = $2) + ", + summary, + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(status) = &new_project.status { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the status of this project!" + .to_string(), + )); + } + + if !(user.role.is_mod() + || !project_item.inner.status.is_approved() + && status == &ProjectStatus::Processing + || project_item.inner.status.is_approved() + && status.can_be_requested()) + { + return Err(ApiError::CustomAuthentication( + "You don't have permission to set this status!" + .to_string(), + )); + } + + if status == &ProjectStatus::Processing { + if project_item.versions.is_empty() { + return Err(ApiError::InvalidInput(String::from( + "Project submitted for review with no initial versions", + ))); + } + + sqlx::query!( + " + UPDATE mods + SET moderation_message = NULL, moderation_message_body = NULL, queued = NOW() + WHERE (id = $1) + ", + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + + moderation_queue + .projects + .insert(project_item.inner.id.into()); + } + + if status.is_approved() + && !project_item.inner.status.is_approved() + { + sqlx::query!( + " + UPDATE mods + SET approved = NOW() + WHERE id = $1 AND approved IS NULL + ", + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + if status.is_searchable() && !project_item.inner.webhook_sent { + if let Ok(webhook_url) = + dotenvy::var("PUBLIC_DISCORD_WEBHOOK") + { + crate::util::webhook::send_discord_webhook( + project_item.inner.id.into(), + &pool, + &redis, + webhook_url, + None, + ) + .await + .ok(); + + sqlx::query!( + " + UPDATE mods + SET webhook_sent = TRUE + WHERE id = $1 + ", + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + } + + if user.role.is_mod() { + if let Ok(webhook_url) = + dotenvy::var("MODERATION_SLACK_WEBHOOK") + { + crate::util::webhook::send_slack_webhook( + project_item.inner.id.into(), + &pool, + &redis, + webhook_url, + Some( + format!( + "*<{}/user/{}|{}>* changed project status from *{}* to *{}*", + dotenvy::var("SITE_URL")?, + user.username, + user.username, + &project_item.inner.status.as_friendly_str(), + status.as_friendly_str(), + ) + .to_string(), + ), + ) + .await + .ok(); + } + } + + if team_member.map(|x| !x.accepted).unwrap_or(true) { + let notified_members = sqlx::query!( + " + SELECT tm.user_id id + FROM team_members tm + WHERE tm.team_id = $1 AND tm.accepted + ", + project_item.inner.team_id as db_ids::TeamId + ) + .fetch(&mut *transaction) + .map_ok(|c| db_models::UserId(c.id)) + .try_collect::>() + .await?; + + NotificationBuilder { + body: NotificationBody::StatusChange { + project_id: project_item.inner.id.into(), + old_status: project_item.inner.status, + new_status: *status, + }, + } + .insert_many(notified_members, &mut transaction, &redis) + .await?; + } + + ThreadMessageBuilder { + author_id: Some(user.id.into()), + body: MessageBody::StatusChange { + new_status: *status, + old_status: project_item.inner.status, + }, + thread_id: project_item.thread_id, + hide_identity: user.role.is_mod(), + } + .insert(&mut transaction) + .await?; + + sqlx::query!( + " + UPDATE mods + SET status = $1 + WHERE (id = $2) + ", + status.as_str(), + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + + if project_item.inner.status.is_searchable() + && !status.is_searchable() + { + remove_documents( + &project_item + .versions + .into_iter() + .map(|x| x.into()) + .collect::>(), + &search_config, + ) + .await?; + } + } + + if let Some(requested_status) = &new_project.requested_status { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the requested status of this project!" + .to_string(), + )); + } + + if !requested_status + .map(|x| x.can_be_requested()) + .unwrap_or(true) + { + return Err(ApiError::InvalidInput(String::from( + "Specified status cannot be requested!", + ))); + } + + sqlx::query!( + " + UPDATE mods + SET requested_status = $1 + WHERE (id = $2) + ", + requested_status.map(|x| x.as_str()), + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if perms.contains(ProjectPermissions::EDIT_DETAILS) { + if new_project.categories.is_some() { + sqlx::query!( + " + DELETE FROM mods_categories + WHERE joining_mod_id = $1 AND is_additional = FALSE + ", + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if new_project.additional_categories.is_some() { + sqlx::query!( + " + DELETE FROM mods_categories + WHERE joining_mod_id = $1 AND is_additional = TRUE + ", + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + } + + if let Some(categories) = &new_project.categories { + edit_project_categories( + categories, + &perms, + id as db_ids::ProjectId, + false, + &mut transaction, + ) + .await?; + } + + if let Some(categories) = &new_project.additional_categories { + edit_project_categories( + categories, + &perms, + id as db_ids::ProjectId, + true, + &mut transaction, + ) + .await?; + } + + if let Some(license_url) = &new_project.license_url { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the license URL of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET license_url = $1 + WHERE (id = $2) + ", + license_url.as_deref(), + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(slug) = &new_project.slug { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the slug of this project!" + .to_string(), + )); + } + + let slug_project_id_option: Option = + parse_base62(slug).ok(); + if let Some(slug_project_id) = slug_project_id_option { + let results = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM mods WHERE id=$1) + ", + slug_project_id as i64 + ) + .fetch_one(&mut *transaction) + .await?; + + if results.exists.unwrap_or(true) { + return Err(ApiError::InvalidInput( + "Slug collides with other project's id!" + .to_string(), + )); + } + } + + // Make sure the new slug is different from the old one + // We are able to unwrap here because the slug is always set + if !slug.eq(&project_item + .inner + .slug + .clone() + .unwrap_or_default()) + { + let results = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM mods WHERE slug = LOWER($1)) + ", + slug + ) + .fetch_one(&mut *transaction) + .await?; + + if results.exists.unwrap_or(true) { + return Err(ApiError::InvalidInput( + "Slug collides with other project's id!" + .to_string(), + )); + } + } + + sqlx::query!( + " + UPDATE mods + SET slug = LOWER($1) + WHERE (id = $2) + ", + Some(slug), + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(license) = &new_project.license_id { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the license of this project!" + .to_string(), + )); + } + + let mut license = license.clone(); + + if license.to_lowercase() == "arr" { + license = models::projects::DEFAULT_LICENSE_ID.to_string(); + } + + spdx::Expression::parse(&license).map_err(|err| { + ApiError::InvalidInput(format!( + "Invalid SPDX license identifier: {err}" + )) + })?; + + sqlx::query!( + " + UPDATE mods + SET license = $1 + WHERE (id = $2) + ", + license, + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + if let Some(links) = &new_project.link_urls { + if !links.is_empty() { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the links of this project!" + .to_string(), + )); + } + + let ids_to_delete = links + .iter() + .map(|(name, _)| name.clone()) + .collect::>(); + // Deletes all links from hashmap- either will be deleted or be replaced + sqlx::query!( + " + DELETE FROM mods_links + WHERE joining_mod_id = $1 AND joining_platform_id IN ( + SELECT id FROM link_platforms WHERE name = ANY($2) + ) + ", + id as db_ids::ProjectId, + &ids_to_delete + ) + .execute(&mut *transaction) + .await?; + + for (platform, url) in links { + if let Some(url) = url { + let platform_id = + db_models::categories::LinkPlatform::get_id( + platform, + &mut *transaction, + ) + .await? + .ok_or_else( + || { + ApiError::InvalidInput(format!( + "Platform {} does not exist.", + platform.clone() + )) + }, + )?; + sqlx::query!( + " + INSERT INTO mods_links (joining_mod_id, joining_platform_id, url) + VALUES ($1, $2, $3) + ", + id as db_ids::ProjectId, + platform_id as db_ids::LinkPlatformId, + url + ) + .execute(&mut *transaction) + .await?; + } + } + } + } + if let Some(moderation_message) = &new_project.moderation_message { + if !user.role.is_mod() + && (!project_item.inner.status.is_approved() + || moderation_message.is_some()) + { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the moderation message of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET moderation_message = $1 + WHERE (id = $2) + ", + moderation_message.as_deref(), + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(moderation_message_body) = + &new_project.moderation_message_body + { + if !user.role.is_mod() + && (!project_item.inner.status.is_approved() + || moderation_message_body.is_some()) + { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the moderation message body of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET moderation_message_body = $1 + WHERE (id = $2) + ", + moderation_message_body.as_deref(), + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(description) = &new_project.description { + if !perms.contains(ProjectPermissions::EDIT_BODY) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the description (body) of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET description = $1 + WHERE (id = $2) + ", + description, + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(monetization_status) = &new_project.monetization_status + { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the monetization status of this project!" + .to_string(), + )); + } + + if (*monetization_status + == MonetizationStatus::ForceDemonetized + || project_item.inner.monetization_status + == MonetizationStatus::ForceDemonetized) + && !user.role.is_mod() + { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the monetization status of this project!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE mods + SET monetization_status = $1 + WHERE (id = $2) + ", + monetization_status.as_str(), + id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + // check new description and body for links to associated images + // if they no longer exist in the description or body, delete them + let checkable_strings: Vec<&str> = + vec![&new_project.description, &new_project.summary] + .into_iter() + .filter_map(|x| x.as_ref().map(|y| y.as_str())) + .collect(); + + let context = ImageContext::Project { + project_id: Some(id.into()), + }; + + img::delete_unused_images( + context, + checkable_strings, + &mut transaction, + &redis, + ) + .await?; + + transaction.commit().await?; + db_models::Project::clear_cache( + project_item.inner.id, + project_item.inner.slug, + None, + &redis, + ) + .await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::CustomAuthentication( + "You do not have permission to edit this project!".to_string(), + )) + } + } else { + Err(ApiError::NotFound) + } +} + +pub async fn edit_project_categories( + categories: &Vec, + perms: &ProjectPermissions, + project_id: db_ids::ProjectId, + additional: bool, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result<(), ApiError> { + if !perms.contains(ProjectPermissions::EDIT_DETAILS) { + let additional_str = if additional { "additional " } else { "" }; + return Err(ApiError::CustomAuthentication(format!( + "You do not have the permissions to edit the {additional_str}categories of this project!" + ))); + } + + let mut mod_categories = Vec::new(); + for category in categories { + let category_ids = db_models::categories::Category::get_ids( + category, + &mut **transaction, + ) + .await?; + // TODO: We should filter out categories that don't match the project type of any of the versions + // ie: if mod and modpack both share a name this should only have modpack if it only has a modpack as a version + + let mcategories = category_ids + .values() + .map(|x| ModCategory::new(project_id, *x, additional)) + .collect::>(); + mod_categories.extend(mcategories); + } + ModCategory::insert_many(mod_categories, &mut *transaction).await?; + + Ok(()) +} + +// TODO: Re-add this if we want to match v3 Projects structure to v3 Search Result structure, otherwise, delete +// #[derive(Serialize, Deserialize)] +// pub struct ReturnSearchResults { +// pub hits: Vec, +// pub page: usize, +// pub hits_per_page: usize, +// pub total_hits: usize, +// } + +pub async fn project_search( + web::Query(info): web::Query, + config: web::Data, +) -> Result { + let results = search_for_project(&info, &config).await?; + + // TODO: add this back + // let results = ReturnSearchResults { + // hits: results + // .hits + // .into_iter() + // .filter_map(Project::from_search) + // .collect::>(), + // page: results.page, + // hits_per_page: results.hits_per_page, + // total_hits: results.total_hits, + // }; + + Ok(HttpResponse::Ok().json(results)) +} + +//checks the validity of a project id or slug +pub async fn project_get_check( + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, +) -> Result { + let slug = info.into_inner().0; + + let project_data = db_models::Project::get(&slug, &**pool, &redis).await?; + + if let Some(project) = project_data { + Ok(HttpResponse::Ok().json(json! ({ + "id": models::ids::ProjectId::from(project.inner.id) + }))) + } else { + Err(ApiError::NotFound) + } +} + +#[derive(Serialize, Deserialize)] +pub struct DependencyInfo { + pub projects: Vec, + pub versions: Vec, +} + +pub async fn dependency_list( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let string = info.into_inner().0; + + let result = db_models::Project::get(&string, &**pool, &redis).await?; + + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + if let Some(project) = result { + if !is_visible_project(&project.inner, &user_option, &pool, false) + .await? + { + return Err(ApiError::NotFound); + } + + let dependencies = database::Project::get_dependencies( + project.inner.id, + &**pool, + &redis, + ) + .await?; + let project_ids = dependencies + .iter() + .filter_map(|x| { + if x.0.is_none() { + if let Some(mod_dependency_id) = x.2 { + Some(mod_dependency_id) + } else { + x.1 + } + } else { + x.1 + } + }) + .unique() + .collect::>(); + + let dep_version_ids = dependencies + .iter() + .filter_map(|x| x.0) + .unique() + .collect::>(); + let (projects_result, versions_result) = futures::future::try_join( + database::Project::get_many_ids(&project_ids, &**pool, &redis), + database::Version::get_many(&dep_version_ids, &**pool, &redis), + ) + .await?; + + let mut projects = filter_visible_projects( + projects_result, + &user_option, + &pool, + false, + ) + .await?; + let mut versions = filter_visible_versions( + versions_result, + &user_option, + &pool, + &redis, + ) + .await?; + + projects.sort_by(|a, b| b.published.cmp(&a.published)); + projects.dedup_by(|a, b| a.id == b.id); + + versions.sort_by(|a, b| b.date_published.cmp(&a.date_published)); + versions.dedup_by(|a, b| a.id == b.id); + + Ok(HttpResponse::Ok().json(DependencyInfo { projects, versions })) + } else { + Err(ApiError::NotFound) + } +} + +#[derive(derive_new::new)] +pub struct CategoryChanges<'a> { + pub categories: &'a Option>, + pub add_categories: &'a Option>, + pub remove_categories: &'a Option>, +} + +#[derive(Deserialize, Validate)] +pub struct BulkEditProject { + #[validate(length(max = 3))] + pub categories: Option>, + #[validate(length(max = 3))] + pub add_categories: Option>, + pub remove_categories: Option>, + + #[validate(length(max = 256))] + pub additional_categories: Option>, + #[validate(length(max = 3))] + pub add_additional_categories: Option>, + pub remove_additional_categories: Option>, + + #[validate(custom( + function = " crate::util::validate::validate_url_hashmap_optional_values" + ))] + pub link_urls: Option>>, +} + +pub async fn projects_edit( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + bulk_edit_project: web::Json, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE]), + ) + .await? + .1; + + bulk_edit_project.validate().map_err(|err| { + ApiError::Validation(validation_errors_to_string(err, None)) + })?; + + let project_ids: Vec = + serde_json::from_str::>(&ids.ids)? + .into_iter() + .map(|x| x.into()) + .collect(); + + let projects_data = + db_models::Project::get_many_ids(&project_ids, &**pool, &redis).await?; + + if let Some(id) = project_ids + .iter() + .find(|x| !projects_data.iter().any(|y| x == &&y.inner.id)) + { + return Err(ApiError::InvalidInput(format!( + "Project {} not found", + ProjectId(id.0 as u64) + ))); + } + + let team_ids = projects_data + .iter() + .map(|x| x.inner.team_id) + .collect::>(); + let team_members = db_models::TeamMember::get_from_team_full_many( + &team_ids, &**pool, &redis, + ) + .await?; + + let organization_ids = projects_data + .iter() + .filter_map(|x| x.inner.organization_id) + .collect::>(); + let organizations = db_models::Organization::get_many_ids( + &organization_ids, + &**pool, + &redis, + ) + .await?; + + let organization_team_ids = organizations + .iter() + .map(|x| x.team_id) + .collect::>(); + let organization_team_members = + db_models::TeamMember::get_from_team_full_many( + &organization_team_ids, + &**pool, + &redis, + ) + .await?; + + let categories = + db_models::categories::Category::list(&**pool, &redis).await?; + let link_platforms = + db_models::categories::LinkPlatform::list(&**pool, &redis).await?; + + let mut transaction = pool.begin().await?; + + for project in projects_data { + if !user.role.is_mod() { + let team_member = team_members.iter().find(|x| { + x.team_id == project.inner.team_id + && x.user_id == user.id.into() + }); + + let organization = project + .inner + .organization_id + .and_then(|oid| organizations.iter().find(|x| x.id == oid)); + + let organization_team_member = + if let Some(organization) = organization { + organization_team_members.iter().find(|x| { + x.team_id == organization.team_id + && x.user_id == user.id.into() + }) + } else { + None + }; + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member.cloned(), + &organization_team_member.cloned(), + ) + .unwrap_or_default(); + + if team_member.is_some() { + if !permissions.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication(format!( + "You do not have the permissions to bulk edit project {}!", + project.inner.name + ))); + } + } else if project.inner.status.is_hidden() { + return Err(ApiError::InvalidInput(format!( + "Project {} not found", + ProjectId(project.inner.id.0 as u64) + ))); + } else { + return Err(ApiError::CustomAuthentication(format!( + "You are not a member of project {}!", + project.inner.name + ))); + }; + } + + bulk_edit_project_categories( + &categories, + &project.categories, + project.inner.id as db_ids::ProjectId, + CategoryChanges::new( + &bulk_edit_project.categories, + &bulk_edit_project.add_categories, + &bulk_edit_project.remove_categories, + ), + 3, + false, + &mut transaction, + ) + .await?; + + bulk_edit_project_categories( + &categories, + &project.additional_categories, + project.inner.id as db_ids::ProjectId, + CategoryChanges::new( + &bulk_edit_project.additional_categories, + &bulk_edit_project.add_additional_categories, + &bulk_edit_project.remove_additional_categories, + ), + 256, + true, + &mut transaction, + ) + .await?; + + if let Some(links) = &bulk_edit_project.link_urls { + let ids_to_delete = links + .iter() + .map(|(name, _)| name.clone()) + .collect::>(); + // Deletes all links from hashmap- either will be deleted or be replaced + sqlx::query!( + " + DELETE FROM mods_links + WHERE joining_mod_id = $1 AND joining_platform_id IN ( + SELECT id FROM link_platforms WHERE name = ANY($2) + ) + ", + project.inner.id as db_ids::ProjectId, + &ids_to_delete + ) + .execute(&mut *transaction) + .await?; + + for (platform, url) in links { + if let Some(url) = url { + let platform_id = link_platforms + .iter() + .find(|x| &x.name == platform) + .ok_or_else(|| { + ApiError::InvalidInput(format!( + "Platform {} does not exist.", + platform.clone() + )) + })? + .id; + sqlx::query!( + " + INSERT INTO mods_links (joining_mod_id, joining_platform_id, url) + VALUES ($1, $2, $3) + ", + project.inner.id as db_ids::ProjectId, + platform_id as db_ids::LinkPlatformId, + url + ) + .execute(&mut *transaction) + .await?; + } + } + } + + db_models::Project::clear_cache( + project.inner.id, + project.inner.slug, + None, + &redis, + ) + .await?; + } + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) +} + +pub async fn bulk_edit_project_categories( + all_db_categories: &[db_models::categories::Category], + project_categories: &Vec, + project_id: db_ids::ProjectId, + bulk_changes: CategoryChanges<'_>, + max_num_categories: usize, + is_additional: bool, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, +) -> Result<(), ApiError> { + let mut set_categories = + if let Some(categories) = bulk_changes.categories.clone() { + categories + } else { + project_categories.clone() + }; + + if let Some(delete_categories) = &bulk_changes.remove_categories { + for category in delete_categories { + if let Some(pos) = set_categories.iter().position(|x| x == category) + { + set_categories.remove(pos); + } + } + } + + if let Some(add_categories) = &bulk_changes.add_categories { + for category in add_categories { + if set_categories.len() < max_num_categories { + set_categories.push(category.clone()); + } else { + break; + } + } + } + + if &set_categories != project_categories { + sqlx::query!( + " + DELETE FROM mods_categories + WHERE joining_mod_id = $1 AND is_additional = $2 + ", + project_id as db_ids::ProjectId, + is_additional + ) + .execute(&mut **transaction) + .await?; + + let mut mod_categories = Vec::new(); + for category in set_categories { + let category_id = all_db_categories + .iter() + .find(|x| x.category == category) + .ok_or_else(|| { + ApiError::InvalidInput(format!( + "Category {} does not exist.", + category.clone() + )) + })? + .id; + mod_categories.push(ModCategory::new( + project_id, + category_id, + is_additional, + )); + } + ModCategory::insert_many(mod_categories, &mut *transaction).await?; + } + + Ok(()) +} + +#[derive(Serialize, Deserialize)] +pub struct Extension { + pub ext: String, +} + +#[allow(clippy::too_many_arguments)] +pub async fn project_icon_edit( + web::Query(ext): web::Query, + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + mut payload: web::Payload, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE]), + ) + .await? + .1; + let string = info.into_inner().0; + + let project_item = db_models::Project::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The specified project does not exist!".to_string(), + ) + })?; + + if !user.role.is_mod() { + let (team_member, organization_team_member) = + db_models::TeamMember::get_for_project_permissions( + &project_item.inner, + user.id.into(), + &**pool, + ) + .await?; + + // Hide the project + if team_member.is_none() && organization_team_member.is_none() { + return Err(ApiError::CustomAuthentication( + "The specified project does not exist!".to_string(), + )); + } + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); + + if !permissions.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit this project's icon." + .to_string(), + )); + } + } + + delete_old_images( + project_item.inner.icon_url, + project_item.inner.raw_icon_url, + &***file_host, + ) + .await?; + + let bytes = read_from_payload( + &mut payload, + 262144, + "Icons must be smaller than 256KiB", + ) + .await?; + + let project_id: ProjectId = project_item.inner.id.into(); + let upload_result = upload_image_optimized( + &format!("data/{}", project_id), + bytes.freeze(), + &ext.ext, + Some(96), + Some(1.0), + &***file_host, + ) + .await?; + + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE mods + SET icon_url = $1, raw_icon_url = $2, color = $3 + WHERE (id = $4) + ", + upload_result.url, + upload_result.raw_url, + upload_result.color.map(|x| x as i32), + project_item.inner.id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + db_models::Project::clear_cache( + project_item.inner.id, + project_item.inner.slug, + None, + &redis, + ) + .await?; + + Ok(HttpResponse::NoContent().body("")) +} + +pub async fn delete_project_icon( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE]), + ) + .await? + .1; + let string = info.into_inner().0; + + let project_item = db_models::Project::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The specified project does not exist!".to_string(), + ) + })?; + + if !user.role.is_mod() { + let (team_member, organization_team_member) = + db_models::TeamMember::get_for_project_permissions( + &project_item.inner, + user.id.into(), + &**pool, + ) + .await?; + + // Hide the project + if team_member.is_none() && organization_team_member.is_none() { + return Err(ApiError::CustomAuthentication( + "The specified project does not exist!".to_string(), + )); + } + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); + + if !permissions.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit this project's icon." + .to_string(), + )); + } + } + + delete_old_images( + project_item.inner.icon_url, + project_item.inner.raw_icon_url, + &***file_host, + ) + .await?; + + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE mods + SET icon_url = NULL, raw_icon_url = NULL, color = NULL + WHERE (id = $1) + ", + project_item.inner.id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + db_models::Project::clear_cache( + project_item.inner.id, + project_item.inner.slug, + None, + &redis, + ) + .await?; + + Ok(HttpResponse::NoContent().body("")) +} + +#[derive(Serialize, Deserialize, Validate)] +pub struct GalleryCreateQuery { + pub featured: bool, + #[validate(length(min = 1, max = 255))] + pub name: Option, + #[validate(length(min = 1, max = 2048))] + pub description: Option, + pub ordering: Option, +} + +#[allow(clippy::too_many_arguments)] +pub async fn add_gallery_item( + web::Query(ext): web::Query, + req: HttpRequest, + web::Query(item): web::Query, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + mut payload: web::Payload, + session_queue: web::Data, +) -> Result { + item.validate().map_err(|err| { + ApiError::Validation(validation_errors_to_string(err, None)) + })?; + + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE]), + ) + .await? + .1; + let string = info.into_inner().0; + + let project_item = db_models::Project::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The specified project does not exist!".to_string(), + ) + })?; + + if project_item.gallery_items.len() > 64 { + return Err(ApiError::CustomAuthentication( + "You have reached the maximum of gallery images to upload." + .to_string(), + )); + } + + if !user.role.is_admin() { + let (team_member, organization_team_member) = + db_models::TeamMember::get_for_project_permissions( + &project_item.inner, + user.id.into(), + &**pool, + ) + .await?; + + // Hide the project + if team_member.is_none() && organization_team_member.is_none() { + return Err(ApiError::CustomAuthentication( + "The specified project does not exist!".to_string(), + )); + } + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); + + if !permissions.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit this project's gallery." + .to_string(), + )); + } + } + + let bytes = read_from_payload( + &mut payload, + 2 * (1 << 20), + "Gallery image exceeds the maximum of 2MiB.", + ) + .await?; + + let id: ProjectId = project_item.inner.id.into(); + let upload_result = upload_image_optimized( + &format!("data/{}/images", id), + bytes.freeze(), + &ext.ext, + Some(350), + Some(1.0), + &***file_host, + ) + .await?; + + if project_item + .gallery_items + .iter() + .any(|x| x.image_url == upload_result.url) + { + return Err(ApiError::InvalidInput( + "You may not upload duplicate gallery images!".to_string(), + )); + } + + let mut transaction = pool.begin().await?; + + if item.featured { + sqlx::query!( + " + UPDATE mods_gallery + SET featured = $2 + WHERE mod_id = $1 + ", + project_item.inner.id as db_ids::ProjectId, + false, + ) + .execute(&mut *transaction) + .await?; + } + + let gallery_item = vec![db_models::project_item::GalleryItem { + image_url: upload_result.url, + raw_image_url: upload_result.raw_url, + featured: item.featured, + name: item.name, + description: item.description, + created: Utc::now(), + ordering: item.ordering.unwrap_or(0), + }]; + GalleryItem::insert_many( + gallery_item, + project_item.inner.id, + &mut transaction, + ) + .await?; + + transaction.commit().await?; + db_models::Project::clear_cache( + project_item.inner.id, + project_item.inner.slug, + None, + &redis, + ) + .await?; + + Ok(HttpResponse::NoContent().body("")) +} + +#[derive(Serialize, Deserialize, Validate)] +pub struct GalleryEditQuery { + /// The url of the gallery item to edit + pub url: String, + pub featured: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate(length(min = 1, max = 255))] + pub name: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate(length(min = 1, max = 2048))] + pub description: Option>, + pub ordering: Option, +} + +pub async fn edit_gallery_item( + req: HttpRequest, + web::Query(item): web::Query, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE]), + ) + .await? + .1; + let string = info.into_inner().0; + + item.validate().map_err(|err| { + ApiError::Validation(validation_errors_to_string(err, None)) + })?; + + let project_item = db_models::Project::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The specified project does not exist!".to_string(), + ) + })?; + + if !user.role.is_mod() { + let (team_member, organization_team_member) = + db_models::TeamMember::get_for_project_permissions( + &project_item.inner, + user.id.into(), + &**pool, + ) + .await?; + + // Hide the project + if team_member.is_none() && organization_team_member.is_none() { + return Err(ApiError::CustomAuthentication( + "The specified project does not exist!".to_string(), + )); + } + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); + + if !permissions.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit this project's gallery." + .to_string(), + )); + } + } + let mut transaction = pool.begin().await?; + + let id = sqlx::query!( + " + SELECT id FROM mods_gallery + WHERE image_url = $1 + ", + item.url + ) + .fetch_optional(&mut *transaction) + .await? + .ok_or_else(|| { + ApiError::InvalidInput(format!( + "Gallery item at URL {} is not part of the project's gallery.", + item.url + )) + })? + .id; + + let mut transaction = pool.begin().await?; + + if let Some(featured) = item.featured { + if featured { + sqlx::query!( + " + UPDATE mods_gallery + SET featured = $2 + WHERE mod_id = $1 + ", + project_item.inner.id as db_ids::ProjectId, + false, + ) + .execute(&mut *transaction) + .await?; + } + + sqlx::query!( + " + UPDATE mods_gallery + SET featured = $2 + WHERE id = $1 + ", + id, + featured + ) + .execute(&mut *transaction) + .await?; + } + if let Some(name) = item.name { + sqlx::query!( + " + UPDATE mods_gallery + SET name = $2 + WHERE id = $1 + ", + id, + name + ) + .execute(&mut *transaction) + .await?; + } + if let Some(description) = item.description { + sqlx::query!( + " + UPDATE mods_gallery + SET description = $2 + WHERE id = $1 + ", + id, + description + ) + .execute(&mut *transaction) + .await?; + } + if let Some(ordering) = item.ordering { + sqlx::query!( + " + UPDATE mods_gallery + SET ordering = $2 + WHERE id = $1 + ", + id, + ordering + ) + .execute(&mut *transaction) + .await?; + } + + transaction.commit().await?; + + db_models::Project::clear_cache( + project_item.inner.id, + project_item.inner.slug, + None, + &redis, + ) + .await?; + + Ok(HttpResponse::NoContent().body("")) +} + +#[derive(Serialize, Deserialize)] +pub struct GalleryDeleteQuery { + pub url: String, +} + +pub async fn delete_gallery_item( + req: HttpRequest, + web::Query(item): web::Query, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE]), + ) + .await? + .1; + let string = info.into_inner().0; + + let project_item = db_models::Project::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The specified project does not exist!".to_string(), + ) + })?; + + if !user.role.is_mod() { + let (team_member, organization_team_member) = + db_models::TeamMember::get_for_project_permissions( + &project_item.inner, + user.id.into(), + &**pool, + ) + .await?; + + // Hide the project + if team_member.is_none() && organization_team_member.is_none() { + return Err(ApiError::CustomAuthentication( + "The specified project does not exist!".to_string(), + )); + } + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); + + if !permissions.contains(ProjectPermissions::EDIT_DETAILS) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit this project's gallery." + .to_string(), + )); + } + } + let mut transaction = pool.begin().await?; + + let item = sqlx::query!( + " + SELECT id, image_url, raw_image_url FROM mods_gallery + WHERE image_url = $1 + ", + item.url + ) + .fetch_optional(&mut *transaction) + .await? + .ok_or_else(|| { + ApiError::InvalidInput(format!( + "Gallery item at URL {} is not part of the project's gallery.", + item.url + )) + })?; + + delete_old_images( + Some(item.image_url), + Some(item.raw_image_url), + &***file_host, + ) + .await?; + + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + DELETE FROM mods_gallery + WHERE id = $1 + ", + item.id + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + db_models::Project::clear_cache( + project_item.inner.id, + project_item.inner.slug, + None, + &redis, + ) + .await?; + + Ok(HttpResponse::NoContent().body("")) +} + +pub async fn project_delete( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + search_config: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_DELETE]), + ) + .await? + .1; + let string = info.into_inner().0; + + let project = db_models::Project::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The specified project does not exist!".to_string(), + ) + })?; + + if !user.role.is_admin() { + let (team_member, organization_team_member) = + db_models::TeamMember::get_for_project_permissions( + &project.inner, + user.id.into(), + &**pool, + ) + .await?; + + // Hide the project + if team_member.is_none() && organization_team_member.is_none() { + return Err(ApiError::CustomAuthentication( + "The specified project does not exist!".to_string(), + )); + } + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); + + if !permissions.contains(ProjectPermissions::DELETE_PROJECT) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to delete this project!".to_string(), + )); + } + } + + let mut transaction = pool.begin().await?; + let context = ImageContext::Project { + project_id: Some(project.inner.id.into()), + }; + let uploaded_images = + db_models::Image::get_many_contexted(context, &mut transaction).await?; + for image in uploaded_images { + image_item::Image::remove(image.id, &mut transaction, &redis).await?; + } + + sqlx::query!( + " + DELETE FROM collections_mods + WHERE mod_id = $1 + ", + project.inner.id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + + let result = + db_models::Project::remove(project.inner.id, &mut transaction, &redis) + .await?; + + transaction.commit().await?; + + remove_documents( + &project + .versions + .into_iter() + .map(|x| x.into()) + .collect::>(), + &search_config, + ) + .await?; + + if result.is_some() { + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::NotFound) + } +} + +pub async fn project_follow( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::USER_WRITE]), + ) + .await? + .1; + let string = info.into_inner().0; + + let result = db_models::Project::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The specified project does not exist!".to_string(), + ) + })?; + + let user_id: db_ids::UserId = user.id.into(); + let project_id: db_ids::ProjectId = result.inner.id; + + if !is_visible_project(&result.inner, &Some(user), &pool, false).await? { + return Err(ApiError::NotFound); + } + + let following = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM mod_follows mf WHERE mf.follower_id = $1 AND mf.mod_id = $2) + ", + user_id as db_ids::UserId, + project_id as db_ids::ProjectId + ) + .fetch_one(&**pool) + .await? + .exists + .unwrap_or(false); + + if !following { + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE mods + SET follows = follows + 1 + WHERE id = $1 + ", + project_id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + " + INSERT INTO mod_follows (follower_id, mod_id) + VALUES ($1, $2) + ", + user_id as db_ids::UserId, + project_id as db_ids::ProjectId + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::InvalidInput( + "You are already following this project!".to_string(), + )) + } +} + +pub async fn project_unfollow( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::USER_WRITE]), + ) + .await? + .1; + let string = info.into_inner().0; + + let result = db_models::Project::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The specified project does not exist!".to_string(), + ) + })?; + + let user_id: db_ids::UserId = user.id.into(); + let project_id = result.inner.id; + + let following = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM mod_follows mf WHERE mf.follower_id = $1 AND mf.mod_id = $2) + ", + user_id as db_ids::UserId, + project_id as db_ids::ProjectId + ) + .fetch_one(&**pool) + .await? + .exists + .unwrap_or(false); + + if following { + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + UPDATE mods + SET follows = follows - 1 + WHERE id = $1 + ", + project_id as db_ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + " + DELETE FROM mod_follows + WHERE follower_id = $1 AND mod_id = $2 + ", + user_id as db_ids::UserId, + project_id as db_ids::ProjectId + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::InvalidInput( + "You are not following this project!".to_string(), + )) + } +} + +pub async fn project_get_organization( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ, Scopes::ORGANIZATION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + let user_id = current_user.as_ref().map(|x| x.id.into()); + + let string = info.into_inner().0; + let result = db_models::Project::get(&string, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The specified project does not exist!".to_string(), + ) + })?; + + if !is_visible_project(&result.inner, ¤t_user, &pool, false).await? { + Err(ApiError::InvalidInput( + "The specified project does not exist!".to_string(), + )) + } else if let Some(organization_id) = result.inner.organization_id { + let organization = + db_models::Organization::get_id(organization_id, &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The attached organization does not exist!".to_string(), + ) + })?; + + let members_data = TeamMember::get_from_team_full( + organization.team_id, + &**pool, + &redis, + ) + .await?; + + let users = crate::database::models::User::get_many_ids( + &members_data.iter().map(|x| x.user_id).collect::>(), + &**pool, + &redis, + ) + .await?; + let logged_in = current_user + .as_ref() + .and_then(|user| { + members_data + .iter() + .find(|x| x.user_id == user.id.into() && x.accepted) + }) + .is_some(); + let team_members: Vec<_> = members_data + .into_iter() + .filter(|x| { + logged_in + || x.accepted + || user_id + .map(|y: crate::database::models::UserId| { + y == x.user_id + }) + .unwrap_or(false) + }) + .flat_map(|data| { + users.iter().find(|x| x.id == data.user_id).map(|user| { + crate::models::teams::TeamMember::from( + data, + user.clone(), + !logged_in, + ) + }) + }) + .collect(); + + let organization = models::organizations::Organization::from( + organization, + team_members, + ); + return Ok(HttpResponse::Ok().json(organization)); + } else { + Err(ApiError::NotFound) + } +} diff --git a/apps/labrinth/src/routes/v3/reports.rs b/apps/labrinth/src/routes/v3/reports.rs new file mode 100644 index 000000000..1af674627 --- /dev/null +++ b/apps/labrinth/src/routes/v3/reports.rs @@ -0,0 +1,536 @@ +use crate::auth::{check_is_moderator_from_headers, get_user_from_headers}; +use crate::database; +use crate::database::models::image_item; +use crate::database::models::thread_item::{ + ThreadBuilder, ThreadMessageBuilder, +}; +use crate::database::redis::RedisPool; +use crate::models::ids::ImageId; +use crate::models::ids::{ + base62_impl::parse_base62, ProjectId, UserId, VersionId, +}; +use crate::models::images::{Image, ImageContext}; +use crate::models::pats::Scopes; +use crate::models::reports::{ItemType, Report}; +use crate::models::threads::{MessageBody, ThreadType}; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use crate::util::img; +use actix_web::{web, HttpRequest, HttpResponse}; +use chrono::Utc; +use futures::StreamExt; +use serde::Deserialize; +use sqlx::PgPool; +use validator::Validate; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.route("report", web::post().to(report_create)); + cfg.route("report", web::get().to(reports)); + cfg.route("reports", web::get().to(reports_get)); + cfg.route("report/{id}", web::get().to(report_get)); + cfg.route("report/{id}", web::patch().to(report_edit)); + cfg.route("report/{id}", web::delete().to(report_delete)); +} + +#[derive(Deserialize, Validate)] +pub struct CreateReport { + pub report_type: String, + pub item_id: String, + pub item_type: ItemType, + pub body: String, + // Associations to uploaded images + #[validate(length(max = 10))] + #[serde(default)] + pub uploaded_images: Vec, +} + +pub async fn report_create( + req: HttpRequest, + pool: web::Data, + mut body: web::Payload, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let mut transaction = pool.begin().await?; + + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::REPORT_CREATE]), + ) + .await? + .1; + + let mut bytes = web::BytesMut::new(); + while let Some(item) = body.next().await { + bytes.extend_from_slice(&item.map_err(|_| { + ApiError::InvalidInput( + "Error while parsing request payload!".to_string(), + ) + })?); + } + let new_report: CreateReport = serde_json::from_slice(bytes.as_ref())?; + + let id = + crate::database::models::generate_report_id(&mut transaction).await?; + let report_type = crate::database::models::categories::ReportType::get_id( + &new_report.report_type, + &mut *transaction, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput(format!( + "Invalid report type: {}", + new_report.report_type + )) + })?; + + let mut report = crate::database::models::report_item::Report { + id, + report_type_id: report_type, + project_id: None, + version_id: None, + user_id: None, + body: new_report.body.clone(), + reporter: current_user.id.into(), + created: Utc::now(), + closed: false, + }; + + match new_report.item_type { + ItemType::Project => { + let project_id = + ProjectId(parse_base62(new_report.item_id.as_str())?); + + let result = sqlx::query!( + "SELECT EXISTS(SELECT 1 FROM mods WHERE id = $1)", + project_id.0 as i64 + ) + .fetch_one(&mut *transaction) + .await?; + + if !result.exists.unwrap_or(false) { + return Err(ApiError::InvalidInput(format!( + "Project could not be found: {}", + new_report.item_id + ))); + } + + report.project_id = Some(project_id.into()) + } + ItemType::Version => { + let version_id = + VersionId(parse_base62(new_report.item_id.as_str())?); + + let result = sqlx::query!( + "SELECT EXISTS(SELECT 1 FROM versions WHERE id = $1)", + version_id.0 as i64 + ) + .fetch_one(&mut *transaction) + .await?; + + if !result.exists.unwrap_or(false) { + return Err(ApiError::InvalidInput(format!( + "Version could not be found: {}", + new_report.item_id + ))); + } + + report.version_id = Some(version_id.into()) + } + ItemType::User => { + let user_id = UserId(parse_base62(new_report.item_id.as_str())?); + + let result = sqlx::query!( + "SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)", + user_id.0 as i64 + ) + .fetch_one(&mut *transaction) + .await?; + + if !result.exists.unwrap_or(false) { + return Err(ApiError::InvalidInput(format!( + "User could not be found: {}", + new_report.item_id + ))); + } + + report.user_id = Some(user_id.into()) + } + ItemType::Unknown => { + return Err(ApiError::InvalidInput(format!( + "Invalid report item type: {}", + new_report.item_type.as_str() + ))) + } + } + + report.insert(&mut transaction).await?; + + for image_id in new_report.uploaded_images { + if let Some(db_image) = + image_item::Image::get(image_id.into(), &mut *transaction, &redis) + .await? + { + let image: Image = db_image.into(); + if !matches!(image.context, ImageContext::Report { .. }) + || image.context.inner_id().is_some() + { + return Err(ApiError::InvalidInput(format!( + "Image {} is not unused and in the 'report' context", + image_id + ))); + } + + sqlx::query!( + " + UPDATE uploaded_images + SET report_id = $1 + WHERE id = $2 + ", + id.0 as i64, + image_id.0 as i64 + ) + .execute(&mut *transaction) + .await?; + + image_item::Image::clear_cache(image.id.into(), &redis).await?; + } else { + return Err(ApiError::InvalidInput(format!( + "Image {} could not be found", + image_id + ))); + } + } + + let thread_id = ThreadBuilder { + type_: ThreadType::Report, + members: vec![], + project_id: None, + report_id: Some(report.id), + } + .insert(&mut transaction) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::Ok().json(Report { + id: id.into(), + report_type: new_report.report_type.clone(), + item_id: new_report.item_id.clone(), + item_type: new_report.item_type.clone(), + reporter: current_user.id, + body: new_report.body.clone(), + created: Utc::now(), + closed: false, + thread_id: thread_id.into(), + })) +} + +#[derive(Deserialize)] +pub struct ReportsRequestOptions { + #[serde(default = "default_count")] + pub count: i16, + #[serde(default = "default_all")] + pub all: bool, +} + +fn default_count() -> i16 { + 100 +} +fn default_all() -> bool { + true +} + +pub async fn reports( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + count: web::Query, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::REPORT_READ]), + ) + .await? + .1; + + use futures::stream::TryStreamExt; + + let report_ids = if user.role.is_mod() && count.all { + sqlx::query!( + " + SELECT id FROM reports + WHERE closed = FALSE + ORDER BY created ASC + LIMIT $1; + ", + count.count as i64 + ) + .fetch(&**pool) + .map_ok(|m| crate::database::models::ids::ReportId(m.id)) + .try_collect::>() + .await? + } else { + sqlx::query!( + " + SELECT id FROM reports + WHERE closed = FALSE AND reporter = $1 + ORDER BY created ASC + LIMIT $2; + ", + user.id.0 as i64, + count.count as i64 + ) + .fetch(&**pool) + .map_ok(|m| crate::database::models::ids::ReportId(m.id)) + .try_collect::>() + .await? + }; + + let query_reports = crate::database::models::report_item::Report::get_many( + &report_ids, + &**pool, + ) + .await?; + + let mut reports: Vec = Vec::new(); + + for x in query_reports { + reports.push(x.into()); + } + + Ok(HttpResponse::Ok().json(reports)) +} + +#[derive(Deserialize)] +pub struct ReportIds { + pub ids: String, +} + +pub async fn reports_get( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let report_ids: Vec = + serde_json::from_str::>(&ids.ids)? + .into_iter() + .map(|x| x.into()) + .collect(); + + let reports_data = crate::database::models::report_item::Report::get_many( + &report_ids, + &**pool, + ) + .await?; + + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::REPORT_READ]), + ) + .await? + .1; + + let all_reports = reports_data + .into_iter() + .filter(|x| user.role.is_mod() || x.reporter == user.id.into()) + .map(|x| x.into()) + .collect::>(); + + Ok(HttpResponse::Ok().json(all_reports)) +} + +pub async fn report_get( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + info: web::Path<(crate::models::reports::ReportId,)>, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::REPORT_READ]), + ) + .await? + .1; + let id = info.into_inner().0.into(); + + let report = + crate::database::models::report_item::Report::get(id, &**pool).await?; + + if let Some(report) = report { + if !user.role.is_mod() && report.reporter != user.id.into() { + return Err(ApiError::NotFound); + } + + let report: Report = report.into(); + Ok(HttpResponse::Ok().json(report)) + } else { + Err(ApiError::NotFound) + } +} + +#[derive(Deserialize, Validate)] +pub struct EditReport { + #[validate(length(max = 65536))] + pub body: Option, + pub closed: Option, +} + +pub async fn report_edit( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + info: web::Path<(crate::models::reports::ReportId,)>, + session_queue: web::Data, + edit_report: web::Json, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::REPORT_WRITE]), + ) + .await? + .1; + let id = info.into_inner().0.into(); + + let report = + crate::database::models::report_item::Report::get(id, &**pool).await?; + + if let Some(report) = report { + if !user.role.is_mod() && report.reporter != user.id.into() { + return Err(ApiError::NotFound); + } + + let mut transaction = pool.begin().await?; + + if let Some(edit_body) = &edit_report.body { + sqlx::query!( + " + UPDATE reports + SET body = $1 + WHERE (id = $2) + ", + edit_body, + id as crate::database::models::ids::ReportId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(edit_closed) = edit_report.closed { + if !user.role.is_mod() { + return Err(ApiError::InvalidInput( + "You cannot reopen a report!".to_string(), + )); + } + + ThreadMessageBuilder { + author_id: Some(user.id.into()), + body: if !edit_closed && report.closed { + MessageBody::ThreadReopen + } else { + MessageBody::ThreadClosure + }, + thread_id: report.thread_id, + hide_identity: user.role.is_mod(), + } + .insert(&mut transaction) + .await?; + + sqlx::query!( + " + UPDATE reports + SET closed = $1 + WHERE (id = $2) + ", + edit_closed, + id as crate::database::models::ids::ReportId, + ) + .execute(&mut *transaction) + .await?; + } + + // delete any images no longer in the body + let checkable_strings: Vec<&str> = vec![&edit_report.body] + .into_iter() + .filter_map(|x: &Option| x.as_ref().map(|y| y.as_str())) + .collect(); + let image_context = ImageContext::Report { + report_id: Some(id.into()), + }; + img::delete_unused_images( + image_context, + checkable_strings, + &mut transaction, + &redis, + ) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::NotFound) + } +} + +pub async fn report_delete( + req: HttpRequest, + pool: web::Data, + info: web::Path<(crate::models::reports::ReportId,)>, + redis: web::Data, + session_queue: web::Data, +) -> Result { + check_is_moderator_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::REPORT_DELETE]), + ) + .await?; + + let mut transaction = pool.begin().await?; + + let id = info.into_inner().0; + let context = ImageContext::Report { + report_id: Some(id), + }; + let uploaded_images = + database::models::Image::get_many_contexted(context, &mut transaction) + .await?; + for image in uploaded_images { + image_item::Image::remove(image.id, &mut transaction, &redis).await?; + } + + let result = crate::database::models::report_item::Report::remove_full( + id.into(), + &mut transaction, + ) + .await?; + transaction.commit().await?; + + if result.is_some() { + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::NotFound) + } +} diff --git a/apps/labrinth/src/routes/v3/statistics.rs b/apps/labrinth/src/routes/v3/statistics.rs new file mode 100644 index 000000000..511448043 --- /dev/null +++ b/apps/labrinth/src/routes/v3/statistics.rs @@ -0,0 +1,94 @@ +use crate::routes::ApiError; +use actix_web::{web, HttpResponse}; +use sqlx::PgPool; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.route("statistics", web::get().to(get_stats)); +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct V3Stats { + pub projects: Option, + pub versions: Option, + pub authors: Option, + pub files: Option, +} + +pub async fn get_stats( + pool: web::Data, +) -> Result { + let projects = sqlx::query!( + " + SELECT COUNT(id) + FROM mods + WHERE status = ANY($1) + ", + &*crate::models::projects::ProjectStatus::iterator() + .filter(|x| x.is_searchable()) + .map(|x| x.to_string()) + .collect::>(), + ) + .fetch_one(&**pool) + .await?; + + let versions = sqlx::query!( + " + SELECT COUNT(v.id) + FROM versions v + INNER JOIN mods m on v.mod_id = m.id AND m.status = ANY($1) + WHERE v.status = ANY($2) + ", + &*crate::models::projects::ProjectStatus::iterator() + .filter(|x| x.is_searchable()) + .map(|x| x.to_string()) + .collect::>(), + &*crate::models::projects::VersionStatus::iterator() + .filter(|x| x.is_listed()) + .map(|x| x.to_string()) + .collect::>(), + ) + .fetch_one(&**pool) + .await?; + + let authors = sqlx::query!( + " + SELECT COUNT(DISTINCT u.id) + FROM users u + INNER JOIN team_members tm on u.id = tm.user_id AND tm.accepted = TRUE + INNER JOIN mods m on tm.team_id = m.team_id AND m.status = ANY($1) + ", + &*crate::models::projects::ProjectStatus::iterator() + .filter(|x| x.is_searchable()) + .map(|x| x.to_string()) + .collect::>(), + ) + .fetch_one(&**pool) + .await?; + + let files = sqlx::query!( + " + SELECT COUNT(f.id) FROM files f + INNER JOIN versions v on f.version_id = v.id AND v.status = ANY($2) + INNER JOIN mods m on v.mod_id = m.id AND m.status = ANY($1) + ", + &*crate::models::projects::ProjectStatus::iterator() + .filter(|x| x.is_searchable()) + .map(|x| x.to_string()) + .collect::>(), + &*crate::models::projects::VersionStatus::iterator() + .filter(|x| x.is_listed()) + .map(|x| x.to_string()) + .collect::>(), + ) + .fetch_one(&**pool) + .await?; + + let v3_stats = V3Stats { + projects: projects.count, + versions: versions.count, + authors: authors.count, + files: files.count, + }; + + Ok(HttpResponse::Ok().json(v3_stats)) +} diff --git a/apps/labrinth/src/routes/v3/tags.rs b/apps/labrinth/src/routes/v3/tags.rs new file mode 100644 index 000000000..d7d5b2516 --- /dev/null +++ b/apps/labrinth/src/routes/v3/tags.rs @@ -0,0 +1,265 @@ +use std::collections::HashMap; + +use super::ApiError; +use crate::database::models::categories::{ + Category, LinkPlatform, ProjectType, ReportType, +}; +use crate::database::models::loader_fields::{ + Game, Loader, LoaderField, LoaderFieldEnumValue, LoaderFieldType, +}; +use crate::database::redis::RedisPool; +use actix_web::{web, HttpResponse}; + +use itertools::Itertools; +use serde_json::Value; +use sqlx::PgPool; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("tag") + .route("category", web::get().to(category_list)) + .route("loader", web::get().to(loader_list)), + ) + .route("games", web::get().to(games_list)) + .route("loader_field", web::get().to(loader_fields_list)) + .route("license", web::get().to(license_list)) + .route("license/{id}", web::get().to(license_text)) + .route("link_platform", web::get().to(link_platform_list)) + .route("report_type", web::get().to(report_type_list)) + .route("project_type", web::get().to(project_type_list)); +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct GameData { + pub slug: String, + pub name: String, + pub icon: Option, + pub banner: Option, +} + +pub async fn games_list( + pool: web::Data, + redis: web::Data, +) -> Result { + let results = Game::list(&**pool, &redis) + .await? + .into_iter() + .map(|x| GameData { + slug: x.slug, + name: x.name, + icon: x.icon_url, + banner: x.banner_url, + }) + .collect::>(); + + Ok(HttpResponse::Ok().json(results)) +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct CategoryData { + pub icon: String, + pub name: String, + pub project_type: String, + pub header: String, +} + +pub async fn category_list( + pool: web::Data, + redis: web::Data, +) -> Result { + let results = Category::list(&**pool, &redis) + .await? + .into_iter() + .map(|x| CategoryData { + icon: x.icon, + name: x.category, + project_type: x.project_type, + header: x.header, + }) + .collect::>(); + + Ok(HttpResponse::Ok().json(results)) +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct LoaderData { + pub icon: String, + pub name: String, + pub supported_project_types: Vec, + pub supported_games: Vec, + pub supported_fields: Vec, // Available loader fields for this loader + pub metadata: Value, +} + +pub async fn loader_list( + pool: web::Data, + redis: web::Data, +) -> Result { + let loaders = Loader::list(&**pool, &redis).await?; + + let loader_fields = LoaderField::get_fields_per_loader( + &loaders.iter().map(|x| x.id).collect_vec(), + &**pool, + &redis, + ) + .await?; + + let mut results = loaders + .into_iter() + .map(|x| LoaderData { + icon: x.icon, + name: x.loader, + supported_project_types: x.supported_project_types, + supported_games: x.supported_games, + supported_fields: loader_fields + .get(&x.id) + .map(|x| x.iter().map(|x| x.field.clone()).collect_vec()) + .unwrap_or_default(), + metadata: x.metadata, + }) + .collect::>(); + + results.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase())); + + Ok(HttpResponse::Ok().json(results)) +} + +#[derive(serde::Deserialize, serde::Serialize)] +pub struct LoaderFieldsEnumQuery { + pub loader_field: String, + pub filters: Option>, // For metadata +} + +// Provides the variants for any enumerable loader field. +pub async fn loader_fields_list( + pool: web::Data, + query: web::Query, + redis: web::Data, +) -> Result { + let query = query.into_inner(); + let loader_field = LoaderField::get_fields_all(&**pool, &redis) + .await? + .into_iter() + .find(|x| x.field == query.loader_field) + .ok_or_else(|| { + ApiError::InvalidInput(format!( + "'{}' was not a valid loader field.", + query.loader_field + )) + })?; + + let loader_field_enum_id = match loader_field.field_type { + LoaderFieldType::Enum(enum_id) + | LoaderFieldType::ArrayEnum(enum_id) => enum_id, + _ => { + return Err(ApiError::InvalidInput(format!( + "'{}' is not an enumerable field, but an '{}' field.", + query.loader_field, + loader_field.field_type.to_str() + ))) + } + }; + + let results: Vec<_> = if let Some(filters) = query.filters { + LoaderFieldEnumValue::list_filter( + loader_field_enum_id, + filters, + &**pool, + &redis, + ) + .await? + } else { + LoaderFieldEnumValue::list(loader_field_enum_id, &**pool, &redis) + .await? + }; + + Ok(HttpResponse::Ok().json(results)) +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct License { + pub short: String, + pub name: String, +} + +pub async fn license_list() -> HttpResponse { + let licenses = spdx::identifiers::LICENSES; + let mut results: Vec = Vec::with_capacity(licenses.len()); + + for (short, name, _) in licenses { + results.push(License { + short: short.to_string(), + name: name.to_string(), + }); + } + + HttpResponse::Ok().json(results) +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct LicenseText { + pub title: String, + pub body: String, +} + +pub async fn license_text( + params: web::Path<(String,)>, +) -> Result { + let license_id = params.into_inner().0; + + if license_id == *crate::models::projects::DEFAULT_LICENSE_ID { + return Ok(HttpResponse::Ok().json(LicenseText { + title: "All Rights Reserved".to_string(), + body: "All rights reserved unless explicitly stated.".to_string(), + })); + } + + if let Some(license) = spdx::license_id(&license_id) { + return Ok(HttpResponse::Ok().json(LicenseText { + title: license.full_name.to_string(), + body: license.text().to_string(), + })); + } + + Err(ApiError::InvalidInput( + "Invalid SPDX identifier specified".to_string(), + )) +} + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct LinkPlatformQueryData { + pub name: String, + pub donation: bool, +} + +pub async fn link_platform_list( + pool: web::Data, + redis: web::Data, +) -> Result { + let results: Vec = + LinkPlatform::list(&**pool, &redis) + .await? + .into_iter() + .map(|x| LinkPlatformQueryData { + name: x.name, + donation: x.donation, + }) + .collect(); + Ok(HttpResponse::Ok().json(results)) +} + +pub async fn report_type_list( + pool: web::Data, + redis: web::Data, +) -> Result { + let results = ReportType::list(&**pool, &redis).await?; + Ok(HttpResponse::Ok().json(results)) +} + +pub async fn project_type_list( + pool: web::Data, + redis: web::Data, +) -> Result { + let results = ProjectType::list(&**pool, &redis).await?; + Ok(HttpResponse::Ok().json(results)) +} diff --git a/apps/labrinth/src/routes/v3/teams.rs b/apps/labrinth/src/routes/v3/teams.rs new file mode 100644 index 000000000..4917d0547 --- /dev/null +++ b/apps/labrinth/src/routes/v3/teams.rs @@ -0,0 +1,1174 @@ +use crate::auth::checks::is_visible_project; +use crate::auth::get_user_from_headers; +use crate::database::models::notification_item::NotificationBuilder; +use crate::database::models::team_item::TeamAssociationId; +use crate::database::models::{Organization, Team, TeamMember, User}; +use crate::database::redis::RedisPool; +use crate::database::Project; +use crate::models::notifications::NotificationBody; +use crate::models::pats::Scopes; +use crate::models::teams::{ + OrganizationPermissions, ProjectPermissions, TeamId, +}; +use crate::models::users::UserId; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use actix_web::{web, HttpRequest, HttpResponse}; +use rust_decimal::Decimal; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.route("teams", web::get().to(teams_get)); + + cfg.service( + web::scope("team") + .route("{id}/members", web::get().to(team_members_get)) + .route("{id}/members/{user_id}", web::patch().to(edit_team_member)) + .route( + "{id}/members/{user_id}", + web::delete().to(remove_team_member), + ) + .route("{id}/members", web::post().to(add_team_member)) + .route("{id}/join", web::post().to(join_team)) + .route("{id}/owner", web::patch().to(transfer_ownership)), + ); +} + +// Returns all members of a project, +// including the team members of the project's team, but +// also the members of the organization's team if the project is associated with an organization +// (Unlike team_members_get_project, which only returns the members of the project's team) +// They can be differentiated by the "organization_permissions" field being null or not +pub async fn team_members_get_project( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let string = info.into_inner().0; + let project_data = + crate::database::models::Project::get(&string, &**pool, &redis).await?; + + if let Some(project) = project_data { + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + if !is_visible_project(&project.inner, ¤t_user, &pool, false) + .await? + { + return Err(ApiError::NotFound); + } + let members_data = TeamMember::get_from_team_full( + project.inner.team_id, + &**pool, + &redis, + ) + .await?; + let users = User::get_many_ids( + &members_data.iter().map(|x| x.user_id).collect::>(), + &**pool, + &redis, + ) + .await?; + + let user_id = current_user.as_ref().map(|x| x.id.into()); + let logged_in = if let Some(user_id) = user_id { + let (team_member, organization_team_member) = + TeamMember::get_for_project_permissions( + &project.inner, + user_id, + &**pool, + ) + .await?; + + team_member.is_some() || organization_team_member.is_some() + } else { + false + }; + + let team_members: Vec<_> = members_data + .into_iter() + .filter(|x| { + logged_in + || x.accepted + || user_id + .map(|y: crate::database::models::UserId| { + y == x.user_id + }) + .unwrap_or(false) + }) + .flat_map(|data| { + users.iter().find(|x| x.id == data.user_id).map(|user| { + crate::models::teams::TeamMember::from( + data, + user.clone(), + !logged_in, + ) + }) + }) + .collect(); + + Ok(HttpResponse::Ok().json(team_members)) + } else { + Err(ApiError::NotFound) + } +} + +pub async fn team_members_get_organization( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let string = info.into_inner().0; + let organization_data = + crate::database::models::Organization::get(&string, &**pool, &redis) + .await?; + + if let Some(organization) = organization_data { + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::ORGANIZATION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let members_data = TeamMember::get_from_team_full( + organization.team_id, + &**pool, + &redis, + ) + .await?; + let users = crate::database::models::User::get_many_ids( + &members_data.iter().map(|x| x.user_id).collect::>(), + &**pool, + &redis, + ) + .await?; + + let user_id = current_user.as_ref().map(|x| x.id.into()); + + let logged_in = current_user + .and_then(|user| { + members_data + .iter() + .find(|x| x.user_id == user.id.into() && x.accepted) + }) + .is_some(); + + let team_members: Vec<_> = members_data + .into_iter() + .filter(|x| { + logged_in + || x.accepted + || user_id + .map(|y: crate::database::models::UserId| { + y == x.user_id + }) + .unwrap_or(false) + }) + .flat_map(|data| { + users.iter().find(|x| x.id == data.user_id).map(|user| { + crate::models::teams::TeamMember::from( + data, + user.clone(), + !logged_in, + ) + }) + }) + .collect(); + + Ok(HttpResponse::Ok().json(team_members)) + } else { + Err(ApiError::NotFound) + } +} + +// Returns all members of a team, but not necessarily those of a project-team's organization (unlike team_members_get_project) +pub async fn team_members_get( + req: HttpRequest, + info: web::Path<(TeamId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let id = info.into_inner().0; + let members_data = + TeamMember::get_from_team_full(id.into(), &**pool, &redis).await?; + let users = crate::database::models::User::get_many_ids( + &members_data.iter().map(|x| x.user_id).collect::>(), + &**pool, + &redis, + ) + .await?; + + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await + .map(|x| x.1) + .ok(); + let user_id = current_user.as_ref().map(|x| x.id.into()); + + let logged_in = current_user + .and_then(|user| { + members_data + .iter() + .find(|x| x.user_id == user.id.into() && x.accepted) + }) + .is_some(); + + let team_members: Vec<_> = members_data + .into_iter() + .filter(|x| { + logged_in + || x.accepted + || user_id + .map(|y: crate::database::models::UserId| y == x.user_id) + .unwrap_or(false) + }) + .flat_map(|data| { + users.iter().find(|x| x.id == data.user_id).map(|user| { + crate::models::teams::TeamMember::from( + data, + user.clone(), + !logged_in, + ) + }) + }) + .collect(); + + Ok(HttpResponse::Ok().json(team_members)) +} + +#[derive(Serialize, Deserialize)] +pub struct TeamIds { + pub ids: String, +} + +pub async fn teams_get( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + use itertools::Itertools; + + let team_ids = serde_json::from_str::>(&ids.ids)? + .into_iter() + .map(|x| x.into()) + .collect::>(); + + let teams_data = + TeamMember::get_from_team_full_many(&team_ids, &**pool, &redis).await?; + let users = crate::database::models::User::get_many_ids( + &teams_data.iter().map(|x| x.user_id).collect::>(), + &**pool, + &redis, + ) + .await?; + + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let teams_groups = teams_data.into_iter().group_by(|data| data.team_id.0); + + let mut teams: Vec> = vec![]; + + for (_, member_data) in &teams_groups { + let members = member_data.collect::>(); + + let logged_in = current_user + .as_ref() + .and_then(|user| { + members + .iter() + .find(|x| x.user_id == user.id.into() && x.accepted) + }) + .is_some(); + + let team_members = members + .into_iter() + .filter(|x| logged_in || x.accepted) + .flat_map(|data| { + users.iter().find(|x| x.id == data.user_id).map(|user| { + crate::models::teams::TeamMember::from( + data, + user.clone(), + !logged_in, + ) + }) + }); + + teams.push(team_members.collect()); + } + + Ok(HttpResponse::Ok().json(teams)) +} + +pub async fn join_team( + req: HttpRequest, + info: web::Path<(TeamId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let team_id = info.into_inner().0.into(); + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE]), + ) + .await? + .1; + + let member = TeamMember::get_from_user_id_pending( + team_id, + current_user.id.into(), + &**pool, + ) + .await?; + + if let Some(member) = member { + if member.accepted { + return Err(ApiError::InvalidInput( + "You are already a member of this team".to_string(), + )); + } + let mut transaction = pool.begin().await?; + + // Edit Team Member to set Accepted to True + TeamMember::edit_team_member( + team_id, + current_user.id.into(), + None, + None, + None, + Some(true), + None, + None, + None, + &mut transaction, + ) + .await?; + + transaction.commit().await?; + + User::clear_project_cache(&[current_user.id.into()], &redis).await?; + TeamMember::clear_cache(team_id, &redis).await?; + } else { + return Err(ApiError::InvalidInput( + "There is no pending request from this team".to_string(), + )); + } + + Ok(HttpResponse::NoContent().body("")) +} + +fn default_role() -> String { + "Member".to_string() +} + +fn default_ordering() -> i64 { + 0 +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct NewTeamMember { + pub user_id: UserId, + #[serde(default = "default_role")] + pub role: String, + #[serde(default)] + pub permissions: ProjectPermissions, + #[serde(default)] + pub organization_permissions: Option, + #[serde(default)] + #[serde(with = "rust_decimal::serde::float")] + pub payouts_split: Decimal, + #[serde(default = "default_ordering")] + pub ordering: i64, +} + +pub async fn add_team_member( + req: HttpRequest, + info: web::Path<(TeamId,)>, + pool: web::Data, + new_member: web::Json, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let team_id = info.into_inner().0.into(); + + let mut transaction = pool.begin().await?; + + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE]), + ) + .await? + .1; + let team_association = Team::get_association(team_id, &**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The team specified does not exist".to_string(), + ) + })?; + let member = + TeamMember::get_from_user_id(team_id, current_user.id.into(), &**pool) + .await?; + match team_association { + // If team is associated with a project, check if they have permissions to invite users to that project + TeamAssociationId::Project(pid) => { + let organization = + Organization::get_associated_organization_project_id( + pid, &**pool, + ) + .await?; + let organization_team_member = + if let Some(organization) = &organization { + TeamMember::get_from_user_id( + organization.team_id, + current_user.id.into(), + &**pool, + ) + .await? + } else { + None + }; + let permissions = ProjectPermissions::get_permissions_by_role( + ¤t_user.role, + &member, + &organization_team_member, + ) + .unwrap_or_default(); + + if !permissions.contains(ProjectPermissions::MANAGE_INVITES) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to invite users to this team" + .to_string(), + )); + } + if !permissions.contains(new_member.permissions) { + return Err(ApiError::InvalidInput( + "The new member has permissions that you don't have" + .to_string(), + )); + } + + if new_member.organization_permissions.is_some() { + return Err(ApiError::InvalidInput( + "The organization permissions of a project team member cannot be set" + .to_string(), + )); + } + } + // If team is associated with an organization, check if they have permissions to invite users to that organization + TeamAssociationId::Organization(_) => { + let organization_permissions = + OrganizationPermissions::get_permissions_by_role( + ¤t_user.role, + &member, + ) + .unwrap_or_default(); + if !organization_permissions + .contains(OrganizationPermissions::MANAGE_INVITES) + { + return Err(ApiError::CustomAuthentication( + "You don't have permission to invite users to this organization".to_string(), + )); + } + if !organization_permissions.contains( + new_member.organization_permissions.unwrap_or_default(), + ) { + return Err(ApiError::InvalidInput( + "The new member has organization permissions that you don't have".to_string(), + )); + } + if !organization_permissions.contains( + OrganizationPermissions::EDIT_MEMBER_DEFAULT_PERMISSIONS, + ) && !new_member.permissions.is_empty() + { + return Err(ApiError::CustomAuthentication( + "You do not have permission to give this user default project permissions. Ensure 'permissions' is set if it is not, and empty (0)." + .to_string(), + )); + } + } + } + + if new_member.payouts_split < Decimal::ZERO + || new_member.payouts_split > Decimal::from(5000) + { + return Err(ApiError::InvalidInput( + "Payouts split must be between 0 and 5000!".to_string(), + )); + } + + let request = TeamMember::get_from_user_id_pending( + team_id, + new_member.user_id.into(), + &**pool, + ) + .await?; + + if let Some(req) = request { + if req.accepted { + return Err(ApiError::InvalidInput( + "The user is already a member of that team".to_string(), + )); + } else { + return Err(ApiError::InvalidInput( + "There is already a pending member request for this user" + .to_string(), + )); + } + } + let new_user = crate::database::models::User::get_id( + new_member.user_id.into(), + &**pool, + &redis, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput("An invalid User ID specified".to_string()) + })?; + + let mut force_accepted = false; + if let TeamAssociationId::Project(pid) = team_association { + // We cannot add the owner to a project team in their own org + let organization = + Organization::get_associated_organization_project_id(pid, &**pool) + .await?; + let new_user_organization_team_member = + if let Some(organization) = &organization { + TeamMember::get_from_user_id( + organization.team_id, + new_user.id, + &**pool, + ) + .await? + } else { + None + }; + if new_user_organization_team_member + .as_ref() + .map(|tm| tm.is_owner) + .unwrap_or(false) + && new_member.permissions != ProjectPermissions::all() + { + return Err(ApiError::InvalidInput( + "You cannot override the owner of an organization's permissions in a project team" + .to_string(), + )); + } + + // In the case of adding a user that is in an org, to a project that is owned by that same org, + // the user is automatically accepted into that project. + // That is because the user is part of the org, and project teame-membership in an org can also be used to reduce permissions + // (Which should not be a deniable action by that user) + if new_user_organization_team_member.is_some() { + force_accepted = true; + } + } + + let new_id = + crate::database::models::ids::generate_team_member_id(&mut transaction) + .await?; + TeamMember { + id: new_id, + team_id, + user_id: new_member.user_id.into(), + role: new_member.role.clone(), + is_owner: false, // Cannot just create an owner + permissions: new_member.permissions, + organization_permissions: new_member.organization_permissions, + accepted: force_accepted, + payouts_split: new_member.payouts_split, + ordering: new_member.ordering, + } + .insert(&mut transaction) + .await?; + + // If the user has an opportunity to accept the invite, send a notification + if !force_accepted { + match team_association { + TeamAssociationId::Project(pid) => { + NotificationBuilder { + body: NotificationBody::TeamInvite { + project_id: pid.into(), + team_id: team_id.into(), + invited_by: current_user.id, + role: new_member.role.clone(), + }, + } + .insert(new_member.user_id.into(), &mut transaction, &redis) + .await?; + } + TeamAssociationId::Organization(oid) => { + NotificationBuilder { + body: NotificationBody::OrganizationInvite { + organization_id: oid.into(), + team_id: team_id.into(), + invited_by: current_user.id, + role: new_member.role.clone(), + }, + } + .insert(new_member.user_id.into(), &mut transaction, &redis) + .await?; + } + } + } + + transaction.commit().await?; + TeamMember::clear_cache(team_id, &redis).await?; + User::clear_project_cache(&[new_member.user_id.into()], &redis).await?; + + Ok(HttpResponse::NoContent().body("")) +} + +#[derive(Serialize, Deserialize, Clone)] +pub struct EditTeamMember { + pub permissions: Option, + pub organization_permissions: Option, + pub role: Option, + pub payouts_split: Option, + pub ordering: Option, +} + +pub async fn edit_team_member( + req: HttpRequest, + info: web::Path<(TeamId, UserId)>, + pool: web::Data, + edit_member: web::Json, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let ids = info.into_inner(); + let id = ids.0.into(); + let user_id = ids.1.into(); + + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE]), + ) + .await? + .1; + + let team_association = + Team::get_association(id, &**pool).await?.ok_or_else(|| { + ApiError::InvalidInput( + "The team specified does not exist".to_string(), + ) + })?; + let member = + TeamMember::get_from_user_id(id, current_user.id.into(), &**pool) + .await?; + let edit_member_db = + TeamMember::get_from_user_id_pending(id, user_id, &**pool) + .await? + .ok_or_else(|| { + ApiError::CustomAuthentication( + "You don't have permission to edit members of this team" + .to_string(), + ) + })?; + + let mut transaction = pool.begin().await?; + + if edit_member_db.is_owner + && (edit_member.permissions.is_some() + || edit_member.organization_permissions.is_some()) + { + return Err(ApiError::InvalidInput( + "The owner's permission's in a team cannot be edited".to_string(), + )); + } + + match team_association { + TeamAssociationId::Project(project_id) => { + let organization = + Organization::get_associated_organization_project_id( + project_id, &**pool, + ) + .await?; + let organization_team_member = + if let Some(organization) = &organization { + TeamMember::get_from_user_id( + organization.team_id, + current_user.id.into(), + &**pool, + ) + .await? + } else { + None + }; + + if organization_team_member + .as_ref() + .map(|x| x.is_owner) + .unwrap_or(false) + && edit_member + .permissions + .map(|x| x != ProjectPermissions::all()) + .unwrap_or(false) + { + return Err(ApiError::CustomAuthentication( + "You cannot override the project permissions of the organization owner!" + .to_string(), + )); + } + + let permissions = ProjectPermissions::get_permissions_by_role( + ¤t_user.role, + &member.clone(), + &organization_team_member, + ) + .unwrap_or_default(); + if !permissions.contains(ProjectPermissions::EDIT_MEMBER) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit members of this team" + .to_string(), + )); + } + + if let Some(new_permissions) = edit_member.permissions { + if !permissions.contains(new_permissions) { + return Err(ApiError::InvalidInput( + "The new permissions have permissions that you don't have".to_string(), + )); + } + } + + if edit_member.organization_permissions.is_some() { + return Err(ApiError::InvalidInput( + "The organization permissions of a project team member cannot be edited" + .to_string(), + )); + } + } + TeamAssociationId::Organization(_) => { + let organization_permissions = + OrganizationPermissions::get_permissions_by_role( + ¤t_user.role, + &member, + ) + .unwrap_or_default(); + + if !organization_permissions + .contains(OrganizationPermissions::EDIT_MEMBER) + { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit members of this team" + .to_string(), + )); + } + + if let Some(new_permissions) = edit_member.organization_permissions + { + if !organization_permissions.contains(new_permissions) { + return Err(ApiError::InvalidInput( + "The new organization permissions have permissions that you don't have" + .to_string(), + )); + } + } + + if edit_member.permissions.is_some() + && !organization_permissions.contains( + OrganizationPermissions::EDIT_MEMBER_DEFAULT_PERMISSIONS, + ) + { + return Err(ApiError::CustomAuthentication( + "You do not have permission to give this user default project permissions." + .to_string(), + )); + } + } + } + + if let Some(payouts_split) = edit_member.payouts_split { + if payouts_split < Decimal::ZERO || payouts_split > Decimal::from(5000) + { + return Err(ApiError::InvalidInput( + "Payouts split must be between 0 and 5000!".to_string(), + )); + } + } + + TeamMember::edit_team_member( + id, + user_id, + edit_member.permissions, + edit_member.organization_permissions, + edit_member.role.clone(), + None, + edit_member.payouts_split, + edit_member.ordering, + None, + &mut transaction, + ) + .await?; + + transaction.commit().await?; + TeamMember::clear_cache(id, &redis).await?; + + Ok(HttpResponse::NoContent().body("")) +} + +#[derive(Deserialize)] +pub struct TransferOwnership { + pub user_id: UserId, +} + +pub async fn transfer_ownership( + req: HttpRequest, + info: web::Path<(TeamId,)>, + pool: web::Data, + new_owner: web::Json, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let id = info.into_inner().0; + + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE]), + ) + .await? + .1; + + // Forbid transferring ownership of a project team that is owned by an organization + // These are owned by the organization owner, and must be removed from the organization first + // There shouldnt be an ownr on these projects in these cases, but just in case. + let team_association_id = Team::get_association(id.into(), &**pool).await?; + if let Some(TeamAssociationId::Project(pid)) = team_association_id { + let result = Project::get_id(pid, &**pool, &redis).await?; + if let Some(project_item) = result { + if project_item.inner.organization_id.is_some() { + return Err(ApiError::InvalidInput( + "You cannot transfer ownership of a project team that is owend by an organization" + .to_string(), + )); + } + } + } + + if !current_user.role.is_admin() { + let member = TeamMember::get_from_user_id( + id.into(), + current_user.id.into(), + &**pool, + ) + .await? + .ok_or_else(|| { + ApiError::CustomAuthentication( + "You don't have permission to edit members of this team" + .to_string(), + ) + })?; + + if !member.is_owner { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit the ownership of this team" + .to_string(), + )); + } + } + + let new_member = TeamMember::get_from_user_id( + id.into(), + new_owner.user_id.into(), + &**pool, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The new owner specified does not exist".to_string(), + ) + })?; + + if !new_member.accepted { + return Err(ApiError::InvalidInput( + "You can only transfer ownership to members who are currently in your team".to_string(), + )); + } + + let mut transaction = pool.begin().await?; + + // The following are the only places new_is_owner is modified. + TeamMember::edit_team_member( + id.into(), + current_user.id.into(), + None, + None, + None, + None, + None, + None, + Some(false), + &mut transaction, + ) + .await?; + + TeamMember::edit_team_member( + id.into(), + new_owner.user_id.into(), + Some(ProjectPermissions::all()), + if matches!( + team_association_id, + Some(TeamAssociationId::Organization(_)) + ) { + Some(OrganizationPermissions::all()) + } else { + None + }, + None, + None, + None, + None, + Some(true), + &mut transaction, + ) + .await?; + + let project_teams_edited = + if let Some(TeamAssociationId::Organization(oid)) = team_association_id + { + // The owner of ALL projects that this organization owns, if applicable, should be removed as members of the project, + // if they are members of those projects. + // (As they are the org owners for them, and they should not have more specific permissions) + + // First, get team id for every project owned by this organization + let team_ids = sqlx::query!( + " + SELECT m.team_id FROM organizations o + INNER JOIN mods m ON m.organization_id = o.id + WHERE o.id = $1 AND $1 IS NOT NULL + ", + oid.0 as i64 + ) + .fetch_all(&mut *transaction) + .await?; + + let team_ids: Vec = team_ids + .into_iter() + .map(|x| TeamId(x.team_id as u64).into()) + .collect(); + + // If the owner of the organization is a member of the project, remove them + for team_id in team_ids.iter() { + TeamMember::delete( + *team_id, + new_owner.user_id.into(), + &mut transaction, + ) + .await?; + } + + team_ids + } else { + vec![] + }; + + transaction.commit().await?; + TeamMember::clear_cache(id.into(), &redis).await?; + for team_id in project_teams_edited { + TeamMember::clear_cache(team_id, &redis).await?; + } + + Ok(HttpResponse::NoContent().body("")) +} + +pub async fn remove_team_member( + req: HttpRequest, + info: web::Path<(TeamId, UserId)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let ids = info.into_inner(); + let id = ids.0.into(); + let user_id = ids.1.into(); + + let current_user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_WRITE]), + ) + .await? + .1; + + let team_association = + Team::get_association(id, &**pool).await?.ok_or_else(|| { + ApiError::InvalidInput( + "The team specified does not exist".to_string(), + ) + })?; + let member = + TeamMember::get_from_user_id(id, current_user.id.into(), &**pool) + .await?; + + let delete_member = + TeamMember::get_from_user_id_pending(id, user_id, &**pool).await?; + + if let Some(delete_member) = delete_member { + if delete_member.is_owner { + // The owner cannot be removed from a team + return Err(ApiError::CustomAuthentication( + "The owner can't be removed from a team".to_string(), + )); + } + + let mut transaction = pool.begin().await?; + + // Organization attached to a project this team is attached to + match team_association { + TeamAssociationId::Project(pid) => { + let organization = + Organization::get_associated_organization_project_id( + pid, &**pool, + ) + .await?; + let organization_team_member = + if let Some(organization) = &organization { + TeamMember::get_from_user_id( + organization.team_id, + current_user.id.into(), + &**pool, + ) + .await? + } else { + None + }; + let permissions = ProjectPermissions::get_permissions_by_role( + ¤t_user.role, + &member, + &organization_team_member, + ) + .unwrap_or_default(); + + if delete_member.accepted { + // Members other than the owner can either leave the team, or be + // removed by a member with the REMOVE_MEMBER permission. + if Some(delete_member.user_id) + == member.as_ref().map(|m| m.user_id) + || permissions + .contains(ProjectPermissions::REMOVE_MEMBER) + // true as if the permission exists, but the member does not, they are part of an org + { + TeamMember::delete(id, user_id, &mut transaction) + .await?; + } else { + return Err(ApiError::CustomAuthentication( + "You do not have permission to remove a member from this team" + .to_string(), + )); + } + } else if Some(delete_member.user_id) + == member.as_ref().map(|m| m.user_id) + || permissions.contains(ProjectPermissions::MANAGE_INVITES) + // true as if the permission exists, but the member does not, they are part of an org + { + // This is a pending invite rather than a member, so the + // user being invited or team members with the MANAGE_INVITES + // permission can remove it. + TeamMember::delete(id, user_id, &mut transaction).await?; + } else { + return Err(ApiError::CustomAuthentication( + "You do not have permission to cancel a team invite" + .to_string(), + )); + } + } + TeamAssociationId::Organization(_) => { + let organization_permissions = + OrganizationPermissions::get_permissions_by_role( + ¤t_user.role, + &member, + ) + .unwrap_or_default(); + // Organization teams requires a TeamMember, so we can 'unwrap' + if delete_member.accepted { + // Members other than the owner can either leave the team, or be + // removed by a member with the REMOVE_MEMBER permission. + if Some(delete_member.user_id) == member.map(|m| m.user_id) + || organization_permissions + .contains(OrganizationPermissions::REMOVE_MEMBER) + { + TeamMember::delete(id, user_id, &mut transaction) + .await?; + } else { + return Err(ApiError::CustomAuthentication( + "You do not have permission to remove a member from this organization" + .to_string(), + )); + } + } else if Some(delete_member.user_id) + == member.map(|m| m.user_id) + || organization_permissions + .contains(OrganizationPermissions::MANAGE_INVITES) + { + // This is a pending invite rather than a member, so the + // user being invited or team members with the MANAGE_INVITES + // permission can remove it. + TeamMember::delete(id, user_id, &mut transaction).await?; + } else { + return Err(ApiError::CustomAuthentication( + "You do not have permission to cancel an organization invite".to_string(), + )); + } + } + } + + transaction.commit().await?; + + TeamMember::clear_cache(id, &redis).await?; + User::clear_project_cache(&[delete_member.user_id], &redis).await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::NotFound) + } +} diff --git a/apps/labrinth/src/routes/v3/threads.rs b/apps/labrinth/src/routes/v3/threads.rs new file mode 100644 index 000000000..ef500e94e --- /dev/null +++ b/apps/labrinth/src/routes/v3/threads.rs @@ -0,0 +1,634 @@ +use std::sync::Arc; + +use crate::auth::get_user_from_headers; +use crate::database; +use crate::database::models::image_item; +use crate::database::models::notification_item::NotificationBuilder; +use crate::database::models::thread_item::ThreadMessageBuilder; +use crate::database::redis::RedisPool; +use crate::file_hosting::FileHost; +use crate::models::ids::ThreadMessageId; +use crate::models::images::{Image, ImageContext}; +use crate::models::notifications::NotificationBody; +use crate::models::pats::Scopes; +use crate::models::projects::ProjectStatus; +use crate::models::threads::{MessageBody, Thread, ThreadId, ThreadType}; +use crate::models::users::User; +use crate::queue::session::AuthQueue; +use crate::routes::ApiError; +use actix_web::{web, HttpRequest, HttpResponse}; +use futures::TryStreamExt; +use serde::Deserialize; +use sqlx::PgPool; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("thread") + .route("{id}", web::get().to(thread_get)) + .route("{id}", web::post().to(thread_send_message)), + ); + cfg.service( + web::scope("message").route("{id}", web::delete().to(message_delete)), + ); + cfg.route("threads", web::get().to(threads_get)); +} + +pub async fn is_authorized_thread( + thread: &database::models::Thread, + user: &User, + pool: &PgPool, +) -> Result { + if user.role.is_mod() { + return Ok(true); + } + + let user_id: database::models::UserId = user.id.into(); + Ok(match thread.type_ { + ThreadType::Report => { + if let Some(report_id) = thread.report_id { + let report_exists = sqlx::query!( + "SELECT EXISTS(SELECT 1 FROM reports WHERE id = $1 AND reporter = $2)", + report_id as database::models::ids::ReportId, + user_id as database::models::ids::UserId, + ) + .fetch_one(pool) + .await? + .exists; + + report_exists.unwrap_or(false) + } else { + false + } + } + ThreadType::Project => { + if let Some(project_id) = thread.project_id { + let project_exists = sqlx::query!( + "SELECT EXISTS(SELECT 1 FROM mods m INNER JOIN team_members tm ON tm.team_id = m.team_id AND tm.user_id = $2 WHERE m.id = $1)", + project_id as database::models::ids::ProjectId, + user_id as database::models::ids::UserId, + ) + .fetch_one(pool) + .await? + .exists; + + if !project_exists.unwrap_or(false) { + let org_exists = sqlx::query!( + "SELECT EXISTS(SELECT 1 FROM mods m INNER JOIN organizations o ON m.organization_id = o.id INNER JOIN team_members tm ON tm.team_id = o.team_id AND tm.user_id = $2 WHERE m.id = $1)", + project_id as database::models::ids::ProjectId, + user_id as database::models::ids::UserId, + ) + .fetch_one(pool) + .await? + .exists; + + org_exists.unwrap_or(false) + } else { + true + } + } else { + false + } + } + ThreadType::DirectMessage => thread.members.contains(&user_id), + }) +} + +pub async fn filter_authorized_threads( + threads: Vec, + user: &User, + pool: &web::Data, + redis: &RedisPool, +) -> Result, ApiError> { + let user_id: database::models::UserId = user.id.into(); + + let mut return_threads = Vec::new(); + let mut check_threads = Vec::new(); + + for thread in threads { + if user.role.is_mod() + || (thread.type_ == ThreadType::DirectMessage + && thread.members.contains(&user_id)) + { + return_threads.push(thread); + } else { + check_threads.push(thread); + } + } + + if !check_threads.is_empty() { + let project_thread_ids = check_threads + .iter() + .filter(|x| x.type_ == ThreadType::Project) + .flat_map(|x| x.project_id.map(|x| x.0)) + .collect::>(); + + if !project_thread_ids.is_empty() { + sqlx::query!( + " + SELECT m.id FROM mods m + INNER JOIN team_members tm ON tm.team_id = m.team_id AND user_id = $2 + WHERE m.id = ANY($1) + ", + &*project_thread_ids, + user_id as database::models::ids::UserId, + ) + .fetch(&***pool) + .map_ok(|row| { + check_threads.retain(|x| { + let bool = x.project_id.map(|x| x.0) == Some(row.id); + + if bool { + return_threads.push(x.clone()); + } + + !bool + }); + }) + .try_collect::>() + .await?; + } + + let org_project_thread_ids = check_threads + .iter() + .filter(|x| x.type_ == ThreadType::Project) + .flat_map(|x| x.project_id.map(|x| x.0)) + .collect::>(); + + if !org_project_thread_ids.is_empty() { + sqlx::query!( + " + SELECT m.id FROM mods m + INNER JOIN organizations o ON o.id = m.organization_id + INNER JOIN team_members tm ON tm.team_id = o.team_id AND user_id = $2 + WHERE m.id = ANY($1) + ", + &*project_thread_ids, + user_id as database::models::ids::UserId, + ) + .fetch(&***pool) + .map_ok(|row| { + check_threads.retain(|x| { + let bool = x.project_id.map(|x| x.0) == Some(row.id); + + if bool { + return_threads.push(x.clone()); + } + + !bool + }); + }) + .try_collect::>() + .await?; + } + + let report_thread_ids = check_threads + .iter() + .filter(|x| x.type_ == ThreadType::Report) + .flat_map(|x| x.report_id.map(|x| x.0)) + .collect::>(); + + if !report_thread_ids.is_empty() { + sqlx::query!( + " + SELECT id FROM reports + WHERE id = ANY($1) AND reporter = $2 + ", + &*report_thread_ids, + user_id as database::models::ids::UserId, + ) + .fetch(&***pool) + .map_ok(|row| { + check_threads.retain(|x| { + let bool = x.report_id.map(|x| x.0) == Some(row.id); + + if bool { + return_threads.push(x.clone()); + } + + !bool + }); + }) + .try_collect::>() + .await?; + } + } + + let mut user_ids = return_threads + .iter() + .flat_map(|x| x.members.clone()) + .collect::>(); + user_ids.append( + &mut return_threads + .iter() + .flat_map(|x| { + x.messages + .iter() + .filter_map(|x| x.author_id) + .collect::>() + }) + .collect::>(), + ); + + let users: Vec = + database::models::User::get_many_ids(&user_ids, &***pool, redis) + .await? + .into_iter() + .map(From::from) + .collect(); + + let mut final_threads = Vec::new(); + + for thread in return_threads { + let mut authors = thread.members.clone(); + + authors.append( + &mut thread + .messages + .iter() + .filter_map(|x| { + if x.hide_identity && !user.role.is_mod() { + None + } else { + x.author_id + } + }) + .collect::>(), + ); + + final_threads.push(Thread::from( + thread, + users + .iter() + .filter(|x| authors.contains(&x.id.into())) + .cloned() + .collect(), + user, + )); + } + + Ok(final_threads) +} + +pub async fn thread_get( + req: HttpRequest, + info: web::Path<(ThreadId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let string = info.into_inner().0.into(); + + let thread_data = database::models::Thread::get(string, &**pool).await?; + + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::THREAD_READ]), + ) + .await? + .1; + + if let Some(mut data) = thread_data { + if is_authorized_thread(&data, &user, &pool).await? { + let authors = &mut data.members; + + authors.append( + &mut data + .messages + .iter() + .filter_map(|x| { + if x.hide_identity && !user.role.is_mod() { + None + } else { + x.author_id + } + }) + .collect::>(), + ); + + let users: Vec = + database::models::User::get_many_ids(authors, &**pool, &redis) + .await? + .into_iter() + .map(From::from) + .collect(); + + return Ok( + HttpResponse::Ok().json(Thread::from(data, users, &user)) + ); + } + } + Err(ApiError::NotFound) +} + +#[derive(Deserialize)] +pub struct ThreadIds { + pub ids: String, +} + +pub async fn threads_get( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::THREAD_READ]), + ) + .await? + .1; + + let thread_ids: Vec = + serde_json::from_str::>(&ids.ids)? + .into_iter() + .map(|x| x.into()) + .collect(); + + let threads_data = + database::models::Thread::get_many(&thread_ids, &**pool).await?; + + let threads = + filter_authorized_threads(threads_data, &user, &pool, &redis).await?; + + Ok(HttpResponse::Ok().json(threads)) +} + +#[derive(Deserialize)] +pub struct NewThreadMessage { + pub body: MessageBody, +} + +pub async fn thread_send_message( + req: HttpRequest, + info: web::Path<(ThreadId,)>, + pool: web::Data, + new_message: web::Json, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::THREAD_WRITE]), + ) + .await? + .1; + + let string: database::models::ThreadId = info.into_inner().0.into(); + + if let MessageBody::Text { + body, + replying_to, + private, + .. + } = &new_message.body + { + if body.len() > 65536 { + return Err(ApiError::InvalidInput( + "Input body is too long!".to_string(), + )); + } + + if *private && !user.role.is_mod() { + return Err(ApiError::InvalidInput( + "You are not allowed to send private messages!".to_string(), + )); + } + + if let Some(replying_to) = replying_to { + let thread_message = database::models::ThreadMessage::get( + (*replying_to).into(), + &**pool, + ) + .await?; + + if let Some(thread_message) = thread_message { + if thread_message.thread_id != string { + return Err(ApiError::InvalidInput( + "Message replied to is from another thread!" + .to_string(), + )); + } + } else { + return Err(ApiError::InvalidInput( + "Message replied to does not exist!".to_string(), + )); + } + } + } else { + return Err(ApiError::InvalidInput( + "You may only send text messages through this route!".to_string(), + )); + } + + let result = database::models::Thread::get(string, &**pool).await?; + + if let Some(thread) = result { + if !is_authorized_thread(&thread, &user, &pool).await? { + return Err(ApiError::NotFound); + } + + let mut transaction = pool.begin().await?; + + let id = ThreadMessageBuilder { + author_id: Some(user.id.into()), + body: new_message.body.clone(), + thread_id: thread.id, + hide_identity: user.role.is_mod(), + } + .insert(&mut transaction) + .await?; + + if let Some(project_id) = thread.project_id { + let project = + database::models::Project::get_id(project_id, &**pool, &redis) + .await?; + + if let Some(project) = project { + if project.inner.status != ProjectStatus::Processing + && user.role.is_mod() + { + let members = + database::models::TeamMember::get_from_team_full( + project.inner.team_id, + &**pool, + &redis, + ) + .await?; + + NotificationBuilder { + body: NotificationBody::ModeratorMessage { + thread_id: thread.id.into(), + message_id: id.into(), + project_id: Some(project.inner.id.into()), + report_id: None, + }, + } + .insert_many( + members.into_iter().map(|x| x.user_id).collect(), + &mut transaction, + &redis, + ) + .await?; + } + } + } else if let Some(report_id) = thread.report_id { + let report = + database::models::report_item::Report::get(report_id, &**pool) + .await?; + + if let Some(report) = report { + if report.closed && !user.role.is_mod() { + return Err(ApiError::InvalidInput( + "You may not reply to a closed report".to_string(), + )); + } + + if user.id != report.reporter.into() { + NotificationBuilder { + body: NotificationBody::ModeratorMessage { + thread_id: thread.id.into(), + message_id: id.into(), + project_id: None, + report_id: Some(report.id.into()), + }, + } + .insert(report.reporter, &mut transaction, &redis) + .await?; + } + } + } + + if let MessageBody::Text { + associated_images, .. + } = &new_message.body + { + for image_id in associated_images { + if let Some(db_image) = image_item::Image::get( + (*image_id).into(), + &mut *transaction, + &redis, + ) + .await? + { + let image: Image = db_image.into(); + if !matches!( + image.context, + ImageContext::ThreadMessage { .. } + ) || image.context.inner_id().is_some() + { + return Err(ApiError::InvalidInput(format!( + "Image {} is not unused and in the 'thread_message' context", + image_id + ))); + } + + sqlx::query!( + " + UPDATE uploaded_images + SET thread_message_id = $1 + WHERE id = $2 + ", + thread.id.0, + image_id.0 as i64 + ) + .execute(&mut *transaction) + .await?; + + image_item::Image::clear_cache(image.id.into(), &redis) + .await?; + } else { + return Err(ApiError::InvalidInput(format!( + "Image {} does not exist", + image_id + ))); + } + } + } + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::NotFound) + } +} + +pub async fn message_delete( + req: HttpRequest, + info: web::Path<(ThreadMessageId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + file_host: web::Data>, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::THREAD_WRITE]), + ) + .await? + .1; + + let result = database::models::ThreadMessage::get( + info.into_inner().0.into(), + &**pool, + ) + .await?; + + if let Some(thread) = result { + if !user.role.is_mod() && thread.author_id != Some(user.id.into()) { + return Err(ApiError::CustomAuthentication( + "You cannot delete this message!".to_string(), + )); + } + + let mut transaction = pool.begin().await?; + + let context = ImageContext::ThreadMessage { + thread_message_id: Some(thread.id.into()), + }; + let images = + database::Image::get_many_contexted(context, &mut transaction) + .await?; + let cdn_url = dotenvy::var("CDN_URL")?; + for image in images { + let name = image.url.split(&format!("{cdn_url}/")).nth(1); + if let Some(icon_path) = name { + file_host.delete_file_version("", icon_path).await?; + } + database::Image::remove(image.id, &mut transaction, &redis).await?; + } + + let private = if let MessageBody::Text { private, .. } = thread.body { + private + } else if let MessageBody::Deleted { private, .. } = thread.body { + private + } else { + false + }; + + database::models::ThreadMessage::remove_full( + thread.id, + private, + &mut transaction, + ) + .await?; + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::NotFound) + } +} diff --git a/apps/labrinth/src/routes/v3/users.rs b/apps/labrinth/src/routes/v3/users.rs new file mode 100644 index 000000000..dd7d3052b --- /dev/null +++ b/apps/labrinth/src/routes/v3/users.rs @@ -0,0 +1,657 @@ +use std::{collections::HashMap, sync::Arc}; + +use actix_web::{web, HttpRequest, HttpResponse}; +use lazy_static::lazy_static; +use regex::Regex; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use validator::Validate; + +use super::{oauth_clients::get_user_clients, ApiError}; +use crate::util::img::delete_old_images; +use crate::{ + auth::{filter_visible_projects, get_user_from_headers}, + database::{models::User, redis::RedisPool}, + file_hosting::FileHost, + models::{ + collections::{Collection, CollectionStatus}, + ids::UserId, + notifications::Notification, + pats::Scopes, + projects::Project, + users::{Badges, Role}, + }, + queue::session::AuthQueue, + util::{routes::read_from_payload, validate::validation_errors_to_string}, +}; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.route("user", web::get().to(user_auth_get)); + cfg.route("users", web::get().to(users_get)); + + cfg.service( + web::scope("user") + .route("{user_id}/projects", web::get().to(projects_list)) + .route("{id}", web::get().to(user_get)) + .route("{user_id}/collections", web::get().to(collections_list)) + .route("{user_id}/organizations", web::get().to(orgs_list)) + .route("{id}", web::patch().to(user_edit)) + .route("{id}/icon", web::patch().to(user_icon_edit)) + .route("{id}", web::delete().to(user_delete)) + .route("{id}/follows", web::get().to(user_follows)) + .route("{id}/notifications", web::get().to(user_notifications)) + .route("{id}/oauth_apps", web::get().to(get_user_clients)), + ); +} + +pub async fn projects_list( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; + + if let Some(id) = id_option.map(|x| x.id) { + let project_data = User::get_projects(id, &**pool, &redis).await?; + + let projects: Vec<_> = crate::database::Project::get_many_ids( + &project_data, + &**pool, + &redis, + ) + .await?; + let projects = + filter_visible_projects(projects, &user, &pool, true).await?; + Ok(HttpResponse::Ok().json(projects)) + } else { + Err(ApiError::NotFound) + } +} + +pub async fn user_auth_get( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let (scopes, mut user) = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::USER_READ]), + ) + .await?; + + if !scopes.contains(Scopes::USER_READ_EMAIL) { + user.email = None; + } + + if !scopes.contains(Scopes::PAYOUTS_READ) { + user.payout_data = None; + } + + Ok(HttpResponse::Ok().json(user)) +} + +#[derive(Serialize, Deserialize)] +pub struct UserIds { + pub ids: String, +} + +pub async fn users_get( + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, +) -> Result { + let user_ids = serde_json::from_str::>(&ids.ids)?; + + let users_data = User::get_many(&user_ids, &**pool, &redis).await?; + + let users: Vec = + users_data.into_iter().map(From::from).collect(); + + Ok(HttpResponse::Ok().json(users)) +} + +pub async fn user_get( + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, +) -> Result { + let user_data = User::get(&info.into_inner().0, &**pool, &redis).await?; + + if let Some(data) = user_data { + let response: crate::models::users::User = data.into(); + Ok(HttpResponse::Ok().json(response)) + } else { + Err(ApiError::NotFound) + } +} + +pub async fn collections_list( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::COLLECTION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; + + if let Some(id) = id_option.map(|x| x.id) { + let user_id: UserId = id.into(); + + let can_view_private = user + .map(|y| y.role.is_mod() || y.id == user_id) + .unwrap_or(false); + + let project_data = User::get_collections(id, &**pool).await?; + + let response: Vec<_> = crate::database::models::Collection::get_many( + &project_data, + &**pool, + &redis, + ) + .await? + .into_iter() + .filter(|x| { + can_view_private || matches!(x.status, CollectionStatus::Listed) + }) + .map(Collection::from) + .collect(); + + Ok(HttpResponse::Ok().json(response)) + } else { + Err(ApiError::NotFound) + } +} + +pub async fn orgs_list( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; + + if let Some(id) = id_option.map(|x| x.id) { + let org_data = User::get_organizations(id, &**pool).await?; + + let organizations_data = + crate::database::models::organization_item::Organization::get_many_ids( + &org_data, &**pool, &redis, + ) + .await?; + + let team_ids = organizations_data + .iter() + .map(|x| x.team_id) + .collect::>(); + + let teams_data = + crate::database::models::TeamMember::get_from_team_full_many( + &team_ids, &**pool, &redis, + ) + .await?; + let users = User::get_many_ids( + &teams_data.iter().map(|x| x.user_id).collect::>(), + &**pool, + &redis, + ) + .await?; + + let mut organizations = vec![]; + let mut team_groups = HashMap::new(); + for item in teams_data { + team_groups.entry(item.team_id).or_insert(vec![]).push(item); + } + + for data in organizations_data { + let members_data = + team_groups.remove(&data.team_id).unwrap_or(vec![]); + let logged_in = user + .as_ref() + .and_then(|user| { + members_data + .iter() + .find(|x| x.user_id == user.id.into() && x.accepted) + }) + .is_some(); + + let team_members: Vec<_> = members_data + .into_iter() + .filter(|x| logged_in || x.accepted || id == x.user_id) + .flat_map(|data| { + users.iter().find(|x| x.id == data.user_id).map(|user| { + crate::models::teams::TeamMember::from( + data, + user.clone(), + !logged_in, + ) + }) + }) + .collect(); + + let organization = crate::models::organizations::Organization::from( + data, + team_members, + ); + organizations.push(organization); + } + + Ok(HttpResponse::Ok().json(organizations)) + } else { + Err(ApiError::NotFound) + } +} + +lazy_static! { + static ref RE_URL_SAFE: Regex = Regex::new(r"^[a-zA-Z0-9_-]*$").unwrap(); +} + +#[derive(Serialize, Deserialize, Validate)] +pub struct EditUser { + #[validate(length(min = 1, max = 39), regex = "RE_URL_SAFE")] + pub username: Option, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + #[validate(length(max = 160))] + pub bio: Option>, + pub role: Option, + pub badges: Option, + #[validate(length(max = 160))] + pub venmo_handle: Option, +} + +pub async fn user_edit( + req: HttpRequest, + info: web::Path<(String,)>, + new_user: web::Json, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let (scopes, user) = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::USER_WRITE]), + ) + .await?; + + new_user.validate().map_err(|err| { + ApiError::Validation(validation_errors_to_string(err, None)) + })?; + + let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; + + if let Some(actual_user) = id_option { + let id = actual_user.id; + let user_id: UserId = id.into(); + + if user.id == user_id || user.role.is_mod() { + let mut transaction = pool.begin().await?; + + if let Some(username) = &new_user.username { + let existing_user_id_option = + User::get(username, &**pool, &redis).await?; + + if existing_user_id_option + .map(|x| UserId::from(x.id)) + .map(|id| id == user.id) + .unwrap_or(true) + { + sqlx::query!( + " + UPDATE users + SET username = $1 + WHERE (id = $2) + ", + username, + id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + } else { + return Err(ApiError::InvalidInput(format!( + "Username {username} is taken!" + ))); + } + } + + if let Some(bio) = &new_user.bio { + sqlx::query!( + " + UPDATE users + SET bio = $1 + WHERE (id = $2) + ", + bio.as_deref(), + id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(role) = &new_user.role { + if !user.role.is_admin() { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the role of this user!" + .to_string(), + )); + } + + let role = role.to_string(); + + sqlx::query!( + " + UPDATE users + SET role = $1 + WHERE (id = $2) + ", + role, + id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(badges) = &new_user.badges { + if !user.role.is_admin() { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the badges of this user!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE users + SET badges = $1 + WHERE (id = $2) + ", + badges.bits() as i64, + id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(venmo_handle) = &new_user.venmo_handle { + if !scopes.contains(Scopes::PAYOUTS_WRITE) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit the venmo handle of this user!" + .to_string(), + )); + } + + sqlx::query!( + " + UPDATE users + SET venmo_handle = $1 + WHERE (id = $2) + ", + venmo_handle, + id as crate::database::models::ids::UserId, + ) + .execute(&mut *transaction) + .await?; + } + + transaction.commit().await?; + User::clear_caches(&[(id, Some(actual_user.username))], &redis) + .await?; + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::CustomAuthentication( + "You do not have permission to edit this user!".to_string(), + )) + } + } else { + Err(ApiError::NotFound) + } +} + +#[derive(Serialize, Deserialize)] +pub struct Extension { + pub ext: String, +} + +#[allow(clippy::too_many_arguments)] +pub async fn user_icon_edit( + web::Query(ext): web::Query, + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + file_host: web::Data>, + mut payload: web::Payload, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::USER_WRITE]), + ) + .await? + .1; + let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; + + if let Some(actual_user) = id_option { + if user.id != actual_user.id.into() && !user.role.is_mod() { + return Err(ApiError::CustomAuthentication( + "You don't have permission to edit this user's icon." + .to_string(), + )); + } + + delete_old_images( + actual_user.avatar_url, + actual_user.raw_avatar_url, + &***file_host, + ) + .await?; + + let bytes = read_from_payload( + &mut payload, + 262144, + "Icons must be smaller than 256KiB", + ) + .await?; + + let user_id: UserId = actual_user.id.into(); + let upload_result = crate::util::img::upload_image_optimized( + &format!("data/{}", user_id), + bytes.freeze(), + &ext.ext, + Some(96), + Some(1.0), + &***file_host, + ) + .await?; + + sqlx::query!( + " + UPDATE users + SET avatar_url = $1, raw_avatar_url = $2 + WHERE (id = $3) + ", + upload_result.url, + upload_result.raw_url, + actual_user.id as crate::database::models::ids::UserId, + ) + .execute(&**pool) + .await?; + User::clear_caches(&[(actual_user.id, None)], &redis).await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::NotFound) + } +} + +pub async fn user_delete( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::USER_DELETE]), + ) + .await? + .1; + let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; + + if let Some(id) = id_option.map(|x| x.id) { + if !user.role.is_admin() && user.id != id.into() { + return Err(ApiError::CustomAuthentication( + "You do not have permission to delete this user!".to_string(), + )); + } + + let mut transaction = pool.begin().await?; + + let result = User::remove(id, &mut transaction, &redis).await?; + + transaction.commit().await?; + + if result.is_some() { + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::NotFound) + } + } else { + Err(ApiError::NotFound) + } +} + +pub async fn user_follows( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::USER_READ]), + ) + .await? + .1; + let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; + + if let Some(id) = id_option.map(|x| x.id) { + if !user.role.is_admin() && user.id != id.into() { + return Err(ApiError::CustomAuthentication( + "You do not have permission to see the projects this user follows!".to_string(), + )); + } + + let project_ids = User::get_follows(id, &**pool).await?; + let projects: Vec<_> = crate::database::Project::get_many_ids( + &project_ids, + &**pool, + &redis, + ) + .await? + .into_iter() + .map(Project::from) + .collect(); + + Ok(HttpResponse::Ok().json(projects)) + } else { + Err(ApiError::NotFound) + } +} + +pub async fn user_notifications( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::NOTIFICATION_READ]), + ) + .await? + .1; + let id_option = User::get(&info.into_inner().0, &**pool, &redis).await?; + + if let Some(id) = id_option.map(|x| x.id) { + if !user.role.is_admin() && user.id != id.into() { + return Err(ApiError::CustomAuthentication( + "You do not have permission to see the notifications of this user!".to_string(), + )); + } + + let mut notifications: Vec = + crate::database::models::notification_item::Notification::get_many_user( + id, &**pool, &redis, + ) + .await? + .into_iter() + .map(Into::into) + .collect(); + + notifications.sort_by(|a, b| b.created.cmp(&a.created)); + Ok(HttpResponse::Ok().json(notifications)) + } else { + Err(ApiError::NotFound) + } +} diff --git a/apps/labrinth/src/routes/v3/version_creation.rs b/apps/labrinth/src/routes/v3/version_creation.rs new file mode 100644 index 000000000..8d531c22b --- /dev/null +++ b/apps/labrinth/src/routes/v3/version_creation.rs @@ -0,0 +1,1072 @@ +use super::project_creation::{CreateError, UploadedFile}; +use crate::auth::get_user_from_headers; +use crate::database::models::loader_fields::{ + LoaderField, LoaderFieldEnumValue, VersionField, +}; +use crate::database::models::notification_item::NotificationBuilder; +use crate::database::models::version_item::{ + DependencyBuilder, VersionBuilder, VersionFileBuilder, +}; +use crate::database::models::{self, image_item, Organization}; +use crate::database::redis::RedisPool; +use crate::file_hosting::FileHost; +use crate::models::images::{Image, ImageContext, ImageId}; +use crate::models::notifications::NotificationBody; +use crate::models::pack::PackFileHash; +use crate::models::pats::Scopes; +use crate::models::projects::{skip_nulls, DependencyType, ProjectStatus}; +use crate::models::projects::{ + Dependency, FileType, Loader, ProjectId, Version, VersionFile, VersionId, + VersionStatus, VersionType, +}; +use crate::models::teams::ProjectPermissions; +use crate::queue::moderation::AutomatedModerationQueue; +use crate::queue::session::AuthQueue; +use crate::util::routes::read_from_field; +use crate::util::validate::validation_errors_to_string; +use crate::validate::{validate_file, ValidationResult}; +use actix_multipart::{Field, Multipart}; +use actix_web::web::Data; +use actix_web::{web, HttpRequest, HttpResponse}; +use chrono::Utc; +use futures::stream::StreamExt; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use sqlx::postgres::PgPool; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use validator::Validate; + +fn default_requested_status() -> VersionStatus { + VersionStatus::Listed +} + +#[derive(Serialize, Deserialize, Validate, Clone)] +pub struct InitialVersionData { + #[serde(alias = "mod_id")] + pub project_id: Option, + #[validate(length(min = 1, max = 256))] + pub file_parts: Vec, + #[validate( + length(min = 1, max = 32), + regex = "crate::util::validate::RE_URL_SAFE" + )] + pub version_number: String, + #[validate( + length(min = 1, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + #[serde(alias = "name")] + pub version_title: String, + #[validate(length(max = 65536))] + #[serde(alias = "changelog")] + pub version_body: Option, + #[validate( + length(min = 0, max = 4096), + custom(function = "crate::util::validate::validate_deps") + )] + pub dependencies: Vec, + #[serde(alias = "version_type")] + pub release_channel: VersionType, + #[validate(length(min = 1))] + pub loaders: Vec, + pub featured: bool, + pub primary_file: Option, + #[serde(default = "default_requested_status")] + pub status: VersionStatus, + #[serde(default = "HashMap::new")] + pub file_types: HashMap>, + // Associations to uploaded images in changelog + #[validate(length(max = 10))] + #[serde(default)] + pub uploaded_images: Vec, + // The ordering relative to other versions + pub ordering: Option, + + // Flattened loader fields + // All other fields are loader-specific VersionFields + // These are flattened during serialization + #[serde(deserialize_with = "skip_nulls")] + #[serde(flatten)] + pub fields: HashMap, +} + +#[derive(Serialize, Deserialize, Clone)] +struct InitialFileData { + #[serde(default = "HashMap::new")] + pub file_types: HashMap>, +} + +// under `/api/v1/version` +pub async fn version_create( + req: HttpRequest, + mut payload: Multipart, + client: Data, + redis: Data, + file_host: Data>, + session_queue: Data, + moderation_queue: web::Data, +) -> Result { + let mut transaction = client.begin().await?; + let mut uploaded_files = Vec::new(); + + let result = version_create_inner( + req, + &mut payload, + &mut transaction, + &redis, + &***file_host, + &mut uploaded_files, + &client, + &session_queue, + &moderation_queue, + ) + .await; + + if result.is_err() { + let undo_result = super::project_creation::undo_uploads( + &***file_host, + &uploaded_files, + ) + .await; + let rollback_result = transaction.rollback().await; + + undo_result?; + if let Err(e) = rollback_result { + return Err(e.into()); + } + } else { + transaction.commit().await?; + } + + result +} + +#[allow(clippy::too_many_arguments)] +async fn version_create_inner( + req: HttpRequest, + payload: &mut Multipart, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &RedisPool, + file_host: &dyn FileHost, + uploaded_files: &mut Vec, + pool: &PgPool, + session_queue: &AuthQueue, + moderation_queue: &AutomatedModerationQueue, +) -> Result { + let cdn_url = dotenvy::var("CDN_URL")?; + + let mut initial_version_data = None; + let mut version_builder = None; + let mut selected_loaders = None; + + let user = get_user_from_headers( + &req, + pool, + redis, + session_queue, + Some(&[Scopes::VERSION_CREATE]), + ) + .await? + .1; + + let mut error = None; + while let Some(item) = payload.next().await { + let mut field: Field = item?; + + if error.is_some() { + continue; + } + + let result = async { + let content_disposition = field.content_disposition().clone(); + let name = content_disposition.get_name().ok_or_else(|| { + CreateError::MissingValueError("Missing content name".to_string()) + })?; + + if name == "data" { + let mut data = Vec::new(); + while let Some(chunk) = field.next().await { + data.extend_from_slice(&chunk?); + } + + let version_create_data: InitialVersionData = serde_json::from_slice(&data)?; + initial_version_data = Some(version_create_data); + let version_create_data = initial_version_data.as_ref().unwrap(); + if version_create_data.project_id.is_none() { + return Err(CreateError::MissingValueError( + "Missing project id".to_string(), + )); + } + + version_create_data.validate().map_err(|err| { + CreateError::ValidationError(validation_errors_to_string(err, None)) + })?; + + if !version_create_data.status.can_be_requested() { + return Err(CreateError::InvalidInput( + "Status specified cannot be requested".to_string(), + )); + } + + let project_id: models::ProjectId = version_create_data.project_id.unwrap().into(); + + // Ensure that the project this version is being added to exists + if models::Project::get_id(project_id, &mut **transaction, redis) + .await? + .is_none() + { + return Err(CreateError::InvalidInput( + "An invalid project id was supplied".to_string(), + )); + } + + // Check that the user creating this version is a team member + // of the project the version is being added to. + let team_member = models::TeamMember::get_from_user_id_project( + project_id, + user.id.into(), + false, + &mut **transaction, + ) + .await?; + + // Get organization attached, if exists, and the member project permissions + let organization = models::Organization::get_associated_organization_project_id( + project_id, + &mut **transaction, + ) + .await?; + + let organization_team_member = if let Some(organization) = &organization { + models::TeamMember::get_from_user_id( + organization.team_id, + user.id.into(), + &mut **transaction, + ) + .await? + } else { + None + }; + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); + + if !permissions.contains(ProjectPermissions::UPLOAD_VERSION) { + return Err(CreateError::CustomAuthenticationError( + "You don't have permission to upload this version!".to_string(), + )); + } + + let version_id: VersionId = models::generate_version_id(transaction).await?.into(); + + let all_loaders = + models::loader_fields::Loader::list(&mut **transaction, redis).await?; + let loaders = version_create_data + .loaders + .iter() + .map(|x| { + all_loaders + .iter() + .find(|y| y.loader == x.0) + .cloned() + .ok_or_else(|| CreateError::InvalidLoader(x.0.clone())) + }) + .collect::, _>>()?; + selected_loaders = Some(loaders.clone()); + let loader_ids: Vec = loaders.iter().map(|y| y.id).collect_vec(); + + let loader_fields = + LoaderField::get_fields(&loader_ids, &mut **transaction, redis).await?; + let mut loader_field_enum_values = LoaderFieldEnumValue::list_many_loader_fields( + &loader_fields, + &mut **transaction, + redis, + ) + .await?; + let version_fields = try_create_version_fields( + version_id, + &version_create_data.fields, + &loader_fields, + &mut loader_field_enum_values, + )?; + + let dependencies = version_create_data + .dependencies + .iter() + .map(|d| models::version_item::DependencyBuilder { + version_id: d.version_id.map(|x| x.into()), + project_id: d.project_id.map(|x| x.into()), + dependency_type: d.dependency_type.to_string(), + file_name: None, + }) + .collect::>(); + + version_builder = Some(VersionBuilder { + version_id: version_id.into(), + project_id, + author_id: user.id.into(), + name: version_create_data.version_title.clone(), + version_number: version_create_data.version_number.clone(), + changelog: version_create_data.version_body.clone().unwrap_or_default(), + files: Vec::new(), + dependencies, + loaders: loader_ids, + version_fields, + version_type: version_create_data.release_channel.to_string(), + featured: version_create_data.featured, + status: version_create_data.status, + requested_status: None, + ordering: version_create_data.ordering, + }); + + return Ok(()); + } + + let version = version_builder.as_mut().ok_or_else(|| { + CreateError::InvalidInput(String::from("`data` field must come before file fields")) + })?; + let loaders = selected_loaders.as_ref().ok_or_else(|| { + CreateError::InvalidInput(String::from("`data` field must come before file fields")) + })?; + let loaders = loaders + .iter() + .map(|x| Loader(x.loader.clone())) + .collect::>(); + + let version_data = initial_version_data + .clone() + .ok_or_else(|| CreateError::InvalidInput("`data` field is required".to_string()))?; + + let existing_file_names = version.files.iter().map(|x| x.filename.clone()).collect(); + + upload_file( + &mut field, + file_host, + version_data.file_parts.len(), + uploaded_files, + &mut version.files, + &mut version.dependencies, + &cdn_url, + &content_disposition, + version.project_id.into(), + version.version_id.into(), + &version.version_fields, + loaders, + version_data.primary_file.is_some(), + version_data.primary_file.as_deref() == Some(name), + version_data.file_types.get(name).copied().flatten(), + existing_file_names, + transaction, + redis, + ) + .await?; + + Ok(()) + } + .await; + + if result.is_err() { + error = result.err(); + } + } + + if let Some(error) = error { + return Err(error); + } + + let version_data = initial_version_data.ok_or_else(|| { + CreateError::InvalidInput("`data` field is required".to_string()) + })?; + let builder = version_builder.ok_or_else(|| { + CreateError::InvalidInput("`data` field is required".to_string()) + })?; + + if builder.files.is_empty() { + return Err(CreateError::InvalidInput( + "Versions must have at least one file uploaded to them".to_string(), + )); + } + + use futures::stream::TryStreamExt; + + let users = sqlx::query!( + " + SELECT follower_id FROM mod_follows + WHERE mod_id = $1 + ", + builder.project_id as crate::database::models::ids::ProjectId + ) + .fetch(&mut **transaction) + .map_ok(|m| models::ids::UserId(m.follower_id)) + .try_collect::>() + .await?; + + let project_id: ProjectId = builder.project_id.into(); + let version_id: VersionId = builder.version_id.into(); + + NotificationBuilder { + body: NotificationBody::ProjectUpdate { + project_id, + version_id, + }, + } + .insert_many(users, &mut *transaction, redis) + .await?; + + let loader_structs = selected_loaders.unwrap_or_default(); + let (all_project_types, all_games): (Vec, Vec) = + loader_structs.iter().fold((vec![], vec![]), |mut acc, x| { + acc.0.extend_from_slice(&x.supported_project_types); + acc.1.extend(x.supported_games.clone()); + acc + }); + + let response = Version { + id: builder.version_id.into(), + project_id: builder.project_id.into(), + author_id: user.id, + featured: builder.featured, + name: builder.name.clone(), + version_number: builder.version_number.clone(), + project_types: all_project_types, + games: all_games, + changelog: builder.changelog.clone(), + date_published: Utc::now(), + downloads: 0, + version_type: version_data.release_channel, + status: builder.status, + requested_status: builder.requested_status, + ordering: builder.ordering, + files: builder + .files + .iter() + .map(|file| VersionFile { + hashes: file + .hashes + .iter() + .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(), + primary: file.primary, + size: file.size, + file_type: file.file_type, + }) + .collect::>(), + dependencies: version_data.dependencies, + loaders: version_data.loaders, + fields: version_data.fields, + }; + + let project_id = builder.project_id; + builder.insert(transaction).await?; + + for image_id in version_data.uploaded_images { + if let Some(db_image) = + image_item::Image::get(image_id.into(), &mut **transaction, redis) + .await? + { + let image: Image = db_image.into(); + if !matches!(image.context, ImageContext::Report { .. }) + || image.context.inner_id().is_some() + { + return Err(CreateError::InvalidInput(format!( + "Image {} is not unused and in the 'version' context", + image_id + ))); + } + + sqlx::query!( + " + UPDATE uploaded_images + SET version_id = $1 + WHERE id = $2 + ", + version_id.0 as i64, + image_id.0 as i64 + ) + .execute(&mut **transaction) + .await?; + + image_item::Image::clear_cache(image.id.into(), redis).await?; + } else { + return Err(CreateError::InvalidInput(format!( + "Image {} does not exist", + image_id + ))); + } + } + + models::Project::clear_cache(project_id, None, Some(true), redis).await?; + + let project_status = sqlx::query!( + "SELECT status FROM mods WHERE id = $1", + project_id as models::ProjectId, + ) + .fetch_optional(pool) + .await?; + + if let Some(project_status) = project_status { + if project_status.status == ProjectStatus::Processing.as_str() { + moderation_queue.projects.insert(project_id.into()); + } + } + + Ok(HttpResponse::Ok().json(response)) +} + +pub async fn upload_file_to_version( + req: HttpRequest, + url_data: web::Path<(VersionId,)>, + mut payload: Multipart, + client: Data, + redis: Data, + file_host: Data>, + session_queue: web::Data, +) -> Result { + let mut transaction = client.begin().await?; + let mut uploaded_files = Vec::new(); + + let version_id = models::VersionId::from(url_data.into_inner().0); + + let result = upload_file_to_version_inner( + req, + &mut payload, + client, + &mut transaction, + redis, + &***file_host, + &mut uploaded_files, + version_id, + &session_queue, + ) + .await; + + if result.is_err() { + let undo_result = super::project_creation::undo_uploads( + &***file_host, + &uploaded_files, + ) + .await; + let rollback_result = transaction.rollback().await; + + undo_result?; + if let Err(e) = rollback_result { + return Err(e.into()); + } + } else { + transaction.commit().await?; + } + + result +} + +#[allow(clippy::too_many_arguments)] +async fn upload_file_to_version_inner( + req: HttpRequest, + payload: &mut Multipart, + client: Data, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: Data, + file_host: &dyn FileHost, + uploaded_files: &mut Vec, + version_id: models::VersionId, + session_queue: &AuthQueue, +) -> Result { + let cdn_url = dotenvy::var("CDN_URL")?; + + let mut initial_file_data: Option = None; + let mut file_builders: Vec = Vec::new(); + + let user = get_user_from_headers( + &req, + &**client, + &redis, + session_queue, + Some(&[Scopes::VERSION_WRITE]), + ) + .await? + .1; + + let result = models::Version::get(version_id, &**client, &redis).await?; + + let version = match result { + Some(v) => v, + None => { + return Err(CreateError::InvalidInput( + "An invalid version id was supplied".to_string(), + )); + } + }; + + let all_loaders = + models::loader_fields::Loader::list(&mut **transaction, &redis).await?; + let selected_loaders = version + .loaders + .iter() + .map(|x| { + all_loaders + .iter() + .find(|y| &y.loader == x) + .cloned() + .ok_or_else(|| CreateError::InvalidLoader(x.clone())) + }) + .collect::, _>>()?; + + if models::Project::get_id( + version.inner.project_id, + &mut **transaction, + &redis, + ) + .await? + .is_none() + { + return Err(CreateError::InvalidInput( + "An invalid project id was supplied".to_string(), + )); + } + + if !user.role.is_admin() { + let team_member = models::TeamMember::get_from_user_id_project( + version.inner.project_id, + user.id.into(), + false, + &mut **transaction, + ) + .await?; + + let organization = + Organization::get_associated_organization_project_id( + version.inner.project_id, + &**client, + ) + .await?; + + let organization_team_member = if let Some(organization) = &organization + { + models::TeamMember::get_from_user_id( + organization.team_id, + user.id.into(), + &mut **transaction, + ) + .await? + } else { + None + }; + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); + + if !permissions.contains(ProjectPermissions::UPLOAD_VERSION) { + return Err(CreateError::CustomAuthenticationError( + "You don't have permission to upload files to this version!" + .to_string(), + )); + } + } + + let project_id = ProjectId(version.inner.project_id.0 as u64); + let mut error = None; + while let Some(item) = payload.next().await { + let mut field: Field = item?; + + if error.is_some() { + continue; + } + + let result = async { + let content_disposition = field.content_disposition().clone(); + let name = content_disposition.get_name().ok_or_else(|| { + CreateError::MissingValueError( + "Missing content name".to_string(), + ) + })?; + + if name == "data" { + let mut data = Vec::new(); + while let Some(chunk) = field.next().await { + data.extend_from_slice(&chunk?); + } + let file_data: InitialFileData = serde_json::from_slice(&data)?; + + initial_file_data = Some(file_data); + return Ok(()); + } + + let file_data = initial_file_data.as_ref().ok_or_else(|| { + CreateError::InvalidInput(String::from( + "`data` field must come before file fields", + )) + })?; + + let loaders = selected_loaders + .iter() + .map(|x| Loader(x.loader.clone())) + .collect::>(); + + let mut dependencies = version + .dependencies + .iter() + .map(|x| DependencyBuilder { + project_id: x.project_id, + version_id: x.version_id, + file_name: x.file_name.clone(), + dependency_type: x.dependency_type.clone(), + }) + .collect(); + + upload_file( + &mut field, + file_host, + 0, + uploaded_files, + &mut file_builders, + &mut dependencies, + &cdn_url, + &content_disposition, + project_id, + version_id.into(), + &version.version_fields, + loaders, + true, + false, + file_data.file_types.get(name).copied().flatten(), + version.files.iter().map(|x| x.filename.clone()).collect(), + transaction, + &redis, + ) + .await?; + + Ok(()) + } + .await; + + if result.is_err() { + error = result.err(); + } + } + + if let Some(error) = error { + return Err(error); + } + + if file_builders.is_empty() { + return Err(CreateError::InvalidInput( + "At least one file must be specified".to_string(), + )); + } else { + for file in file_builders { + file.insert(version_id, &mut *transaction).await?; + } + } + + // Clear version cache + models::Version::clear_cache(&version, &redis).await?; + + Ok(HttpResponse::NoContent().body("")) +} + +// This function is used for adding a file to a version, uploading the initial +// files for a version, and for uploading the initial version files for a project +#[allow(clippy::too_many_arguments)] +pub async fn upload_file( + field: &mut Field, + file_host: &dyn FileHost, + total_files_len: usize, + uploaded_files: &mut Vec, + version_files: &mut Vec, + dependencies: &mut Vec, + cdn_url: &str, + content_disposition: &actix_web::http::header::ContentDisposition, + project_id: ProjectId, + version_id: VersionId, + version_fields: &[VersionField], + loaders: Vec, + ignore_primary: bool, + force_primary: bool, + file_type: Option, + other_file_names: Vec, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &RedisPool, +) -> Result<(), CreateError> { + let (file_name, file_extension) = get_name_ext(content_disposition)?; + + if other_file_names.contains(&format!("{}.{}", file_name, file_extension)) { + return Err(CreateError::InvalidInput( + "Duplicate files are not allowed to be uploaded to Modrinth!" + .to_string(), + )); + } + + if file_name.contains('/') { + return Err(CreateError::InvalidInput( + "File names must not contain slashes!".to_string(), + )); + } + + let content_type = crate::util::ext::project_file_type(file_extension) + .ok_or_else(|| { + CreateError::InvalidFileType(file_extension.to_string()) + })?; + + let data = read_from_field( + field, 500 * (1 << 20), + "Project file exceeds the maximum of 500MiB. Contact a moderator or admin to request permission to upload larger files." + ).await?; + + let hash = sha1::Sha1::from(&data).hexdigest(); + let exists = sqlx::query!( + " + SELECT EXISTS(SELECT 1 FROM hashes h + INNER JOIN files f ON f.id = h.file_id + INNER JOIN versions v ON v.id = f.version_id + WHERE h.algorithm = $2 AND h.hash = $1 AND v.mod_id != $3) + ", + hash.as_bytes(), + "sha1", + project_id.0 as i64 + ) + .fetch_one(&mut **transaction) + .await? + .exists + .unwrap_or(false); + + if exists { + return Err(CreateError::InvalidInput( + "Duplicate files are not allowed to be uploaded to Modrinth!" + .to_string(), + )); + } + + let validation_result = validate_file( + data.clone().into(), + file_extension.to_string(), + loaders.clone(), + file_type, + version_fields.to_vec(), + &mut *transaction, + redis, + ) + .await?; + + if let ValidationResult::PassWithPackDataAndFiles { + ref format, + ref files, + } = validation_result + { + if dependencies.is_empty() { + let hashes: Vec> = format + .files + .iter() + .filter_map(|x| x.hashes.get(&PackFileHash::Sha1)) + .map(|x| x.as_bytes().to_vec()) + .collect(); + + let res = sqlx::query!( + " + SELECT v.id version_id, v.mod_id project_id, h.hash hash FROM hashes h + INNER JOIN files f on h.file_id = f.id + INNER JOIN versions v on f.version_id = v.id + WHERE h.algorithm = 'sha1' AND h.hash = ANY($1) + ", + &*hashes + ) + .fetch_all(&mut **transaction) + .await?; + + for file in &format.files { + if let Some(dep) = res.iter().find(|x| { + Some(&*x.hash) + == file + .hashes + .get(&PackFileHash::Sha1) + .map(|x| x.as_bytes()) + }) { + dependencies.push(DependencyBuilder { + project_id: Some(models::ProjectId(dep.project_id)), + version_id: Some(models::VersionId(dep.version_id)), + file_name: None, + dependency_type: DependencyType::Embedded.to_string(), + }); + } else if let Some(first_download) = file.downloads.first() { + dependencies.push(DependencyBuilder { + project_id: None, + version_id: None, + file_name: Some( + first_download + .rsplit('/') + .next() + .unwrap_or(first_download) + .to_string(), + ), + dependency_type: DependencyType::Embedded.to_string(), + }); + } + } + + for file in files { + if !file.is_empty() { + dependencies.push(DependencyBuilder { + project_id: None, + version_id: None, + file_name: Some(file.to_string()), + dependency_type: DependencyType::Embedded.to_string(), + }); + } + } + } + } + + let data = data.freeze(); + let primary = (validation_result.is_passed() + && version_files.iter().all(|x| !x.primary) + && !ignore_primary) + || force_primary + || total_files_len == 1; + + let file_path_encode = format!( + "data/{}/versions/{}/{}", + project_id, + version_id, + urlencoding::encode(file_name) + ); + let file_path = + format!("data/{}/versions/{}/{}", project_id, version_id, &file_name); + + let upload_data = file_host + .upload_file(content_type, &file_path, data) + .await?; + + uploaded_files.push(UploadedFile { + file_id: upload_data.file_id, + file_name: file_path, + }); + + let sha1_bytes = upload_data.content_sha1.into_bytes(); + let sha512_bytes = upload_data.content_sha512.into_bytes(); + + if version_files.iter().any(|x| { + x.hashes + .iter() + .any(|y| y.hash == sha1_bytes || y.hash == sha512_bytes) + }) { + return Err(CreateError::InvalidInput( + "Duplicate files are not allowed to be uploaded to Modrinth!" + .to_string(), + )); + } + + if let ValidationResult::Warning(msg) = validation_result { + if primary { + return Err(CreateError::InvalidInput(msg.to_string())); + } + } + + version_files.push(VersionFileBuilder { + filename: file_name.to_string(), + url: format!("{cdn_url}/{file_path_encode}"), + hashes: vec![ + models::version_item::HashBuilder { + algorithm: "sha1".to_string(), + // This is an invalid cast - the database expects the hash's + // bytes, but this is the string version. + hash: sha1_bytes, + }, + models::version_item::HashBuilder { + algorithm: "sha512".to_string(), + // This is an invalid cast - the database expects the hash's + // bytes, but this is the string version. + hash: sha512_bytes, + }, + ], + primary, + size: upload_data.content_length, + file_type, + }); + + Ok(()) +} + +pub fn get_name_ext( + content_disposition: &actix_web::http::header::ContentDisposition, +) -> Result<(&str, &str), CreateError> { + let file_name = content_disposition.get_filename().ok_or_else(|| { + CreateError::MissingValueError("Missing content file name".to_string()) + })?; + let file_extension = if let Some(last_period) = file_name.rfind('.') { + file_name.get((last_period + 1)..).unwrap_or("") + } else { + return Err(CreateError::MissingValueError( + "Missing content file extension".to_string(), + )); + }; + Ok((file_name, file_extension)) +} + +// Reused functionality between project_creation and version_creation +// Create a list of VersionFields from the fetched data, and check that all mandatory fields are present +pub fn try_create_version_fields( + version_id: VersionId, + submitted_fields: &HashMap, + loader_fields: &[LoaderField], + loader_field_enum_values: &mut HashMap< + models::LoaderFieldId, + Vec, + >, +) -> Result, CreateError> { + let mut version_fields = vec![]; + let mut remaining_mandatory_loader_fields = loader_fields + .iter() + .filter(|lf| !lf.optional) + .map(|lf| lf.field.clone()) + .collect::>(); + for (key, value) in submitted_fields.iter() { + let loader_field = loader_fields + .iter() + .find(|lf| &lf.field == key) + .ok_or_else(|| { + CreateError::InvalidInput(format!( + "Loader field '{key}' does not exist for any loaders supplied," + )) + })?; + remaining_mandatory_loader_fields.remove(&loader_field.field); + let enum_variants = loader_field_enum_values + .remove(&loader_field.id) + .unwrap_or_default(); + + let vf: VersionField = VersionField::check_parse( + version_id.into(), + loader_field.clone(), + value.clone(), + enum_variants, + ) + .map_err(CreateError::InvalidInput)?; + version_fields.push(vf); + } + + if !remaining_mandatory_loader_fields.is_empty() { + return Err(CreateError::InvalidInput(format!( + "Missing mandatory loader fields: {}", + remaining_mandatory_loader_fields.iter().join(", ") + ))); + } + Ok(version_fields) +} diff --git a/apps/labrinth/src/routes/v3/version_file.rs b/apps/labrinth/src/routes/v3/version_file.rs new file mode 100644 index 000000000..e34d8ef53 --- /dev/null +++ b/apps/labrinth/src/routes/v3/version_file.rs @@ -0,0 +1,738 @@ +use super::ApiError; +use crate::auth::checks::{filter_visible_versions, is_visible_version}; +use crate::auth::{filter_visible_projects, get_user_from_headers}; +use crate::database::redis::RedisPool; +use crate::models::ids::VersionId; +use crate::models::pats::Scopes; +use crate::models::projects::VersionType; +use crate::models::teams::ProjectPermissions; +use crate::queue::session::AuthQueue; +use crate::{database, models}; +use actix_web::{web, HttpRequest, HttpResponse}; +use dashmap::DashMap; +use futures::TryStreamExt; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use std::collections::HashMap; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.service( + web::scope("version_file") + .route("{version_id}", web::get().to(get_version_from_hash)) + .route("{version_id}/update", web::post().to(get_update_from_hash)) + .route("project", web::post().to(get_projects_from_hashes)) + .route("{version_id}", web::delete().to(delete_file)) + .route("{version_id}/download", web::get().to(download_version)), + ); + cfg.service( + web::scope("version_files") + .route("update", web::post().to(update_files)) + .route("update_individual", web::post().to(update_individual_files)) + .route("", web::post().to(get_versions_from_hashes)), + ); +} + +pub async fn get_version_from_hash( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + hash_query: web::Query, + session_queue: web::Data, +) -> Result { + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::VERSION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + let hash = info.into_inner().0.to_lowercase(); + let algorithm = hash_query + .algorithm + .clone() + .unwrap_or_else(|| default_algorithm_from_hashes(&[hash.clone()])); + let file = database::models::Version::get_file_from_hash( + algorithm, + hash, + hash_query.version_id.map(|x| x.into()), + &**pool, + &redis, + ) + .await?; + if let Some(file) = file { + let version = + database::models::Version::get(file.version_id, &**pool, &redis) + .await?; + if let Some(version) = version { + if !is_visible_version(&version.inner, &user_option, &pool, &redis) + .await? + { + return Err(ApiError::NotFound); + } + + Ok(HttpResponse::Ok() + .json(models::projects::Version::from(version))) + } else { + Err(ApiError::NotFound) + } + } else { + Err(ApiError::NotFound) + } +} + +#[derive(Serialize, Deserialize)] +pub struct HashQuery { + pub algorithm: Option, // Defaults to calculation based on size of hash + pub version_id: Option, +} + +// Calculates whether or not to use sha1 or sha512 based on the size of the hash +pub fn default_algorithm_from_hashes(hashes: &[String]) -> String { + // Gets first hash, optionally + let empty_string = "".into(); + let hash = hashes.first().unwrap_or(&empty_string); + let hash_len = hash.len(); + // Sha1 = 40 characters + // Sha512 = 128 characters + // Favour sha1 as default, unless the hash is longer or equal to 128 characters + if hash_len >= 128 { + return "sha512".into(); + } + "sha1".into() +} + +#[derive(Serialize, Deserialize)] +pub struct UpdateData { + pub loaders: Option>, + pub version_types: Option>, + /* + Loader fields to filter with: + "game_versions": ["1.16.5", "1.17"] + + Returns if it matches any of the values + */ + pub loader_fields: Option>>, +} + +pub async fn get_update_from_hash( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + hash_query: web::Query, + update_data: web::Json, + session_queue: web::Data, +) -> Result { + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::VERSION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + let hash = info.into_inner().0.to_lowercase(); + if let Some(file) = database::models::Version::get_file_from_hash( + hash_query + .algorithm + .clone() + .unwrap_or_else(|| default_algorithm_from_hashes(&[hash.clone()])), + hash, + hash_query.version_id.map(|x| x.into()), + &**pool, + &redis, + ) + .await? + { + if let Some(project) = + database::models::Project::get_id(file.project_id, &**pool, &redis) + .await? + { + let versions = database::models::Version::get_many( + &project.versions, + &**pool, + &redis, + ) + .await? + .into_iter() + .filter(|x| { + let mut bool = true; + if let Some(version_types) = &update_data.version_types { + bool &= version_types + .iter() + .any(|y| y.as_str() == x.inner.version_type); + } + if let Some(loaders) = &update_data.loaders { + bool &= x.loaders.iter().any(|y| loaders.contains(y)); + } + if let Some(loader_fields) = &update_data.loader_fields { + for (key, values) in loader_fields { + bool &= if let Some(x_vf) = x + .version_fields + .iter() + .find(|y| y.field_name == *key) + { + values + .iter() + .any(|v| x_vf.value.contains_json_value(v)) + } else { + true + }; + } + } + bool + }) + .sorted(); + + if let Some(first) = versions.last() { + if !is_visible_version( + &first.inner, + &user_option, + &pool, + &redis, + ) + .await? + { + return Err(ApiError::NotFound); + } + + return Ok(HttpResponse::Ok() + .json(models::projects::Version::from(first))); + } + } + } + Err(ApiError::NotFound) +} + +// Requests above with multiple versions below +#[derive(Deserialize)] +pub struct FileHashes { + pub algorithm: Option, // Defaults to calculation based on size of hash + pub hashes: Vec, +} + +pub async fn get_versions_from_hashes( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + file_data: web::Json, + session_queue: web::Data, +) -> Result { + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::VERSION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let algorithm = file_data + .algorithm + .clone() + .unwrap_or_else(|| default_algorithm_from_hashes(&file_data.hashes)); + + let files = database::models::Version::get_files_from_hash( + algorithm.clone(), + &file_data.hashes, + &**pool, + &redis, + ) + .await?; + + let version_ids = files.iter().map(|x| x.version_id).collect::>(); + let versions_data = filter_visible_versions( + database::models::Version::get_many(&version_ids, &**pool, &redis) + .await?, + &user_option, + &pool, + &redis, + ) + .await?; + + let mut response = HashMap::new(); + + for version in versions_data { + for file in files.iter().filter(|x| x.version_id == version.id.into()) { + if let Some(hash) = file.hashes.get(&algorithm) { + response.insert(hash.clone(), version.clone()); + } + } + } + + Ok(HttpResponse::Ok().json(response)) +} + +pub async fn get_projects_from_hashes( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + file_data: web::Json, + session_queue: web::Data, +) -> Result { + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ, Scopes::VERSION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let algorithm = file_data + .algorithm + .clone() + .unwrap_or_else(|| default_algorithm_from_hashes(&file_data.hashes)); + let files = database::models::Version::get_files_from_hash( + algorithm.clone(), + &file_data.hashes, + &**pool, + &redis, + ) + .await?; + + let project_ids = files.iter().map(|x| x.project_id).collect::>(); + + let projects_data = filter_visible_projects( + database::models::Project::get_many_ids(&project_ids, &**pool, &redis) + .await?, + &user_option, + &pool, + false, + ) + .await?; + + let mut response = HashMap::new(); + + for project in projects_data { + for file in files.iter().filter(|x| x.project_id == project.id.into()) { + if let Some(hash) = file.hashes.get(&algorithm) { + response.insert(hash.clone(), project.clone()); + } + } + } + + Ok(HttpResponse::Ok().json(response)) +} + +#[derive(Deserialize)] +pub struct ManyUpdateData { + pub algorithm: Option, // Defaults to calculation based on size of hash + pub hashes: Vec, + pub loaders: Option>, + pub game_versions: Option>, + pub version_types: Option>, +} +pub async fn update_files( + pool: web::Data, + redis: web::Data, + update_data: web::Json, +) -> Result { + let algorithm = update_data + .algorithm + .clone() + .unwrap_or_else(|| default_algorithm_from_hashes(&update_data.hashes)); + let files = database::models::Version::get_files_from_hash( + algorithm.clone(), + &update_data.hashes, + &**pool, + &redis, + ) + .await?; + + // TODO: de-hardcode this and actually use version fields system + let update_version_ids = sqlx::query!( + " + SELECT v.id version_id, v.mod_id mod_id + FROM mods m + INNER JOIN versions v ON m.id = v.mod_id AND (cardinality($4::varchar[]) = 0 OR v.version_type = ANY($4)) + INNER JOIN version_fields vf ON vf.field_id = 3 AND v.id = vf.version_id + INNER JOIN loader_field_enum_values lfev ON vf.enum_value = lfev.id AND (cardinality($2::varchar[]) = 0 OR lfev.value = ANY($2::varchar[])) + INNER JOIN loaders_versions lv ON lv.version_id = v.id + INNER JOIN loaders l on lv.loader_id = l.id AND (cardinality($3::varchar[]) = 0 OR l.loader = ANY($3::varchar[])) + WHERE m.id = ANY($1) + ORDER BY v.date_published ASC + ", + &files.iter().map(|x| x.project_id.0).collect::>(), + &update_data.game_versions.clone().unwrap_or_default(), + &update_data.loaders.clone().unwrap_or_default(), + &update_data.version_types.clone().unwrap_or_default().iter().map(|x| x.to_string()).collect::>(), + ) + .fetch(&**pool) + .try_fold(DashMap::new(), |acc : DashMap<_,Vec>, m| { + acc.entry(database::models::ProjectId(m.mod_id)) + .or_default() + .push(database::models::VersionId(m.version_id)); + async move { Ok(acc) } + }) + .await?; + + let versions = database::models::Version::get_many( + &update_version_ids + .into_iter() + .filter_map(|x| x.1.last().copied()) + .collect::>(), + &**pool, + &redis, + ) + .await?; + + let mut response = HashMap::new(); + for file in files { + if let Some(version) = versions + .iter() + .find(|x| x.inner.project_id == file.project_id) + { + if let Some(hash) = file.hashes.get(&algorithm) { + response.insert( + hash.clone(), + models::projects::Version::from(version.clone()), + ); + } + } + } + + Ok(HttpResponse::Ok().json(response)) +} + +#[derive(Serialize, Deserialize)] +pub struct FileUpdateData { + pub hash: String, + pub loaders: Option>, + pub loader_fields: Option>>, + pub version_types: Option>, +} + +#[derive(Serialize, Deserialize)] +pub struct ManyFileUpdateData { + pub algorithm: Option, // Defaults to calculation based on size of hash + pub hashes: Vec, +} + +pub async fn update_individual_files( + req: HttpRequest, + pool: web::Data, + redis: web::Data, + update_data: web::Json, + session_queue: web::Data, +) -> Result { + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::VERSION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let algorithm = update_data.algorithm.clone().unwrap_or_else(|| { + default_algorithm_from_hashes( + &update_data + .hashes + .iter() + .map(|x| x.hash.clone()) + .collect::>(), + ) + }); + let files = database::models::Version::get_files_from_hash( + algorithm.clone(), + &update_data + .hashes + .iter() + .map(|x| x.hash.clone()) + .collect::>(), + &**pool, + &redis, + ) + .await?; + + let projects = database::models::Project::get_many_ids( + &files.iter().map(|x| x.project_id).collect::>(), + &**pool, + &redis, + ) + .await?; + let all_versions = database::models::Version::get_many( + &projects + .iter() + .flat_map(|x| x.versions.clone()) + .collect::>(), + &**pool, + &redis, + ) + .await?; + + let mut response = HashMap::new(); + + for project in projects { + for file in files.iter().filter(|x| x.project_id == project.inner.id) { + if let Some(hash) = file.hashes.get(&algorithm) { + if let Some(query_file) = + update_data.hashes.iter().find(|x| &x.hash == hash) + { + let version = all_versions + .iter() + .filter(|x| x.inner.project_id == file.project_id) + .filter(|x| { + let mut bool = true; + + if let Some(version_types) = + &query_file.version_types + { + bool &= version_types.iter().any(|y| { + y.as_str() == x.inner.version_type + }); + } + if let Some(loaders) = &query_file.loaders { + bool &= x + .loaders + .iter() + .any(|y| loaders.contains(y)); + } + + if let Some(loader_fields) = + &query_file.loader_fields + { + for (key, values) in loader_fields { + bool &= if let Some(x_vf) = x + .version_fields + .iter() + .find(|y| y.field_name == *key) + { + values.iter().any(|v| { + x_vf.value.contains_json_value(v) + }) + } else { + true + }; + } + } + bool + }) + .sorted() + .last(); + + if let Some(version) = version { + if is_visible_version( + &version.inner, + &user_option, + &pool, + &redis, + ) + .await? + { + response.insert( + hash.clone(), + models::projects::Version::from( + version.clone(), + ), + ); + } + } + } + } + } + } + + Ok(HttpResponse::Ok().json(response)) +} + +// under /api/v1/version_file/{hash} +pub async fn delete_file( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + hash_query: web::Query, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::VERSION_WRITE]), + ) + .await? + .1; + + let hash = info.into_inner().0.to_lowercase(); + let algorithm = hash_query + .algorithm + .clone() + .unwrap_or_else(|| default_algorithm_from_hashes(&[hash.clone()])); + let file = database::models::Version::get_file_from_hash( + algorithm.clone(), + hash, + hash_query.version_id.map(|x| x.into()), + &**pool, + &redis, + ) + .await?; + + if let Some(row) = file { + if !user.role.is_admin() { + let team_member = + database::models::TeamMember::get_from_user_id_version( + row.version_id, + user.id.into(), + &**pool, + ) + .await + .map_err(ApiError::Database)?; + + let organization = + database::models::Organization::get_associated_organization_project_id( + row.project_id, + &**pool, + ) + .await + .map_err(ApiError::Database)?; + + let organization_team_member = + if let Some(organization) = &organization { + database::models::TeamMember::get_from_user_id_organization( + organization.id, + user.id.into(), + false, + &**pool, + ) + .await + .map_err(ApiError::Database)? + } else { + None + }; + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); + + if !permissions.contains(ProjectPermissions::DELETE_VERSION) { + return Err(ApiError::CustomAuthentication( + "You don't have permission to delete this file!" + .to_string(), + )); + } + } + + let version = + database::models::Version::get(row.version_id, &**pool, &redis) + .await?; + if let Some(version) = version { + if version.files.len() < 2 { + return Err(ApiError::InvalidInput( + "Versions must have at least one file uploaded to them" + .to_string(), + )); + } + + database::models::Version::clear_cache(&version, &redis).await?; + } + + let mut transaction = pool.begin().await?; + + sqlx::query!( + " + DELETE FROM hashes + WHERE file_id = $1 + ", + row.id.0 + ) + .execute(&mut *transaction) + .await?; + + sqlx::query!( + " + DELETE FROM files + WHERE files.id = $1 + ", + row.id.0, + ) + .execute(&mut *transaction) + .await?; + + transaction.commit().await?; + + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::NotFound) + } +} + +#[derive(Serialize, Deserialize)] +pub struct DownloadRedirect { + pub url: String, +} + +// under /api/v1/version_file/{hash}/download +pub async fn download_version( + req: HttpRequest, + info: web::Path<(String,)>, + pool: web::Data, + redis: web::Data, + hash_query: web::Query, + session_queue: web::Data, +) -> Result { + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::VERSION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let hash = info.into_inner().0.to_lowercase(); + let algorithm = hash_query + .algorithm + .clone() + .unwrap_or_else(|| default_algorithm_from_hashes(&[hash.clone()])); + let file = database::models::Version::get_file_from_hash( + algorithm.clone(), + hash, + hash_query.version_id.map(|x| x.into()), + &**pool, + &redis, + ) + .await?; + + if let Some(file) = file { + let version = + database::models::Version::get(file.version_id, &**pool, &redis) + .await?; + + if let Some(version) = version { + if !is_visible_version(&version.inner, &user_option, &pool, &redis) + .await? + { + return Err(ApiError::NotFound); + } + + Ok(HttpResponse::TemporaryRedirect() + .append_header(("Location", &*file.url)) + .json(DownloadRedirect { url: file.url })) + } else { + Err(ApiError::NotFound) + } + } else { + Err(ApiError::NotFound) + } +} diff --git a/apps/labrinth/src/routes/v3/versions.rs b/apps/labrinth/src/routes/v3/versions.rs new file mode 100644 index 000000000..ac27a075c --- /dev/null +++ b/apps/labrinth/src/routes/v3/versions.rs @@ -0,0 +1,970 @@ +use std::collections::HashMap; + +use super::ApiError; +use crate::auth::checks::{ + filter_visible_versions, is_visible_project, is_visible_version, +}; +use crate::auth::get_user_from_headers; +use crate::database; +use crate::database::models::loader_fields::{ + self, LoaderField, LoaderFieldEnumValue, VersionField, +}; +use crate::database::models::version_item::{DependencyBuilder, LoaderVersion}; +use crate::database::models::{image_item, Organization}; +use crate::database::redis::RedisPool; +use crate::models; +use crate::models::ids::base62_impl::parse_base62; +use crate::models::ids::VersionId; +use crate::models::images::ImageContext; +use crate::models::pats::Scopes; +use crate::models::projects::{skip_nulls, Loader}; +use crate::models::projects::{ + Dependency, FileType, VersionStatus, VersionType, +}; +use crate::models::teams::ProjectPermissions; +use crate::queue::session::AuthQueue; +use crate::search::indexing::remove_documents; +use crate::search::SearchConfig; +use crate::util::img; +use crate::util::validate::validation_errors_to_string; +use actix_web::{web, HttpRequest, HttpResponse}; +use itertools::Itertools; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use validator::Validate; + +pub fn config(cfg: &mut web::ServiceConfig) { + cfg.route( + "version", + web::post().to(super::version_creation::version_create), + ); + cfg.route("versions", web::get().to(versions_get)); + + cfg.service( + web::scope("version") + .route("{id}", web::get().to(version_get)) + .route("{id}", web::patch().to(version_edit)) + .route("{id}", web::delete().to(version_delete)) + .route( + "{version_id}/file", + web::post().to(super::version_creation::upload_file_to_version), + ), + ); +} + +// Given a project ID/slug and a version slug +pub async fn version_project_get( + req: HttpRequest, + info: web::Path<(String, String)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let info = info.into_inner(); + version_project_get_helper(req, info, pool, redis, session_queue).await +} +pub async fn version_project_get_helper( + req: HttpRequest, + id: (String, String), + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let result = database::models::Project::get(&id.0, &**pool, &redis).await?; + + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ, Scopes::VERSION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + if let Some(project) = result { + if !is_visible_project(&project.inner, &user_option, &pool, false) + .await? + { + return Err(ApiError::NotFound); + } + + let versions = database::models::Version::get_many( + &project.versions, + &**pool, + &redis, + ) + .await?; + + let id_opt = parse_base62(&id.1).ok(); + let version = versions.into_iter().find(|x| { + Some(x.inner.id.0 as u64) == id_opt + || x.inner.version_number == id.1 + }); + + if let Some(version) = version { + if is_visible_version(&version.inner, &user_option, &pool, &redis) + .await? + { + return Ok(HttpResponse::Ok() + .json(models::projects::Version::from(version))); + } + } + } + + Err(ApiError::NotFound) +} + +#[derive(Serialize, Deserialize)] +pub struct VersionIds { + pub ids: String, +} + +pub async fn versions_get( + req: HttpRequest, + web::Query(ids): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let version_ids = + serde_json::from_str::>(&ids.ids)? + .into_iter() + .map(|x| x.into()) + .collect::>(); + let versions_data = + database::models::Version::get_many(&version_ids, &**pool, &redis) + .await?; + + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::VERSION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + let versions = + filter_visible_versions(versions_data, &user_option, &pool, &redis) + .await?; + + Ok(HttpResponse::Ok().json(versions)) +} + +pub async fn version_get( + req: HttpRequest, + info: web::Path<(models::ids::VersionId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let id = info.into_inner().0; + version_get_helper(req, id, pool, redis, session_queue).await +} + +pub async fn version_get_helper( + req: HttpRequest, + id: models::ids::VersionId, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let version_data = + database::models::Version::get(id.into(), &**pool, &redis).await?; + + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::VERSION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + if let Some(data) = version_data { + if is_visible_version(&data.inner, &user_option, &pool, &redis).await? { + return Ok( + HttpResponse::Ok().json(models::projects::Version::from(data)) + ); + } + } + + Err(ApiError::NotFound) +} + +#[derive(Serialize, Deserialize, Validate, Default, Debug)] +pub struct EditVersion { + #[validate( + length(min = 1, max = 64), + custom(function = "crate::util::validate::validate_name") + )] + pub name: Option, + #[validate( + length(min = 1, max = 32), + regex = "crate::util::validate::RE_URL_SAFE" + )] + pub version_number: Option, + #[validate(length(max = 65536))] + pub changelog: Option, + pub version_type: Option, + #[validate( + length(min = 0, max = 4096), + custom(function = "crate::util::validate::validate_deps") + )] + pub dependencies: Option>, + pub loaders: Option>, + pub featured: Option, + pub downloads: Option, + pub status: Option, + pub file_types: Option>, + #[serde( + default, + skip_serializing_if = "Option::is_none", + with = "::serde_with::rust::double_option" + )] + pub ordering: Option>, + + // Flattened loader fields + // All other fields are loader-specific VersionFields + // These are flattened during serialization + #[serde(deserialize_with = "skip_nulls")] + #[serde(flatten)] + pub fields: HashMap, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct EditVersionFileType { + pub algorithm: String, + pub hash: String, + pub file_type: Option, +} + +pub async fn version_edit( + req: HttpRequest, + info: web::Path<(VersionId,)>, + pool: web::Data, + redis: web::Data, + new_version: web::Json, + session_queue: web::Data, +) -> Result { + let new_version: EditVersion = + serde_json::from_value(new_version.into_inner())?; + version_edit_helper( + req, + info.into_inner(), + pool, + redis, + new_version, + session_queue, + ) + .await +} +pub async fn version_edit_helper( + req: HttpRequest, + info: (VersionId,), + pool: web::Data, + redis: web::Data, + new_version: EditVersion, + session_queue: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::VERSION_WRITE]), + ) + .await? + .1; + + new_version.validate().map_err(|err| { + ApiError::Validation(validation_errors_to_string(err, None)) + })?; + + let version_id = info.0; + let id = version_id.into(); + + let result = database::models::Version::get(id, &**pool, &redis).await?; + + if let Some(version_item) = result { + let team_member = + database::models::TeamMember::get_from_user_id_project( + version_item.inner.project_id, + user.id.into(), + false, + &**pool, + ) + .await?; + + let organization = + Organization::get_associated_organization_project_id( + version_item.inner.project_id, + &**pool, + ) + .await?; + + let organization_team_member = if let Some(organization) = &organization + { + database::models::TeamMember::get_from_user_id( + organization.team_id, + user.id.into(), + &**pool, + ) + .await? + } else { + None + }; + + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ); + + if let Some(perms) = permissions { + if !perms.contains(ProjectPermissions::UPLOAD_VERSION) { + return Err(ApiError::CustomAuthentication( + "You do not have the permissions to edit this version!" + .to_string(), + )); + } + + let mut transaction = pool.begin().await?; + + if let Some(name) = &new_version.name { + sqlx::query!( + " + UPDATE versions + SET name = $1 + WHERE (id = $2) + ", + name.trim(), + id as database::models::ids::VersionId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(number) = &new_version.version_number { + sqlx::query!( + " + UPDATE versions + SET version_number = $1 + WHERE (id = $2) + ", + number, + id as database::models::ids::VersionId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(version_type) = &new_version.version_type { + sqlx::query!( + " + UPDATE versions + SET version_type = $1 + WHERE (id = $2) + ", + version_type.as_str(), + id as database::models::ids::VersionId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(dependencies) = &new_version.dependencies { + sqlx::query!( + " + DELETE FROM dependencies WHERE dependent_id = $1 + ", + id as database::models::ids::VersionId, + ) + .execute(&mut *transaction) + .await?; + + let builders = dependencies + .iter() + .map(|x| database::models::version_item::DependencyBuilder { + project_id: x.project_id.map(|x| x.into()), + version_id: x.version_id.map(|x| x.into()), + file_name: x.file_name.clone(), + dependency_type: x.dependency_type.to_string(), + }) + .collect::>(); + + DependencyBuilder::insert_many( + builders, + version_item.inner.id, + &mut transaction, + ) + .await?; + } + + if !new_version.fields.is_empty() { + let version_fields_names = new_version + .fields + .keys() + .map(|x| x.to_string()) + .collect::>(); + + let all_loaders = + loader_fields::Loader::list(&mut *transaction, &redis) + .await?; + let loader_ids = version_item + .loaders + .iter() + .filter_map(|x| { + all_loaders + .iter() + .find(|y| &y.loader == x) + .map(|y| y.id) + }) + .collect_vec(); + + let loader_fields = LoaderField::get_fields( + &loader_ids, + &mut *transaction, + &redis, + ) + .await? + .into_iter() + .filter(|lf| version_fields_names.contains(&lf.field)) + .collect::>(); + + let loader_field_ids = loader_fields + .iter() + .map(|lf| lf.id.0) + .collect::>(); + sqlx::query!( + " + DELETE FROM version_fields + WHERE version_id = $1 + AND field_id = ANY($2) + ", + id as database::models::ids::VersionId, + &loader_field_ids + ) + .execute(&mut *transaction) + .await?; + + let mut loader_field_enum_values = + LoaderFieldEnumValue::list_many_loader_fields( + &loader_fields, + &mut *transaction, + &redis, + ) + .await?; + + let mut version_fields = Vec::new(); + for (vf_name, vf_value) in new_version.fields { + let loader_field = loader_fields + .iter() + .find(|lf| lf.field == vf_name) + .ok_or_else(|| { + ApiError::InvalidInput(format!( + "Loader field '{vf_name}' does not exist for any loaders supplied." + )) + })?; + let enum_variants = loader_field_enum_values + .remove(&loader_field.id) + .unwrap_or_default(); + let vf: VersionField = VersionField::check_parse( + version_id.into(), + loader_field.clone(), + vf_value.clone(), + enum_variants, + ) + .map_err(ApiError::InvalidInput)?; + version_fields.push(vf); + } + VersionField::insert_many(version_fields, &mut transaction) + .await?; + } + + if let Some(loaders) = &new_version.loaders { + sqlx::query!( + " + DELETE FROM loaders_versions WHERE version_id = $1 + ", + id as database::models::ids::VersionId, + ) + .execute(&mut *transaction) + .await?; + + let mut loader_versions = Vec::new(); + for loader in loaders { + let loader_id = + database::models::loader_fields::Loader::get_id( + &loader.0, + &mut *transaction, + &redis, + ) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "No database entry for loader provided." + .to_string(), + ) + })?; + loader_versions.push(LoaderVersion::new(loader_id, id)); + } + LoaderVersion::insert_many(loader_versions, &mut transaction) + .await?; + + crate::database::models::Project::clear_cache( + version_item.inner.project_id, + None, + None, + &redis, + ) + .await?; + } + + if let Some(featured) = &new_version.featured { + sqlx::query!( + " + UPDATE versions + SET featured = $1 + WHERE (id = $2) + ", + featured, + id as database::models::ids::VersionId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(body) = &new_version.changelog { + sqlx::query!( + " + UPDATE versions + SET changelog = $1 + WHERE (id = $2) + ", + body, + id as database::models::ids::VersionId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(downloads) = &new_version.downloads { + if !user.role.is_mod() { + return Err(ApiError::CustomAuthentication( + "You don't have permission to set the downloads of this mod".to_string(), + )); + } + + sqlx::query!( + " + UPDATE versions + SET downloads = $1 + WHERE (id = $2) + ", + *downloads as i32, + id as database::models::ids::VersionId, + ) + .execute(&mut *transaction) + .await?; + + let diff = *downloads - (version_item.inner.downloads as u32); + + sqlx::query!( + " + UPDATE mods + SET downloads = downloads + $1 + WHERE (id = $2) + ", + diff as i32, + version_item.inner.project_id + as database::models::ids::ProjectId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(status) = &new_version.status { + if !status.can_be_requested() { + return Err(ApiError::InvalidInput( + "The requested status cannot be set!".to_string(), + )); + } + + sqlx::query!( + " + UPDATE versions + SET status = $1 + WHERE (id = $2) + ", + status.as_str(), + id as database::models::ids::VersionId, + ) + .execute(&mut *transaction) + .await?; + } + + if let Some(file_types) = &new_version.file_types { + for file_type in file_types { + let result = sqlx::query!( + " + SELECT f.id id FROM hashes h + INNER JOIN files f ON h.file_id = f.id + WHERE h.algorithm = $2 AND h.hash = $1 + ", + file_type.hash.as_bytes(), + file_type.algorithm + ) + .fetch_optional(&**pool) + .await? + .ok_or_else(|| { + ApiError::InvalidInput(format!( + "Specified file with hash {} does not exist.", + file_type.algorithm.clone() + )) + })?; + + sqlx::query!( + " + UPDATE files + SET file_type = $2 + WHERE (id = $1) + ", + result.id, + file_type.file_type.as_ref().map(|x| x.as_str()), + ) + .execute(&mut *transaction) + .await?; + } + } + + if let Some(ordering) = &new_version.ordering { + sqlx::query!( + " + UPDATE versions + SET ordering = $1 + WHERE (id = $2) + ", + ordering.to_owned() as Option, + id as database::models::ids::VersionId, + ) + .execute(&mut *transaction) + .await?; + } + + // delete any images no longer in the changelog + let checkable_strings: Vec<&str> = vec![&new_version.changelog] + .into_iter() + .filter_map(|x| x.as_ref().map(|y| y.as_str())) + .collect(); + let context = ImageContext::Version { + version_id: Some(version_item.inner.id.into()), + }; + + img::delete_unused_images( + context, + checkable_strings, + &mut transaction, + &redis, + ) + .await?; + + transaction.commit().await?; + database::models::Version::clear_cache(&version_item, &redis) + .await?; + database::models::Project::clear_cache( + version_item.inner.project_id, + None, + Some(true), + &redis, + ) + .await?; + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::CustomAuthentication( + "You do not have permission to edit this version!".to_string(), + )) + } + } else { + Err(ApiError::NotFound) + } +} + +#[derive(Serialize, Deserialize)] +pub struct VersionListFilters { + pub loaders: Option, + pub featured: Option, + pub version_type: Option, + pub limit: Option, + pub offset: Option, + /* + Loader fields to filter with: + "game_versions": ["1.16.5", "1.17"] + + Returns if it matches any of the values + */ + pub loader_fields: Option, +} + +pub async fn version_list( + req: HttpRequest, + info: web::Path<(String,)>, + web::Query(filters): web::Query, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, +) -> Result { + let string = info.into_inner().0; + + let result = + database::models::Project::get(&string, &**pool, &redis).await?; + + let user_option = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::PROJECT_READ, Scopes::VERSION_READ]), + ) + .await + .map(|x| x.1) + .ok(); + + if let Some(project) = result { + if !is_visible_project(&project.inner, &user_option, &pool, false) + .await? + { + return Err(ApiError::NotFound); + } + + let loader_field_filters = filters.loader_fields.as_ref().map(|x| { + serde_json::from_str::>>(x) + .unwrap_or_default() + }); + let loader_filters = filters.loaders.as_ref().map(|x| { + serde_json::from_str::>(x).unwrap_or_default() + }); + let mut versions = database::models::Version::get_many( + &project.versions, + &**pool, + &redis, + ) + .await? + .into_iter() + .skip(filters.offset.unwrap_or(0)) + .take(filters.limit.unwrap_or(usize::MAX)) + .filter(|x| { + let mut bool = true; + + if let Some(version_type) = filters.version_type { + bool &= &*x.inner.version_type == version_type.as_str(); + } + if let Some(loaders) = &loader_filters { + bool &= x.loaders.iter().any(|y| loaders.contains(y)); + } + if let Some(loader_fields) = &loader_field_filters { + for (key, values) in loader_fields { + bool &= if let Some(x_vf) = + x.version_fields.iter().find(|y| y.field_name == *key) + { + values.iter().any(|v| x_vf.value.contains_json_value(v)) + } else { + true + }; + } + } + bool + }) + .collect::>(); + + let mut response = versions + .iter() + .filter(|version| { + filters + .featured + .map(|featured| featured == version.inner.featured) + .unwrap_or(true) + }) + .cloned() + .collect::>(); + + versions.sort_by(|a, b| { + b.inner.date_published.cmp(&a.inner.date_published) + }); + + // Attempt to populate versions with "auto featured" versions + if response.is_empty() + && !versions.is_empty() + && filters.featured.unwrap_or(false) + { + // TODO: This is a bandaid fix for detecting auto-featured versions. + // In the future, not all versions will have 'game_versions' fields, so this will need to be changed. + let (loaders, game_versions) = futures::future::try_join( + database::models::loader_fields::Loader::list(&**pool, &redis), + database::models::legacy_loader_fields::MinecraftGameVersion::list( + None, + Some(true), + &**pool, + &redis, + ), + ) + .await?; + + let mut joined_filters = Vec::new(); + for game_version in &game_versions { + for loader in &loaders { + joined_filters.push((game_version, loader)) + } + } + + joined_filters.into_iter().for_each(|filter| { + versions + .iter() + .find(|version| { + // TODO: This is the bandaid fix for detecting auto-featured versions. + let game_versions = version + .version_fields + .iter() + .find(|vf| vf.field_name == "game_versions") + .map(|vf| vf.value.clone()) + .map(|v| v.as_strings()) + .unwrap_or_default(); + game_versions.contains(&filter.0.version) + && version.loaders.contains(&filter.1.loader) + }) + .map(|version| response.push(version.clone())) + .unwrap_or(()); + }); + + if response.is_empty() { + versions + .into_iter() + .for_each(|version| response.push(version)); + } + } + + response.sort_by(|a, b| { + b.inner.date_published.cmp(&a.inner.date_published) + }); + response.dedup_by(|a, b| a.inner.id == b.inner.id); + + let response = + filter_visible_versions(response, &user_option, &pool, &redis) + .await?; + + Ok(HttpResponse::Ok().json(response)) + } else { + Err(ApiError::NotFound) + } +} + +pub async fn version_delete( + req: HttpRequest, + info: web::Path<(VersionId,)>, + pool: web::Data, + redis: web::Data, + session_queue: web::Data, + search_config: web::Data, +) -> Result { + let user = get_user_from_headers( + &req, + &**pool, + &redis, + &session_queue, + Some(&[Scopes::VERSION_DELETE]), + ) + .await? + .1; + let id = info.into_inner().0; + + let version = database::models::Version::get(id.into(), &**pool, &redis) + .await? + .ok_or_else(|| { + ApiError::InvalidInput( + "The specified version does not exist!".to_string(), + ) + })?; + + if !user.role.is_admin() { + let team_member = + database::models::TeamMember::get_from_user_id_project( + version.inner.project_id, + user.id.into(), + false, + &**pool, + ) + .await + .map_err(ApiError::Database)?; + + let organization = + Organization::get_associated_organization_project_id( + version.inner.project_id, + &**pool, + ) + .await?; + + let organization_team_member = if let Some(organization) = &organization + { + database::models::TeamMember::get_from_user_id( + organization.team_id, + user.id.into(), + &**pool, + ) + .await? + } else { + None + }; + let permissions = ProjectPermissions::get_permissions_by_role( + &user.role, + &team_member, + &organization_team_member, + ) + .unwrap_or_default(); + + if !permissions.contains(ProjectPermissions::DELETE_VERSION) { + return Err(ApiError::CustomAuthentication( + "You do not have permission to delete versions in this team" + .to_string(), + )); + } + } + + let mut transaction = pool.begin().await?; + let context = ImageContext::Version { + version_id: Some(version.inner.id.into()), + }; + let uploaded_images = + database::models::Image::get_many_contexted(context, &mut transaction) + .await?; + for image in uploaded_images { + image_item::Image::remove(image.id, &mut transaction, &redis).await?; + } + + let result = database::models::Version::remove_full( + version.inner.id, + &redis, + &mut transaction, + ) + .await?; + transaction.commit().await?; + remove_documents(&[version.inner.id.into()], &search_config).await?; + database::models::Project::clear_cache( + version.inner.project_id, + None, + Some(true), + &redis, + ) + .await?; + + if result.is_some() { + Ok(HttpResponse::NoContent().body("")) + } else { + Err(ApiError::NotFound) + } +} diff --git a/apps/labrinth/src/scheduler.rs b/apps/labrinth/src/scheduler.rs new file mode 100644 index 000000000..7bc5e5195 --- /dev/null +++ b/apps/labrinth/src/scheduler.rs @@ -0,0 +1,216 @@ +use actix_rt::Arbiter; +use futures::StreamExt; + +pub struct Scheduler { + arbiter: Arbiter, +} + +impl Default for Scheduler { + fn default() -> Self { + Self::new() + } +} + +impl Scheduler { + pub fn new() -> Self { + Scheduler { + arbiter: Arbiter::new(), + } + } + + pub fn run(&mut self, interval: std::time::Duration, mut task: F) + where + F: FnMut() -> R + Send + 'static, + R: std::future::Future + Send + 'static, + { + let future = IntervalStream::new(actix_rt::time::interval(interval)) + .for_each_concurrent(2, move |_| task()); + + self.arbiter.spawn(future); + } +} + +impl Drop for Scheduler { + fn drop(&mut self) { + self.arbiter.stop(); + } +} + +use log::{info, warn}; + +pub fn schedule_versions( + scheduler: &mut Scheduler, + pool: sqlx::Pool, + redis: RedisPool, +) { + let version_index_interval = std::time::Duration::from_secs( + parse_var("VERSION_INDEX_INTERVAL").unwrap_or(1800), + ); + + scheduler.run(version_index_interval, move || { + let pool_ref = pool.clone(); + let redis = redis.clone(); + async move { + info!("Indexing game versions list from Mojang"); + let result = update_versions(&pool_ref, &redis).await; + if let Err(e) = result { + warn!("Version update failed: {}", e); + } + info!("Done indexing game versions"); + } + }); +} + +use thiserror::Error; + +#[derive(Error, Debug)] +pub enum VersionIndexingError { + #[error("Network error while updating game versions list: {0}")] + NetworkError(#[from] reqwest::Error), + #[error("Database error while updating game versions list: {0}")] + DatabaseError(#[from] crate::database::models::DatabaseError), +} + +use crate::{ + database::{ + models::legacy_loader_fields::MinecraftGameVersion, redis::RedisPool, + }, + util::env::parse_var, +}; +use chrono::{DateTime, Utc}; +use serde::Deserialize; +use tokio_stream::wrappers::IntervalStream; + +#[derive(Deserialize)] +struct InputFormat<'a> { + // latest: LatestFormat, + versions: Vec>, +} +#[derive(Deserialize)] +struct VersionFormat<'a> { + id: String, + #[serde(rename = "type")] + type_: std::borrow::Cow<'a, str>, + #[serde(rename = "releaseTime")] + release_time: DateTime, +} + +async fn update_versions( + pool: &sqlx::Pool, + redis: &RedisPool, +) -> Result<(), VersionIndexingError> { + let input = reqwest::get( + "https://piston-meta.mojang.com/mc/game/version_manifest_v2.json", + ) + .await? + .json::() + .await?; + + let mut skipped_versions_count = 0u32; + + // A list of version names that contains spaces. + // Generated using the command + // ```sh + // curl https://launchermeta.mojang.com/mc/game/version_manifest.json \ + // | jq '[.versions[].id | select(contains(" "))]' + // ``` + const HALL_OF_SHAME: [(&str, &str); 12] = [ + ("1.14.2 Pre-Release 4", "1.14.2-pre4"), + ("1.14.2 Pre-Release 3", "1.14.2-pre3"), + ("1.14.2 Pre-Release 2", "1.14.2-pre2"), + ("1.14.2 Pre-Release 1", "1.14.2-pre1"), + ("1.14.1 Pre-Release 2", "1.14.1-pre2"), + ("1.14.1 Pre-Release 1", "1.14.1-pre1"), + ("1.14 Pre-Release 5", "1.14-pre5"), + ("1.14 Pre-Release 4", "1.14-pre4"), + ("1.14 Pre-Release 3", "1.14-pre3"), + ("1.14 Pre-Release 2", "1.14-pre2"), + ("1.14 Pre-Release 1", "1.14-pre1"), + ("3D Shareware v1.34", "3D-Shareware-v1.34"), + ]; + + lazy_static::lazy_static! { + /// Mojank for some reason has versions released at the same DateTime. This hardcodes them to fix this, + /// as most of our ordering logic is with DateTime + static ref HALL_OF_SHAME_2: [(&'static str, chrono::DateTime); 4] = [ + ( + "1.4.5", + chrono::DateTime::parse_from_rfc3339("2012-12-19T22:00:00+00:00") + .unwrap() + .into(), + ), + ( + "1.4.6", + chrono::DateTime::parse_from_rfc3339("2012-12-19T22:00:01+00:00") + .unwrap() + .into(), + ), + ( + "1.6.3", + chrono::DateTime::parse_from_rfc3339("2013-09-13T10:54:41+00:00") + .unwrap() + .into(), + ), + ( + "13w37b", + chrono::DateTime::parse_from_rfc3339("2013-09-13T10:54:42+00:00") + .unwrap() + .into(), + ), + ]; + } + + for version in input.versions.into_iter() { + let mut name = version.id; + if !name + .chars() + .all(|c| c.is_ascii_alphanumeric() || "-_.".contains(c)) + { + if let Some((_, alternate)) = + HALL_OF_SHAME.iter().find(|(version, _)| name == *version) + { + name = String::from(*alternate); + } else { + // We'll deal with these manually + skipped_versions_count += 1; + continue; + } + } + + let type_ = match &*version.type_ { + "release" => "release", + "snapshot" => "snapshot", + "old_alpha" => "alpha", + "old_beta" => "beta", + _ => "other", + }; + + MinecraftGameVersion::builder() + .version(&name)? + .version_type(type_)? + .created( + if let Some((_, alternate)) = + HALL_OF_SHAME_2.iter().find(|(version, _)| name == *version) + { + alternate + } else { + &version.release_time + }, + ) + .insert(pool, redis) + .await?; + } + + if skipped_versions_count > 0 { + // This will currently always trigger due to 1.14 pre releases + // and the shareware april fools update. We could set a threshold + // that accounts for those versions and update it whenever we + // manually fix another version. + warn!( + "Skipped {} game versions; check for new versions and add them manually", + skipped_versions_count + ); + } + + Ok(()) +} diff --git a/apps/labrinth/src/search/indexing/local_import.rs b/apps/labrinth/src/search/indexing/local_import.rs new file mode 100644 index 000000000..f24af8e28 --- /dev/null +++ b/apps/labrinth/src/search/indexing/local_import.rs @@ -0,0 +1,548 @@ +use chrono::{DateTime, Utc}; +use dashmap::DashMap; +use futures::TryStreamExt; +use itertools::Itertools; +use log::info; +use std::collections::HashMap; + +use super::IndexingError; +use crate::database::models::loader_fields::{ + QueryLoaderField, QueryLoaderFieldEnumValue, QueryVersionField, + VersionField, +}; +use crate::database::models::{ + LoaderFieldEnumId, LoaderFieldEnumValueId, LoaderFieldId, ProjectId, + VersionId, +}; +use crate::models::projects::from_duplicate_version_fields; +use crate::models::v2::projects::LegacyProject; +use crate::routes::v2_reroute; +use crate::search::UploadSearchProject; +use sqlx::postgres::PgPool; + +pub async fn index_local( + pool: &PgPool, +) -> Result, IndexingError> { + info!("Indexing local projects!"); + + // todo: loaders, project type, game versions + struct PartialProject { + id: ProjectId, + name: String, + summary: String, + downloads: i32, + follows: i32, + icon_url: Option, + updated: DateTime, + approved: DateTime, + slug: Option, + color: Option, + license: String, + } + + let db_projects = sqlx::query!( + " + SELECT m.id id, m.name name, m.summary summary, m.downloads downloads, m.follows follows, + m.icon_url icon_url, m.updated updated, m.approved approved, m.published, m.license license, m.slug slug, m.color + FROM mods m + WHERE m.status = ANY($1) + GROUP BY m.id; + ", + &*crate::models::projects::ProjectStatus::iterator() + .filter(|x| x.is_searchable()) + .map(|x| x.to_string()) + .collect::>(), + ) + .fetch(pool) + .map_ok(|m| { + PartialProject { + id: ProjectId(m.id), + name: m.name, + summary: m.summary, + downloads: m.downloads, + follows: m.follows, + icon_url: m.icon_url, + updated: m.updated, + approved: m.approved.unwrap_or(m.published), + slug: m.slug, + color: m.color, + license: m.license, + } + }) + .try_collect::>() + .await?; + + let project_ids = db_projects.iter().map(|x| x.id.0).collect::>(); + + struct PartialGallery { + url: String, + featured: bool, + ordering: i64, + } + + info!("Indexing local gallery!"); + + let mods_gallery: DashMap> = sqlx::query!( + " + SELECT mod_id, image_url, featured, ordering + FROM mods_gallery + WHERE mod_id = ANY($1) + ", + &*project_ids, + ) + .fetch(pool) + .try_fold( + DashMap::new(), + |acc: DashMap>, m| { + acc.entry(ProjectId(m.mod_id)) + .or_default() + .push(PartialGallery { + url: m.image_url, + featured: m.featured.unwrap_or(false), + ordering: m.ordering, + }); + async move { Ok(acc) } + }, + ) + .await?; + + info!("Indexing local categories!"); + + let categories: DashMap> = sqlx::query!( + " + SELECT mc.joining_mod_id mod_id, c.category name, mc.is_additional is_additional + FROM mods_categories mc + INNER JOIN categories c ON mc.joining_category_id = c.id + WHERE joining_mod_id = ANY($1) + ", + &*project_ids, + ) + .fetch(pool) + .try_fold( + DashMap::new(), + |acc: DashMap>, m| { + acc.entry(ProjectId(m.mod_id)) + .or_default() + .push((m.name, m.is_additional)); + async move { Ok(acc) } + }, + ) + .await?; + + info!("Indexing local versions!"); + let mut versions = index_versions(pool, project_ids.clone()).await?; + + info!("Indexing local org owners!"); + + let mods_org_owners: DashMap = sqlx::query!( + " + SELECT m.id mod_id, u.username + FROM mods m + INNER JOIN organizations o ON o.id = m.organization_id + INNER JOIN team_members tm ON tm.is_owner = TRUE and tm.team_id = o.team_id + INNER JOIN users u ON u.id = tm.user_id + WHERE m.id = ANY($1) + ", + &*project_ids, + ) + .fetch(pool) + .try_fold(DashMap::new(), |acc: DashMap, m| { + acc.insert(ProjectId(m.mod_id), m.username); + async move { Ok(acc) } + }) + .await?; + + info!("Indexing local team owners!"); + + let mods_team_owners: DashMap = sqlx::query!( + " + SELECT m.id mod_id, u.username + FROM mods m + INNER JOIN team_members tm ON tm.is_owner = TRUE and tm.team_id = m.team_id + INNER JOIN users u ON u.id = tm.user_id + WHERE m.id = ANY($1) + ", + &project_ids, + ) + .fetch(pool) + .try_fold(DashMap::new(), |acc: DashMap, m| { + acc.insert(ProjectId(m.mod_id), m.username); + async move { Ok(acc) } + }) + .await?; + + info!("Getting all loader fields!"); + let loader_fields: Vec = sqlx::query!( + " + SELECT DISTINCT id, field, field_type, enum_type, min_val, max_val, optional + FROM loader_fields lf + ", + ) + .fetch(pool) + .map_ok(|m| QueryLoaderField { + id: LoaderFieldId(m.id), + field: m.field, + field_type: m.field_type, + enum_type: m.enum_type.map(LoaderFieldEnumId), + min_val: m.min_val, + max_val: m.max_val, + optional: m.optional, + }) + .try_collect() + .await?; + let loader_fields: Vec<&QueryLoaderField> = loader_fields.iter().collect(); + + info!("Getting all loader field enum values!"); + + let loader_field_enum_values: Vec = + sqlx::query!( + " + SELECT DISTINCT id, enum_id, value, ordering, created, metadata + FROM loader_field_enum_values lfev + ORDER BY enum_id, ordering, created DESC + " + ) + .fetch(pool) + .map_ok(|m| QueryLoaderFieldEnumValue { + id: LoaderFieldEnumValueId(m.id), + enum_id: LoaderFieldEnumId(m.enum_id), + value: m.value, + ordering: m.ordering, + created: m.created, + metadata: m.metadata, + }) + .try_collect() + .await?; + + info!("Indexing loaders, project types!"); + let mut uploads = Vec::new(); + + let total_len = db_projects.len(); + let mut count = 0; + for project in db_projects { + count += 1; + info!("projects index prog: {count}/{total_len}"); + + let owner = + if let Some((_, org_owner)) = mods_org_owners.remove(&project.id) { + org_owner + } else if let Some((_, team_owner)) = + mods_team_owners.remove(&project.id) + { + team_owner + } else { + println!( + "org owner not found for project {} id: {}!", + project.name, project.id.0 + ); + continue; + }; + + let license = match project.license.split(' ').next() { + Some(license) => license.to_string(), + None => project.license.clone(), + }; + + let open_source = match spdx::license_id(&license) { + Some(id) => id.is_osi_approved(), + _ => false, + }; + + let (featured_gallery, gallery) = + if let Some((_, gallery)) = mods_gallery.remove(&project.id) { + let mut vals = Vec::new(); + let mut featured = None; + + for x in gallery + .into_iter() + .sorted_by(|a, b| a.ordering.cmp(&b.ordering)) + { + if x.featured && featured.is_none() { + featured = Some(x.url); + } else { + vals.push(x.url); + } + } + + (featured, vals) + } else { + (None, vec![]) + }; + + let (categories, display_categories) = + if let Some((_, categories)) = categories.remove(&project.id) { + let mut vals = Vec::new(); + let mut featured_vals = Vec::new(); + + for (val, is_additional) in categories { + if !is_additional { + featured_vals.push(val.clone()); + } + + vals.push(val); + } + + (vals, featured_vals) + } else { + (vec![], vec![]) + }; + + if let Some(versions) = versions.remove(&project.id) { + // Aggregated project loader fields + let project_version_fields = versions + .iter() + .flat_map(|x| x.version_fields.clone()) + .collect::>(); + let aggregated_version_fields = VersionField::from_query_json( + project_version_fields, + &loader_fields, + &loader_field_enum_values, + true, + ); + let project_loader_fields = + from_duplicate_version_fields(aggregated_version_fields); + + // aggregated project loaders + let project_loaders = versions + .iter() + .flat_map(|x| x.loaders.clone()) + .collect::>(); + + for version in versions { + let version_fields = VersionField::from_query_json( + version.version_fields, + &loader_fields, + &loader_field_enum_values, + false, + ); + let unvectorized_loader_fields = version_fields + .iter() + .map(|vf| { + (vf.field_name.clone(), vf.value.serialize_internal()) + }) + .collect(); + let mut loader_fields = + from_duplicate_version_fields(version_fields); + let project_types = version.project_types; + + let mut version_loaders = version.loaders; + + // Uses version loaders, not project loaders. + let mut categories = categories.clone(); + categories.append(&mut version_loaders.clone()); + + let display_categories = display_categories.clone(); + categories.append(&mut version_loaders); + + // SPECIAL BEHAVIOUR + // Todo: revisit. + // For consistency with v2 searching, we consider the loader field 'mrpack_loaders' to be a category. + // These were previously considered the loader, and in v2, the loader is a category for searching. + // So to avoid breakage or awkward conversions, we just consider those loader_fields to be categories. + // The loaders are kept in loader_fields as well, so that no information is lost on retrieval. + let mrpack_loaders = loader_fields + .get("mrpack_loaders") + .cloned() + .map(|x| { + x.into_iter() + .filter_map(|x| x.as_str().map(String::from)) + .collect::>() + }) + .unwrap_or_default(); + categories.extend(mrpack_loaders); + if loader_fields.contains_key("mrpack_loaders") { + categories.retain(|x| *x != "mrpack"); + } + + // SPECIAL BEHAVIOUR: + // For consitency with v2 searching, we manually input the + // client_side and server_side fields from the loader fields into + // separate loader fields. + // 'client_side' and 'server_side' remain supported by meilisearch even though they are no longer v3 fields. + let (_, v2_og_project_type) = + LegacyProject::get_project_type(&project_types); + let (client_side, server_side) = + v2_reroute::convert_side_types_v2( + &unvectorized_loader_fields, + Some(&v2_og_project_type), + ); + + if let Ok(client_side) = serde_json::to_value(client_side) { + loader_fields + .insert("client_side".to_string(), vec![client_side]); + } + if let Ok(server_side) = serde_json::to_value(server_side) { + loader_fields + .insert("server_side".to_string(), vec![server_side]); + } + + let usp = UploadSearchProject { + version_id: crate::models::ids::VersionId::from(version.id) + .to_string(), + project_id: crate::models::ids::ProjectId::from(project.id) + .to_string(), + name: project.name.clone(), + summary: project.summary.clone(), + categories: categories.clone(), + display_categories: display_categories.clone(), + follows: project.follows, + downloads: project.downloads, + icon_url: project.icon_url.clone(), + author: owner.clone(), + date_created: project.approved, + created_timestamp: project.approved.timestamp(), + date_modified: project.updated, + modified_timestamp: project.updated.timestamp(), + license: license.clone(), + slug: project.slug.clone(), + // TODO + project_types, + gallery: gallery.clone(), + featured_gallery: featured_gallery.clone(), + open_source, + color: project.color.map(|x| x as u32), + loader_fields, + project_loader_fields: project_loader_fields.clone(), + // 'loaders' is aggregate of all versions' loaders + loaders: project_loaders.clone(), + }; + + uploads.push(usp); + } + } + } + + Ok(uploads) +} + +struct PartialVersion { + id: VersionId, + loaders: Vec, + project_types: Vec, + version_fields: Vec, +} + +async fn index_versions( + pool: &PgPool, + project_ids: Vec, +) -> Result>, IndexingError> { + let versions: HashMap> = sqlx::query!( + " + SELECT v.id, v.mod_id + FROM versions v + WHERE mod_id = ANY($1) + ", + &project_ids, + ) + .fetch(pool) + .try_fold( + HashMap::new(), + |mut acc: HashMap>, m| { + acc.entry(ProjectId(m.mod_id)) + .or_default() + .push(VersionId(m.id)); + async move { Ok(acc) } + }, + ) + .await?; + + // Get project types, loaders + #[derive(Default)] + struct VersionLoaderData { + loaders: Vec, + project_types: Vec, + } + + let all_version_ids = versions + .iter() + .flat_map(|(_, version_ids)| version_ids.iter()) + .map(|x| x.0) + .collect::>(); + + let loaders_ptypes: DashMap = sqlx::query!( + " + SELECT DISTINCT version_id, + ARRAY_AGG(DISTINCT l.loader) filter (where l.loader is not null) loaders, + ARRAY_AGG(DISTINCT pt.name) filter (where pt.name is not null) project_types + FROM versions v + INNER JOIN loaders_versions lv ON v.id = lv.version_id + INNER JOIN loaders l ON lv.loader_id = l.id + INNER JOIN loaders_project_types lpt ON lpt.joining_loader_id = l.id + INNER JOIN project_types pt ON pt.id = lpt.joining_project_type_id + WHERE v.id = ANY($1) + GROUP BY version_id + ", + &all_version_ids + ) + .fetch(pool) + .map_ok(|m| { + let version_id = VersionId(m.version_id); + + let version_loader_data = VersionLoaderData { + loaders: m.loaders.unwrap_or_default(), + project_types: m.project_types.unwrap_or_default(), + }; + (version_id, version_loader_data) + }) + .try_collect() + .await?; + + // Get version fields + let version_fields: DashMap> = + sqlx::query!( + " + SELECT version_id, field_id, int_value, enum_value, string_value + FROM version_fields + WHERE version_id = ANY($1) + ", + &all_version_ids, + ) + .fetch(pool) + .try_fold( + DashMap::new(), + |acc: DashMap>, m| { + let qvf = QueryVersionField { + version_id: VersionId(m.version_id), + field_id: LoaderFieldId(m.field_id), + int_value: m.int_value, + enum_value: m.enum_value.map(LoaderFieldEnumValueId), + string_value: m.string_value, + }; + + acc.entry(VersionId(m.version_id)).or_default().push(qvf); + async move { Ok(acc) } + }, + ) + .await?; + + // Convert to partial versions + let mut res_versions: HashMap> = + HashMap::new(); + for (project_id, version_ids) in versions.iter() { + for version_id in version_ids { + // Extract version-specific data fetched + // We use 'remove' as every version is only in the map once + let version_loader_data = loaders_ptypes + .remove(version_id) + .map(|(_, version_loader_data)| version_loader_data) + .unwrap_or_default(); + + let version_fields = version_fields + .remove(version_id) + .map(|(_, version_fields)| version_fields) + .unwrap_or_default(); + + res_versions + .entry(*project_id) + .or_default() + .push(PartialVersion { + id: *version_id, + loaders: version_loader_data.loaders, + project_types: version_loader_data.project_types, + version_fields, + }); + } + } + + Ok(res_versions) +} diff --git a/apps/labrinth/src/search/indexing/mod.rs b/apps/labrinth/src/search/indexing/mod.rs new file mode 100644 index 000000000..679473038 --- /dev/null +++ b/apps/labrinth/src/search/indexing/mod.rs @@ -0,0 +1,390 @@ +/// This module is used for the indexing from any source. +pub mod local_import; + +use crate::database::redis::RedisPool; +use crate::models::ids::base62_impl::to_base62; +use crate::search::{SearchConfig, UploadSearchProject}; +use local_import::index_local; +use log::info; +use meilisearch_sdk::client::Client; +use meilisearch_sdk::indexes::Index; +use meilisearch_sdk::settings::{PaginationSetting, Settings}; +use meilisearch_sdk::SwapIndexes; +use sqlx::postgres::PgPool; +use thiserror::Error; +#[derive(Error, Debug)] +pub enum IndexingError { + #[error("Error while connecting to the MeiliSearch database")] + Indexing(#[from] meilisearch_sdk::errors::Error), + #[error("Error while serializing or deserializing JSON: {0}")] + Serde(#[from] serde_json::Error), + #[error("Database Error: {0}")] + Sqlx(#[from] sqlx::error::Error), + #[error("Database Error: {0}")] + Database(#[from] crate::database::models::DatabaseError), + #[error("Environment Error")] + Env(#[from] dotenvy::Error), + #[error("Error while awaiting index creation task")] + Task, +} + +// The chunk size for adding projects to the indexing database. If the request size +// is too large (>10MiB) then the request fails with an error. This chunk size +// assumes a max average size of 4KiB per project to avoid this cap. +const MEILISEARCH_CHUNK_SIZE: usize = 10000000; +const TIMEOUT: std::time::Duration = std::time::Duration::from_secs(60); + +pub async fn remove_documents( + ids: &[crate::models::ids::VersionId], + config: &SearchConfig, +) -> Result<(), meilisearch_sdk::errors::Error> { + let mut indexes = get_indexes_for_indexing(config, false).await?; + let mut indexes_next = get_indexes_for_indexing(config, true).await?; + indexes.append(&mut indexes_next); + + for index in indexes { + index + .delete_documents( + &ids.iter().map(|x| to_base62(x.0)).collect::>(), + ) + .await?; + } + + Ok(()) +} + +pub async fn index_projects( + pool: PgPool, + redis: RedisPool, + config: &SearchConfig, +) -> Result<(), IndexingError> { + info!("Indexing projects."); + + // First, ensure current index exists (so no error happens- current index should be worst-case empty, not missing) + get_indexes_for_indexing(config, false).await?; + + // Then, delete the next index if it still exists + let indices = get_indexes_for_indexing(config, true).await?; + for index in indices { + index.delete().await?; + } + // Recreate the next index for indexing + let indices = get_indexes_for_indexing(config, true).await?; + + let all_loader_fields = + crate::database::models::loader_fields::LoaderField::get_fields_all( + &pool, &redis, + ) + .await? + .into_iter() + .map(|x| x.field) + .collect::>(); + + let uploads = index_local(&pool).await?; + add_projects(&indices, uploads, all_loader_fields.clone(), config).await?; + + // Swap the index + swap_index(config, "projects").await?; + swap_index(config, "projects_filtered").await?; + + // Delete the now-old index + for index in indices { + index.delete().await?; + } + + info!("Done adding projects."); + Ok(()) +} + +pub async fn swap_index( + config: &SearchConfig, + index_name: &str, +) -> Result<(), IndexingError> { + let client = config.make_client(); + let index_name_next = config.get_index_name(index_name, true); + let index_name = config.get_index_name(index_name, false); + let swap_indices = SwapIndexes { + indexes: (index_name_next, index_name), + }; + client + .swap_indexes([&swap_indices]) + .await? + .wait_for_completion(&client, None, Some(TIMEOUT)) + .await?; + + Ok(()) +} + +pub async fn get_indexes_for_indexing( + config: &SearchConfig, + next: bool, // Get the 'next' one +) -> Result, meilisearch_sdk::errors::Error> { + let client = config.make_client(); + let project_name = config.get_index_name("projects", next); + let project_filtered_name = + config.get_index_name("projects_filtered", next); + let projects_index = create_or_update_index( + &client, + &project_name, + Some(&[ + "words", + "typo", + "proximity", + "attribute", + "exactness", + "sort", + ]), + ) + .await?; + let projects_filtered_index = create_or_update_index( + &client, + &project_filtered_name, + Some(&[ + "sort", + "words", + "typo", + "proximity", + "attribute", + "exactness", + ]), + ) + .await?; + + Ok(vec![projects_index, projects_filtered_index]) +} + +async fn create_or_update_index( + client: &Client, + name: &str, + custom_rules: Option<&'static [&'static str]>, +) -> Result { + info!("Updating/creating index {}", name); + + match client.get_index(name).await { + Ok(index) => { + info!("Updating index settings."); + + let mut settings = default_settings(); + + if let Some(custom_rules) = custom_rules { + settings = settings.with_ranking_rules(custom_rules); + } + + info!("Performing index settings set."); + index + .set_settings(&settings) + .await? + .wait_for_completion(client, None, Some(TIMEOUT)) + .await?; + info!("Done performing index settings set."); + + Ok(index) + } + _ => { + info!("Creating index."); + + // Only create index and set settings if the index doesn't already exist + let task = client.create_index(name, Some("version_id")).await?; + let task = task + .wait_for_completion(client, None, Some(TIMEOUT)) + .await?; + let index = task + .try_make_index(client) + .map_err(|x| x.unwrap_failure())?; + + let mut settings = default_settings(); + + if let Some(custom_rules) = custom_rules { + settings = settings.with_ranking_rules(custom_rules); + } + + index + .set_settings(&settings) + .await? + .wait_for_completion(client, None, Some(TIMEOUT)) + .await?; + + Ok(index) + } + } +} + +async fn add_to_index( + client: &Client, + index: &Index, + mods: &[UploadSearchProject], +) -> Result<(), IndexingError> { + for chunk in mods.chunks(MEILISEARCH_CHUNK_SIZE) { + info!( + "Adding chunk starting with version id {}", + chunk[0].version_id + ); + index + .add_or_replace(chunk, Some("version_id")) + .await? + .wait_for_completion( + client, + None, + Some(std::time::Duration::from_secs(3600)), + ) + .await?; + info!("Added chunk of {} projects to index", chunk.len()); + } + + Ok(()) +} + +async fn update_and_add_to_index( + client: &Client, + index: &Index, + projects: &[UploadSearchProject], + _additional_fields: &[String], +) -> Result<(), IndexingError> { + // TODO: Uncomment this- hardcoding loader_fields is a band-aid fix, and will be fixed soon + // let mut new_filterable_attributes: Vec = index.get_filterable_attributes().await?; + // let mut new_displayed_attributes = index.get_displayed_attributes().await?; + + // // Check if any 'additional_fields' are not already in the index + // // Only add if they are not already in the index + // let new_fields = additional_fields + // .iter() + // .filter(|x| !new_filterable_attributes.contains(x)) + // .collect::>(); + // if !new_fields.is_empty() { + // info!("Adding new fields to index: {:?}", new_fields); + // new_filterable_attributes.extend(new_fields.iter().map(|s: &&String| s.to_string())); + // new_displayed_attributes.extend(new_fields.iter().map(|s| s.to_string())); + + // // Adds new fields to the index + // let filterable_task = index + // .set_filterable_attributes(new_filterable_attributes) + // .await?; + // let displayable_task = index + // .set_displayed_attributes(new_displayed_attributes) + // .await?; + + // // Allow a long timeout for adding new attributes- it only needs to happen the once + // filterable_task + // .wait_for_completion(client, None, Some(TIMEOUT * 100)) + // .await?; + // displayable_task + // .wait_for_completion(client, None, Some(TIMEOUT * 100)) + // .await?; + // } + + info!("Adding to index."); + + add_to_index(client, index, projects).await?; + + Ok(()) +} + +pub async fn add_projects( + indices: &[Index], + projects: Vec, + additional_fields: Vec, + config: &SearchConfig, +) -> Result<(), IndexingError> { + let client = config.make_client(); + for index in indices { + update_and_add_to_index(&client, index, &projects, &additional_fields) + .await?; + } + + Ok(()) +} + +fn default_settings() -> Settings { + Settings::new() + .with_distinct_attribute("project_id") + .with_displayed_attributes(DEFAULT_DISPLAYED_ATTRIBUTES) + .with_searchable_attributes(DEFAULT_SEARCHABLE_ATTRIBUTES) + .with_sortable_attributes(DEFAULT_SORTABLE_ATTRIBUTES) + .with_filterable_attributes(DEFAULT_ATTRIBUTES_FOR_FACETING) + .with_pagination(PaginationSetting { + max_total_hits: 2147483647, + }) +} + +const DEFAULT_DISPLAYED_ATTRIBUTES: &[&str] = &[ + "project_id", + "version_id", + "project_types", + "slug", + "author", + "name", + "summary", + "categories", + "display_categories", + "downloads", + "follows", + "icon_url", + "date_created", + "date_modified", + "latest_version", + "license", + "gallery", + "featured_gallery", + "color", + // Note: loader fields are not here, but are added on as they are needed (so they can be dynamically added depending on which exist). + // TODO: remove these- as they should be automatically populated. This is a band-aid fix. + "server_only", + "client_only", + "game_versions", + "singleplayer", + "client_and_server", + "mrpack_loaders", + // V2 legacy fields for logical consistency + "client_side", + "server_side", + // Non-searchable fields for filling out the Project model. + "license_url", + "monetization_status", + "team_id", + "thread_id", + "versions", + "date_published", + "date_queued", + "status", + "requested_status", + "games", + "organization_id", + "links", + "gallery_items", + "loaders", // search uses loaders as categories- this is purely for the Project model. + "project_loader_fields", +]; + +const DEFAULT_SEARCHABLE_ATTRIBUTES: &[&str] = + &["name", "summary", "author", "slug"]; + +const DEFAULT_ATTRIBUTES_FOR_FACETING: &[&str] = &[ + "categories", + "license", + "project_types", + "downloads", + "follows", + "author", + "name", + "date_created", + "created_timestamp", + "date_modified", + "modified_timestamp", + "project_id", + "open_source", + "color", + // Note: loader fields are not here, but are added on as they are needed (so they can be dynamically added depending on which exist). + // TODO: remove these- as they should be automatically populated. This is a band-aid fix. + "server_only", + "client_only", + "game_versions", + "singleplayer", + "client_and_server", + "mrpack_loaders", + // V2 legacy fields for logical consistency + "client_side", + "server_side", +]; + +const DEFAULT_SORTABLE_ATTRIBUTES: &[&str] = + &["downloads", "follows", "date_created", "date_modified"]; diff --git a/apps/labrinth/src/search/mod.rs b/apps/labrinth/src/search/mod.rs new file mode 100644 index 000000000..244928f25 --- /dev/null +++ b/apps/labrinth/src/search/mod.rs @@ -0,0 +1,315 @@ +use crate::models::error::ApiError; +use crate::models::projects::SearchRequest; +use actix_web::http::StatusCode; +use actix_web::HttpResponse; +use chrono::{DateTime, Utc}; +use itertools::Itertools; +use meilisearch_sdk::client::Client; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::borrow::Cow; +use std::collections::HashMap; +use std::fmt::Write; +use thiserror::Error; + +pub mod indexing; + +#[derive(Error, Debug)] +pub enum SearchError { + #[error("MeiliSearch Error: {0}")] + MeiliSearch(#[from] meilisearch_sdk::errors::Error), + #[error("Error while serializing or deserializing JSON: {0}")] + Serde(#[from] serde_json::Error), + #[error("Error while parsing an integer: {0}")] + IntParsing(#[from] std::num::ParseIntError), + #[error("Error while formatting strings: {0}")] + FormatError(#[from] std::fmt::Error), + #[error("Environment Error")] + Env(#[from] dotenvy::Error), + #[error("Invalid index to sort by: {0}")] + InvalidIndex(String), +} + +impl actix_web::ResponseError for SearchError { + fn status_code(&self) -> StatusCode { + match self { + SearchError::Env(..) => StatusCode::INTERNAL_SERVER_ERROR, + SearchError::MeiliSearch(..) => StatusCode::BAD_REQUEST, + SearchError::Serde(..) => StatusCode::BAD_REQUEST, + SearchError::IntParsing(..) => StatusCode::BAD_REQUEST, + SearchError::InvalidIndex(..) => StatusCode::BAD_REQUEST, + SearchError::FormatError(..) => StatusCode::BAD_REQUEST, + } + } + + fn error_response(&self) -> HttpResponse { + HttpResponse::build(self.status_code()).json(ApiError { + error: match self { + SearchError::Env(..) => "environment_error", + SearchError::MeiliSearch(..) => "meilisearch_error", + SearchError::Serde(..) => "invalid_input", + SearchError::IntParsing(..) => "invalid_input", + SearchError::InvalidIndex(..) => "invalid_input", + SearchError::FormatError(..) => "invalid_input", + }, + description: self.to_string(), + }) + } +} + +#[derive(Debug, Clone)] +pub struct SearchConfig { + pub address: String, + pub key: String, + pub meta_namespace: String, +} + +impl SearchConfig { + // Panics if the environment variables are not set, + // but these are already checked for on startup. + pub fn new(meta_namespace: Option) -> Self { + let address = + dotenvy::var("MEILISEARCH_ADDR").expect("MEILISEARCH_ADDR not set"); + let key = + dotenvy::var("MEILISEARCH_KEY").expect("MEILISEARCH_KEY not set"); + + Self { + address, + key, + meta_namespace: meta_namespace.unwrap_or_default(), + } + } + + pub fn make_client(&self) -> Client { + Client::new(self.address.as_str(), Some(self.key.as_str())) + } + + // Next: true if we want the next index (we are preparing the next swap), false if we want the current index (searching) + pub fn get_index_name(&self, index: &str, next: bool) -> String { + let alt = if next { "_alt" } else { "" }; + format!("{}_{}_{}", self.meta_namespace, index, alt) + } +} + +/// A project document used for uploading projects to MeiliSearch's indices. +/// This contains some extra data that is not returned by search results. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct UploadSearchProject { + pub version_id: String, + pub project_id: String, + // + pub project_types: Vec, + pub slug: Option, + pub author: String, + pub name: String, + pub summary: String, + pub categories: Vec, + pub display_categories: Vec, + pub follows: i32, + pub downloads: i32, + pub icon_url: Option, + pub license: String, + pub gallery: Vec, + pub featured_gallery: Option, + /// RFC 3339 formatted creation date of the project + pub date_created: DateTime, + /// Unix timestamp of the creation date of the project + pub created_timestamp: i64, + /// RFC 3339 formatted date/time of last major modification (update) + pub date_modified: DateTime, + /// Unix timestamp of the last major modification + pub modified_timestamp: i64, + pub open_source: bool, + pub color: Option, + + // Hidden fields to get the Project model out of the search results. + pub loaders: Vec, // Search uses loaders as categories- this is purely for the Project model. + pub project_loader_fields: HashMap>, // Aggregation of loader_fields from all versions of the project, allowing for reconstruction of the Project model. + + #[serde(flatten)] + pub loader_fields: HashMap>, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct SearchResults { + pub hits: Vec, + pub page: usize, + pub hits_per_page: usize, + pub total_hits: usize, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct ResultSearchProject { + pub version_id: String, + pub project_id: String, + pub project_types: Vec, + pub slug: Option, + pub author: String, + pub name: String, + pub summary: String, + pub categories: Vec, + pub display_categories: Vec, + pub downloads: i32, + pub follows: i32, + pub icon_url: Option, + /// RFC 3339 formatted creation date of the project + pub date_created: String, + /// RFC 3339 formatted modification date of the project + pub date_modified: String, + pub license: String, + pub gallery: Vec, + pub featured_gallery: Option, + pub color: Option, + + // Hidden fields to get the Project model out of the search results. + pub loaders: Vec, // Search uses loaders as categories- this is purely for the Project model. + pub project_loader_fields: HashMap>, // Aggregation of loader_fields from all versions of the project, allowing for reconstruction of the Project model. + + #[serde(flatten)] + pub loader_fields: HashMap>, +} + +pub fn get_sort_index( + config: &SearchConfig, + index: &str, +) -> Result<(String, [&'static str; 1]), SearchError> { + let projects_name = config.get_index_name("projects", false); + let projects_filtered_name = + config.get_index_name("projects_filtered", false); + Ok(match index { + "relevance" => (projects_name, ["downloads:desc"]), + "downloads" => (projects_filtered_name, ["downloads:desc"]), + "follows" => (projects_name, ["follows:desc"]), + "updated" => (projects_name, ["date_modified:desc"]), + "newest" => (projects_name, ["date_created:desc"]), + i => return Err(SearchError::InvalidIndex(i.to_string())), + }) +} + +pub async fn search_for_project( + info: &SearchRequest, + config: &SearchConfig, +) -> Result { + let client = Client::new(&*config.address, Some(&*config.key)); + + let offset: usize = info.offset.as_deref().unwrap_or("0").parse()?; + let index = info.index.as_deref().unwrap_or("relevance"); + let limit = info + .limit + .as_deref() + .unwrap_or("10") + .parse::()? + .min(100); + + let sort = get_sort_index(config, index)?; + let meilisearch_index = client.get_index(sort.0).await?; + + let mut filter_string = String::new(); + + // Convert offset and limit to page and hits_per_page + let hits_per_page = limit; + let page = offset / limit + 1; + + let results = { + let mut query = meilisearch_index.search(); + query + .with_page(page) + .with_hits_per_page(hits_per_page) + .with_query(info.query.as_deref().unwrap_or_default()) + .with_sort(&sort.1); + + if let Some(new_filters) = info.new_filters.as_deref() { + query.with_filter(new_filters); + } else { + let facets = if let Some(facets) = &info.facets { + Some(serde_json::from_str::>>(facets)?) + } else { + None + }; + + let filters: Cow<_> = + match (info.filters.as_deref(), info.version.as_deref()) { + (Some(f), Some(v)) => format!("({f}) AND ({v})").into(), + (Some(f), None) => f.into(), + (None, Some(v)) => v.into(), + (None, None) => "".into(), + }; + + if let Some(facets) = facets { + // Search can now *optionally* have a third inner array: So Vec(AND)>> + // For every inner facet, we will check if it can be deserialized into a Vec<&str>, and do so. + // If not, we will assume it is a single facet and wrap it in a Vec. + let facets: Vec>> = facets + .into_iter() + .map(|facets| { + facets + .into_iter() + .map(|facet| { + if facet.is_array() { + serde_json::from_value::>(facet) + .unwrap_or_default() + } else { + vec![serde_json::from_value::( + facet, + ) + .unwrap_or_default()] + } + }) + .collect_vec() + }) + .collect_vec(); + + filter_string.push('('); + for (index, facet_outer_list) in facets.iter().enumerate() { + filter_string.push('('); + + for (facet_outer_index, facet_inner_list) in + facet_outer_list.iter().enumerate() + { + filter_string.push('('); + for (facet_inner_index, facet) in + facet_inner_list.iter().enumerate() + { + filter_string.push_str(&facet.replace(':', " = ")); + if facet_inner_index != (facet_inner_list.len() - 1) + { + filter_string.push_str(" AND ") + } + } + filter_string.push(')'); + + if facet_outer_index != (facet_outer_list.len() - 1) { + filter_string.push_str(" OR ") + } + } + + filter_string.push(')'); + + if index != (facets.len() - 1) { + filter_string.push_str(" AND ") + } + } + filter_string.push(')'); + + if !filters.is_empty() { + write!(filter_string, " AND ({filters})")?; + } + } else { + filter_string.push_str(&filters); + } + + if !filter_string.is_empty() { + query.with_filter(&filter_string); + } + } + + query.execute::().await? + }; + + Ok(SearchResults { + hits: results.hits.into_iter().map(|r| r.result).collect(), + page: results.page.unwrap_or_default(), + hits_per_page: results.hits_per_page.unwrap_or_default(), + total_hits: results.total_hits.unwrap_or_default(), + }) +} diff --git a/apps/labrinth/src/util/actix.rs b/apps/labrinth/src/util/actix.rs new file mode 100644 index 000000000..d89eb17ef --- /dev/null +++ b/apps/labrinth/src/util/actix.rs @@ -0,0 +1,95 @@ +use actix_web::test::TestRequest; +use bytes::{Bytes, BytesMut}; + +// Multipart functionality for actix +// Primarily for testing or some implementations of route-redirection +// (actix-test does not innately support multipart) +#[derive(Debug, Clone)] +pub struct MultipartSegment { + pub name: String, + pub filename: Option, + pub content_type: Option, + pub data: MultipartSegmentData, +} + +#[derive(Debug, Clone)] +#[allow(dead_code)] +pub enum MultipartSegmentData { + Text(String), + Binary(Vec), +} + +pub trait AppendsMultipart { + fn set_multipart( + self, + data: impl IntoIterator, + ) -> Self; +} + +impl AppendsMultipart for TestRequest { + fn set_multipart( + self, + data: impl IntoIterator, + ) -> Self { + let (boundary, payload) = generate_multipart(data); + self.append_header(( + "Content-Type", + format!("multipart/form-data; boundary={}", boundary), + )) + .set_payload(payload) + } +} + +pub fn generate_multipart( + data: impl IntoIterator, +) -> (String, Bytes) { + let mut boundary: String = String::from("----WebKitFormBoundary"); + boundary.push_str(&rand::random::().to_string()); + boundary.push_str(&rand::random::().to_string()); + boundary.push_str(&rand::random::().to_string()); + + let mut payload = BytesMut::new(); + + for segment in data { + payload.extend_from_slice( + format!( + "--{boundary}\r\nContent-Disposition: form-data; name=\"{name}\"", + boundary = boundary, + name = segment.name + ) + .as_bytes(), + ); + + if let Some(filename) = &segment.filename { + payload.extend_from_slice( + format!("; filename=\"{filename}\"", filename = filename) + .as_bytes(), + ); + } + if let Some(content_type) = &segment.content_type { + payload.extend_from_slice( + format!( + "\r\nContent-Type: {content_type}", + content_type = content_type + ) + .as_bytes(), + ); + } + payload.extend_from_slice(b"\r\n\r\n"); + + match &segment.data { + MultipartSegmentData::Text(text) => { + payload.extend_from_slice(text.as_bytes()); + } + MultipartSegmentData::Binary(binary) => { + payload.extend_from_slice(binary); + } + } + payload.extend_from_slice(b"\r\n"); + } + payload.extend_from_slice( + format!("--{boundary}--\r\n", boundary = boundary).as_bytes(), + ); + + (boundary, Bytes::from(payload)) +} diff --git a/apps/labrinth/src/util/bitflag.rs b/apps/labrinth/src/util/bitflag.rs new file mode 100644 index 000000000..c4b789cd7 --- /dev/null +++ b/apps/labrinth/src/util/bitflag.rs @@ -0,0 +1,23 @@ +#[macro_export] +macro_rules! bitflags_serde_impl { + ($type:ident, $int_type:ident) => { + impl serde::Serialize for $type { + fn serialize( + &self, + serializer: S, + ) -> Result { + serializer.serialize_i64(self.bits() as i64) + } + } + + impl<'de> serde::Deserialize<'de> for $type { + fn deserialize>( + deserializer: D, + ) -> Result { + let v: i64 = Deserialize::deserialize(deserializer)?; + + Ok($type::from_bits_truncate(v as $int_type)) + } + } + }; +} diff --git a/apps/labrinth/src/util/captcha.rs b/apps/labrinth/src/util/captcha.rs new file mode 100644 index 000000000..4f4c425ab --- /dev/null +++ b/apps/labrinth/src/util/captcha.rs @@ -0,0 +1,44 @@ +use crate::routes::ApiError; +use crate::util::env::parse_var; +use actix_web::HttpRequest; +use serde::Deserialize; +use serde_json::json; + +pub async fn check_turnstile_captcha( + req: &HttpRequest, + challenge: &str, +) -> Result { + let conn_info = req.connection_info().clone(); + let ip_addr = if parse_var("CLOUDFLARE_INTEGRATION").unwrap_or(false) { + if let Some(header) = req.headers().get("CF-Connecting-IP") { + header.to_str().ok() + } else { + conn_info.peer_addr() + } + } else { + conn_info.peer_addr() + }; + + let client = reqwest::Client::new(); + + #[derive(Deserialize)] + struct Response { + success: bool, + } + + let val: Response = client + .post("https://challenges.cloudflare.com/turnstile/v0/siteverify") + .json(&json!({ + "secret": dotenvy::var("TURNSTILE_SECRET")?, + "response": challenge, + "remoteip": ip_addr, + })) + .send() + .await + .map_err(|_| ApiError::Turnstile)? + .json() + .await + .map_err(|_| ApiError::Turnstile)?; + + Ok(val.success) +} diff --git a/apps/labrinth/src/util/cors.rs b/apps/labrinth/src/util/cors.rs new file mode 100644 index 000000000..5f35b2bc5 --- /dev/null +++ b/apps/labrinth/src/util/cors.rs @@ -0,0 +1,10 @@ +use actix_cors::Cors; + +pub fn default_cors() -> Cors { + Cors::default() + .allow_any_origin() + .allow_any_header() + .allow_any_method() + .max_age(3600) + .send_wildcard() +} diff --git a/apps/labrinth/src/util/date.rs b/apps/labrinth/src/util/date.rs new file mode 100644 index 000000000..3551307bd --- /dev/null +++ b/apps/labrinth/src/util/date.rs @@ -0,0 +1,9 @@ +use chrono::Utc; + +// this converts timestamps to the timestamp format clickhouse requires/uses +pub fn get_current_tenths_of_ms() -> i64 { + Utc::now() + .timestamp_nanos_opt() + .expect("value can not be represented in a timestamp with nanosecond precision.") + / 100_000 +} diff --git a/apps/labrinth/src/util/env.rs b/apps/labrinth/src/util/env.rs new file mode 100644 index 000000000..9de970c6f --- /dev/null +++ b/apps/labrinth/src/util/env.rs @@ -0,0 +1,10 @@ +use std::str::FromStr; + +pub fn parse_var(var: &'static str) -> Option { + dotenvy::var(var).ok().and_then(|i| i.parse().ok()) +} +pub fn parse_strings_from_var(var: &'static str) -> Option> { + dotenvy::var(var) + .ok() + .and_then(|s| serde_json::from_str::>(&s).ok()) +} diff --git a/apps/labrinth/src/util/ext.rs b/apps/labrinth/src/util/ext.rs new file mode 100644 index 000000000..1f2e9fd38 --- /dev/null +++ b/apps/labrinth/src/util/ext.rs @@ -0,0 +1,30 @@ +pub fn get_image_content_type(extension: &str) -> Option<&'static str> { + match extension { + "bmp" => Some("image/bmp"), + "gif" => Some("image/gif"), + "jpeg" | "jpg" => Some("image/jpeg"), + "png" => Some("image/png"), + "webp" => Some("image/webp"), + _ => None, + } +} + +pub fn get_image_ext(content_type: &str) -> Option<&'static str> { + match content_type { + "image/bmp" => Some("bmp"), + "image/gif" => Some("gif"), + "image/jpeg" => Some("jpg"), + "image/png" => Some("png"), + "image/webp" => Some("webp"), + _ => None, + } +} + +pub fn project_file_type(ext: &str) -> Option<&str> { + match ext { + "jar" => Some("application/java-archive"), + "zip" | "litemod" => Some("application/zip"), + "mrpack" => Some("application/x-modrinth-modpack+zip"), + _ => None, + } +} diff --git a/apps/labrinth/src/util/guards.rs b/apps/labrinth/src/util/guards.rs new file mode 100644 index 000000000..f7ad43ccf --- /dev/null +++ b/apps/labrinth/src/util/guards.rs @@ -0,0 +1,12 @@ +use actix_web::guard::GuardContext; + +pub const ADMIN_KEY_HEADER: &str = "Modrinth-Admin"; +pub fn admin_key_guard(ctx: &GuardContext) -> bool { + let admin_key = std::env::var("LABRINTH_ADMIN_KEY").expect( + "No admin key provided, this should have been caught by check_env_vars", + ); + ctx.head() + .headers() + .get(ADMIN_KEY_HEADER) + .map_or(false, |it| it.as_bytes() == admin_key.as_bytes()) +} diff --git a/apps/labrinth/src/util/img.rs b/apps/labrinth/src/util/img.rs new file mode 100644 index 000000000..4e5cb24fb --- /dev/null +++ b/apps/labrinth/src/util/img.rs @@ -0,0 +1,221 @@ +use crate::database; +use crate::database::models::image_item; +use crate::database::redis::RedisPool; +use crate::file_hosting::FileHost; +use crate::models::images::ImageContext; +use crate::routes::ApiError; +use color_thief::ColorFormat; +use image::imageops::FilterType; +use image::{ + DynamicImage, EncodableLayout, GenericImageView, ImageError, + ImageOutputFormat, +}; +use std::io::Cursor; +use webp::Encoder; + +pub fn get_color_from_img(data: &[u8]) -> Result, ImageError> { + let image = image::load_from_memory(data)? + .resize(256, 256, FilterType::Nearest) + .crop_imm(128, 128, 64, 64); + let color = color_thief::get_palette( + image.to_rgb8().as_bytes(), + ColorFormat::Rgb, + 10, + 2, + ) + .ok() + .and_then(|x| x.first().copied()) + .map(|x| (x.r as u32) << 16 | (x.g as u32) << 8 | (x.b as u32)); + + Ok(color) +} + +pub struct UploadImageResult { + pub url: String, + pub url_path: String, + + pub raw_url: String, + pub raw_url_path: String, + + pub color: Option, +} + +pub async fn upload_image_optimized( + upload_folder: &str, + bytes: bytes::Bytes, + file_extension: &str, + target_width: Option, + min_aspect_ratio: Option, + file_host: &dyn FileHost, +) -> Result { + let content_type = crate::util::ext::get_image_content_type(file_extension) + .ok_or_else(|| { + ApiError::InvalidInput(format!( + "Invalid format for image: {}", + file_extension + )) + })?; + + let cdn_url = dotenvy::var("CDN_URL")?; + + let hash = sha1::Sha1::from(&bytes).hexdigest(); + let (processed_image, processed_image_ext) = process_image( + bytes.clone(), + content_type, + target_width, + min_aspect_ratio, + )?; + let color = get_color_from_img(&bytes)?; + + // Only upload the processed image if it's smaller than the original + let processed_upload_data = if processed_image.len() < bytes.len() { + Some( + file_host + .upload_file( + content_type, + &format!( + "{}/{}_{}.{}", + upload_folder, + hash, + target_width.unwrap_or(0), + processed_image_ext + ), + processed_image, + ) + .await?, + ) + } else { + None + }; + + let upload_data = file_host + .upload_file( + content_type, + &format!("{}/{}.{}", upload_folder, hash, file_extension), + bytes, + ) + .await?; + + let url = format!("{}/{}", cdn_url, upload_data.file_name); + Ok(UploadImageResult { + url: processed_upload_data + .clone() + .map(|x| format!("{}/{}", cdn_url, x.file_name)) + .unwrap_or_else(|| url.clone()), + url_path: processed_upload_data + .map(|x| x.file_name) + .unwrap_or_else(|| upload_data.file_name.clone()), + + raw_url: url, + raw_url_path: upload_data.file_name, + color, + }) +} + +fn process_image( + image_bytes: bytes::Bytes, + content_type: &str, + target_width: Option, + min_aspect_ratio: Option, +) -> Result<(bytes::Bytes, String), ImageError> { + if content_type.to_lowercase() == "image/gif" { + return Ok((image_bytes.clone(), "gif".to_string())); + } + + let mut img = image::load_from_memory(&image_bytes)?; + + let webp_bytes = convert_to_webp(&img)?; + img = image::load_from_memory(&webp_bytes)?; + + // Resize the image + let (orig_width, orig_height) = img.dimensions(); + let aspect_ratio = orig_width as f32 / orig_height as f32; + + if let Some(target_width) = target_width { + if img.width() > target_width { + let new_height = + (target_width as f32 / aspect_ratio).round() as u32; + img = img.resize(target_width, new_height, FilterType::Lanczos3); + } + } + + if let Some(min_aspect_ratio) = min_aspect_ratio { + // Crop if necessary + if aspect_ratio < min_aspect_ratio { + let crop_height = + (img.width() as f32 / min_aspect_ratio).round() as u32; + let y_offset = (img.height() - crop_height) / 2; + img = img.crop_imm(0, y_offset, img.width(), crop_height); + } + } + + // Optimize and compress + let mut output = Vec::new(); + img.write_to(&mut Cursor::new(&mut output), ImageOutputFormat::WebP)?; + + Ok((bytes::Bytes::from(output), "webp".to_string())) +} + +fn convert_to_webp(img: &DynamicImage) -> Result, ImageError> { + let rgba = img.to_rgba8(); + let encoder = Encoder::from_rgba(&rgba, img.width(), img.height()); + let webp = encoder.encode(75.0); // Quality factor: 0-100, 75 is a good balance + Ok(webp.to_vec()) +} + +pub async fn delete_old_images( + image_url: Option, + raw_image_url: Option, + file_host: &dyn FileHost, +) -> Result<(), ApiError> { + let cdn_url = dotenvy::var("CDN_URL")?; + let cdn_url_start = format!("{cdn_url}/"); + if let Some(image_url) = image_url { + let name = image_url.split(&cdn_url_start).nth(1); + + if let Some(icon_path) = name { + file_host.delete_file_version("", icon_path).await?; + } + } + + if let Some(raw_image_url) = raw_image_url { + let name = raw_image_url.split(&cdn_url_start).nth(1); + + if let Some(icon_path) = name { + file_host.delete_file_version("", icon_path).await?; + } + } + + Ok(()) +} + +// check changes to associated images +// if they no longer exist in the String list, delete them +// Eg: if description is modified and no longer contains a link to an iamge +pub async fn delete_unused_images( + context: ImageContext, + reference_strings: Vec<&str>, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &RedisPool, +) -> Result<(), ApiError> { + let uploaded_images = + database::models::Image::get_many_contexted(context, transaction) + .await?; + + for image in uploaded_images { + let mut should_delete = true; + for reference in &reference_strings { + if image.url.contains(reference) { + should_delete = false; + break; + } + } + + if should_delete { + image_item::Image::remove(image.id, transaction, redis).await?; + image_item::Image::clear_cache(image.id, redis).await?; + } + } + + Ok(()) +} diff --git a/apps/labrinth/src/util/mod.rs b/apps/labrinth/src/util/mod.rs new file mode 100644 index 000000000..b7271c706 --- /dev/null +++ b/apps/labrinth/src/util/mod.rs @@ -0,0 +1,14 @@ +pub mod actix; +pub mod bitflag; +pub mod captcha; +pub mod cors; +pub mod date; +pub mod env; +pub mod ext; +pub mod guards; +pub mod img; +pub mod ratelimit; +pub mod redis; +pub mod routes; +pub mod validate; +pub mod webhook; diff --git a/apps/labrinth/src/util/ratelimit.rs b/apps/labrinth/src/util/ratelimit.rs new file mode 100644 index 000000000..f20fb51f3 --- /dev/null +++ b/apps/labrinth/src/util/ratelimit.rs @@ -0,0 +1,187 @@ +use governor::clock::{Clock, DefaultClock}; +use governor::{middleware, state, RateLimiter}; +use std::str::FromStr; +use std::sync::Arc; + +use crate::routes::ApiError; +use crate::util::env::parse_var; +use actix_web::{ + body::EitherBody, + dev::{forward_ready, Service, ServiceRequest, ServiceResponse, Transform}, + Error, ResponseError, +}; +use futures_util::future::LocalBoxFuture; +use futures_util::future::{ready, Ready}; + +pub type KeyedRateLimiter< + K = String, + MW = middleware::StateInformationMiddleware, +> = Arc< + RateLimiter, DefaultClock, MW>, +>; + +pub struct RateLimit(pub KeyedRateLimiter); + +impl Transform for RateLimit +where + S: Service, Error = Error>, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse>; + type Error = Error; + type Transform = RateLimitService; + type InitError = (); + type Future = Ready>; + + fn new_transform(&self, service: S) -> Self::Future { + ready(Ok(RateLimitService { + service, + rate_limiter: Arc::clone(&self.0), + })) + } +} + +#[doc(hidden)] +pub struct RateLimitService { + service: S, + rate_limiter: KeyedRateLimiter, +} + +impl Service for RateLimitService +where + S: Service, Error = Error>, + S::Future: 'static, + B: 'static, +{ + type Response = ServiceResponse>; + type Error = Error; + type Future = LocalBoxFuture<'static, Result>; + + forward_ready!(service); + + fn call(&self, req: ServiceRequest) -> Self::Future { + if let Some(key) = req.headers().get("x-ratelimit-key") { + if key.to_str().ok() + == dotenvy::var("RATE_LIMIT_IGNORE_KEY").ok().as_deref() + { + let res = self.service.call(req); + + return Box::pin(async move { + let service_response = res.await?; + Ok(service_response.map_into_left_body()) + }); + } + } + + let conn_info = req.connection_info().clone(); + let ip = if parse_var("CLOUDFLARE_INTEGRATION").unwrap_or(false) { + if let Some(header) = req.headers().get("CF-Connecting-IP") { + header.to_str().ok() + } else { + conn_info.peer_addr() + } + } else { + conn_info.peer_addr() + }; + + if let Some(ip) = ip { + let ip = ip.to_string(); + + match self.rate_limiter.check_key(&ip) { + Ok(snapshot) => { + let fut = self.service.call(req); + + Box::pin(async move { + match fut.await { + Ok(mut service_response) => { + // Now you have a mutable reference to the ServiceResponse, so you can modify its headers. + let headers = service_response.headers_mut(); + headers.insert( + actix_web::http::header::HeaderName::from_str( + "x-ratelimit-limit", + ) + .unwrap(), + snapshot.quota().burst_size().get().into(), + ); + headers.insert( + actix_web::http::header::HeaderName::from_str( + "x-ratelimit-remaining", + ) + .unwrap(), + snapshot.remaining_burst_capacity().into(), + ); + + headers.insert( + actix_web::http::header::HeaderName::from_str( + "x-ratelimit-reset", + ) + .unwrap(), + snapshot + .quota() + .burst_size_replenished_in() + .as_secs() + .into(), + ); + + // Return the modified response as Ok. + Ok(service_response.map_into_left_body()) + } + Err(e) => { + // Handle error case + Err(e) + } + } + }) + } + Err(negative) => { + let wait_time = + negative.wait_time_from(DefaultClock::default().now()); + + let mut response = ApiError::RateLimitError( + wait_time.as_millis(), + negative.quota().burst_size().get(), + ) + .error_response(); + + let headers = response.headers_mut(); + + headers.insert( + actix_web::http::header::HeaderName::from_str( + "x-ratelimit-limit", + ) + .unwrap(), + negative.quota().burst_size().get().into(), + ); + headers.insert( + actix_web::http::header::HeaderName::from_str( + "x-ratelimit-remaining", + ) + .unwrap(), + 0.into(), + ); + headers.insert( + actix_web::http::header::HeaderName::from_str( + "x-ratelimit-reset", + ) + .unwrap(), + wait_time.as_secs().into(), + ); + + Box::pin(async { + Ok(req.into_response(response.map_into_right_body())) + }) + } + } + } else { + let response = ApiError::CustomAuthentication( + "Unable to obtain user IP address!".to_string(), + ) + .error_response(); + + Box::pin(async { + Ok(req.into_response(response.map_into_right_body())) + }) + } + } +} diff --git a/apps/labrinth/src/util/redis.rs b/apps/labrinth/src/util/redis.rs new file mode 100644 index 000000000..b3f34ee2b --- /dev/null +++ b/apps/labrinth/src/util/redis.rs @@ -0,0 +1,18 @@ +use redis::Cmd; + +pub fn redis_args(cmd: &mut Cmd, args: &[String]) { + for arg in args { + cmd.arg(arg); + } +} + +pub async fn redis_execute( + cmd: &mut Cmd, + redis: &mut deadpool_redis::Connection, +) -> Result +where + T: redis::FromRedisValue, +{ + let res = cmd.query_async::(redis).await?; + Ok(res) +} diff --git a/apps/labrinth/src/util/routes.rs b/apps/labrinth/src/util/routes.rs new file mode 100644 index 000000000..f12e07d97 --- /dev/null +++ b/apps/labrinth/src/util/routes.rs @@ -0,0 +1,42 @@ +use crate::routes::v3::project_creation::CreateError; +use crate::routes::ApiError; +use actix_multipart::Field; +use actix_web::web::Payload; +use bytes::BytesMut; +use futures::StreamExt; + +pub async fn read_from_payload( + payload: &mut Payload, + cap: usize, + err_msg: &'static str, +) -> Result { + let mut bytes = BytesMut::new(); + while let Some(item) = payload.next().await { + if bytes.len() >= cap { + return Err(ApiError::InvalidInput(String::from(err_msg))); + } else { + bytes.extend_from_slice(&item.map_err(|_| { + ApiError::InvalidInput( + "Unable to parse bytes in payload sent!".to_string(), + ) + })?); + } + } + Ok(bytes) +} + +pub async fn read_from_field( + field: &mut Field, + cap: usize, + err_msg: &'static str, +) -> Result { + let mut bytes = BytesMut::new(); + while let Some(chunk) = field.next().await { + if bytes.len() >= cap { + return Err(CreateError::InvalidInput(String::from(err_msg))); + } else { + bytes.extend_from_slice(&chunk?); + } + } + Ok(bytes) +} diff --git a/apps/labrinth/src/util/validate.rs b/apps/labrinth/src/util/validate.rs new file mode 100644 index 000000000..312f80f9e --- /dev/null +++ b/apps/labrinth/src/util/validate.rs @@ -0,0 +1,160 @@ +use itertools::Itertools; +use lazy_static::lazy_static; +use regex::Regex; +use validator::{ValidationErrors, ValidationErrorsKind}; + +use crate::models::pats::Scopes; + +lazy_static! { + pub static ref RE_URL_SAFE: Regex = + Regex::new(r#"^[a-zA-Z0-9!@$()`.+,_"-]*$"#).unwrap(); +} + +//TODO: In order to ensure readability, only the first error is printed, this may need to be expanded on in the future! +pub fn validation_errors_to_string( + errors: ValidationErrors, + adder: Option, +) -> String { + let mut output = String::new(); + + let map = errors.into_errors(); + + let key_option = map.keys().next().copied(); + + if let Some(field) = key_option { + if let Some(error) = map.get(field) { + return match error { + ValidationErrorsKind::Struct(errors) => { + validation_errors_to_string( + *errors.clone(), + Some(format!("of item {field}")), + ) + } + ValidationErrorsKind::List(list) => { + if let Some((index, errors)) = list.iter().next() { + output.push_str(&validation_errors_to_string( + *errors.clone(), + Some(format!("of list {field} with index {index}")), + )); + } + + output + } + ValidationErrorsKind::Field(errors) => { + if let Some(error) = errors.first() { + if let Some(adder) = adder { + output.push_str(&format!( + "Field {} {} failed validation with error: {}", + field, adder, error.code + )); + } else { + output.push_str(&format!( + "Field {} failed validation with error: {}", + field, error.code + )); + } + } + + output + } + }; + } + } + + String::new() +} + +pub fn validate_deps( + values: &[crate::models::projects::Dependency], +) -> Result<(), validator::ValidationError> { + if values + .iter() + .duplicates_by(|x| { + format!( + "{}-{}-{}", + x.version_id + .unwrap_or(crate::models::projects::VersionId(0)), + x.project_id + .unwrap_or(crate::models::projects::ProjectId(0)), + x.file_name.as_deref().unwrap_or_default() + ) + }) + .next() + .is_some() + { + return Err(validator::ValidationError::new("duplicate dependency")); + } + + Ok(()) +} + +pub fn validate_url(value: &str) -> Result<(), validator::ValidationError> { + let url = url::Url::parse(value) + .ok() + .ok_or_else(|| validator::ValidationError::new("invalid URL"))?; + + if url.scheme() != "https" { + return Err(validator::ValidationError::new("URL must be https")); + } + + Ok(()) +} + +pub fn validate_url_hashmap_optional_values( + values: &std::collections::HashMap>, +) -> Result<(), validator::ValidationError> { + for value in values.values().flatten() { + validate_url(value)?; + } + + Ok(()) +} + +pub fn validate_url_hashmap_values( + values: &std::collections::HashMap, +) -> Result<(), validator::ValidationError> { + for value in values.values() { + validate_url(value)?; + } + + Ok(()) +} + +pub fn validate_no_restricted_scopes( + value: &Scopes, +) -> Result<(), validator::ValidationError> { + if value.is_restricted() { + return Err(validator::ValidationError::new( + "Restricted scopes not allowed", + )); + } + + Ok(()) +} + +pub fn validate_name(value: &str) -> Result<(), validator::ValidationError> { + if value.trim().is_empty() { + return Err(validator::ValidationError::new( + "Name cannot contain only whitespace.", + )); + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validate_name_with_valid_input() { + let result = validate_name("My Test mod"); + assert!(result.is_ok()); + } + + #[test] + fn validate_name_with_invalid_input_returns_error() { + let result = validate_name(" "); + assert!(result.is_err()); + } +} diff --git a/apps/labrinth/src/util/webhook.rs b/apps/labrinth/src/util/webhook.rs new file mode 100644 index 000000000..5227b82eb --- /dev/null +++ b/apps/labrinth/src/util/webhook.rs @@ -0,0 +1,625 @@ +use crate::database::models::legacy_loader_fields::MinecraftGameVersion; +use crate::database::redis::RedisPool; +use crate::models::ids::base62_impl::to_base62; +use crate::models::projects::ProjectId; +use crate::routes::ApiError; +use chrono::{DateTime, Utc}; +use serde::Serialize; +use sqlx::PgPool; + +const PLUGIN_LOADERS: &[&str] = &[ + "bukkit", + "spigot", + "paper", + "purpur", + "bungeecord", + "waterfall", + "velocity", + "sponge", +]; + +struct WebhookMetadata { + pub project_url: String, + pub project_title: String, + pub project_summary: String, + pub display_project_type: String, + pub project_icon_url: Option, + pub color: Option, + + pub author: Option, + + pub categories_formatted: Vec, + pub loaders_formatted: Vec, + pub versions_formatted: Vec, + + pub gallery_image: Option, +} + +struct WebhookAuthor { + pub name: String, + pub url: String, + pub icon_url: Option, +} + +async fn get_webhook_metadata( + project_id: ProjectId, + pool: &PgPool, + redis: &RedisPool, + emoji: bool, +) -> Result, ApiError> { + let project = crate::database::models::project_item::Project::get_id( + project_id.into(), + pool, + redis, + ) + .await?; + + if let Some(mut project) = project { + let mut owner = None; + + if let Some(organization_id) = project.inner.organization_id { + let organization = crate::database::models::organization_item::Organization::get_id( + organization_id, + pool, + redis, + ) + .await?; + + if let Some(organization) = organization { + owner = Some(WebhookAuthor { + name: organization.name, + url: format!( + "{}/organization/{}", + dotenvy::var("SITE_URL").unwrap_or_default(), + organization.slug + ), + icon_url: organization.icon_url, + }); + } + } else { + let team = crate::database::models::team_item::TeamMember::get_from_team_full( + project.inner.team_id, + pool, + redis, + ) + .await?; + + if let Some(member) = team.into_iter().find(|x| x.is_owner) { + let user = crate::database::models::user_item::User::get_id( + member.user_id, + pool, + redis, + ) + .await?; + + if let Some(user) = user { + owner = Some(WebhookAuthor { + url: format!( + "{}/user/{}", + dotenvy::var("SITE_URL").unwrap_or_default(), + user.username + ), + name: user.username, + icon_url: user.avatar_url, + }); + } + } + }; + + let all_game_versions = + MinecraftGameVersion::list(None, None, pool, redis).await?; + + let versions = project + .aggregate_version_fields + .clone() + .into_iter() + .find_map(|vf| { + MinecraftGameVersion::try_from_version_field(&vf).ok() + }) + .unwrap_or_default(); + + let formatted_game_versions = get_gv_range(versions, all_game_versions); + + let mut project_type = project.project_types.pop().unwrap_or_default(); // TODO: Should this grab a not-first? + + if project + .inner + .loaders + .iter() + .all(|x| PLUGIN_LOADERS.contains(&&**x)) + { + project_type = "plugin".to_string(); + } else if project.inner.loaders.iter().any(|x| x == "datapack") { + project_type = "datapack".to_string(); + } + + let mut display_project_type = match &*project_type { + "datapack" => "data pack", + "resourcepack" => "resource pack", + _ => &*project_type, + } + .to_string(); + + Ok(Some(WebhookMetadata { + project_url: format!( + "{}/{}/{}", + dotenvy::var("SITE_URL").unwrap_or_default(), + project_type, + project + .inner + .slug + .clone() + .unwrap_or_else(|| to_base62(project.inner.id.0 as u64)) + ), + project_title: project.inner.name, + project_summary: project.inner.summary, + display_project_type: format!( + "{}{display_project_type}", + display_project_type.remove(0).to_uppercase() + ), + project_icon_url: project.inner.icon_url, + color: project.inner.color, + author: owner, + categories_formatted: project + .categories + .into_iter() + .map(|mut x| format!("{}{x}", x.remove(0).to_uppercase())) + .collect::>(), + loaders_formatted: project + .inner + .loaders + .into_iter() + .map(|loader| { + let mut x = if &*loader == "datapack" { + "Data Pack".to_string() + } else if &*loader == "mrpack" { + "Modpack".to_string() + } else { + loader.clone() + }; + + if emoji { + let emoji_id: i64 = match &*loader { + "bukkit" => 1049793345481883689, + "bungeecord" => 1049793347067314220, + "canvas" => 1107352170656968795, + "datapack" => 1057895494652788866, + "fabric" => 1049793348719890532, + "folia" => 1107348745571537018, + "forge" => 1049793350498275358, + "iris" => 1107352171743281173, + "liteloader" => 1049793351630733333, + "minecraft" => 1049793352964526100, + "modloader" => 1049793353962762382, + "neoforge" => 1140437823783190679, + "optifine" => 1107352174415052901, + "paper" => 1049793355598540810, + "purpur" => 1140436034505674762, + "quilt" => 1049793857681887342, + "rift" => 1049793359373414502, + "spigot" => 1049793413886779413, + "sponge" => 1049793416969605231, + "vanilla" => 1107350794178678855, + "velocity" => 1049793419108700170, + "waterfall" => 1049793420937412638, + _ => 1049805243866681424, + }; + + format!( + "<:{loader}:{emoji_id}> {}{x}", + x.remove(0).to_uppercase() + ) + } else { + format!("{}{x}", x.remove(0).to_uppercase()) + } + }) + .collect(), + versions_formatted: formatted_game_versions, + gallery_image: project + .gallery_items + .into_iter() + .find(|x| x.featured) + .map(|x| x.image_url), + })) + } else { + Ok(None) + } +} + +pub async fn send_slack_webhook( + project_id: ProjectId, + pool: &PgPool, + redis: &RedisPool, + webhook_url: String, + message: Option, +) -> Result<(), ApiError> { + let metadata = get_webhook_metadata(project_id, pool, redis, false).await?; + + if let Some(metadata) = metadata { + let mut blocks = vec![]; + + if let Some(message) = message { + blocks.push(serde_json::json!({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": message, + } + })); + } + + if let Some(ref author) = metadata.author { + let mut elements = vec![]; + + if let Some(ref icon_url) = author.icon_url { + elements.push(serde_json::json!({ + "type": "image", + "image_url": icon_url, + "alt_text": "Author" + })); + } + + elements.push(serde_json::json!({ + "type": "mrkdwn", + "text": format!("<{}|{}>", author.url, author.name) + })); + + blocks.push(serde_json::json!({ + "type": "context", + "elements": elements + })); + } + + let mut project_block = serde_json::json!({ + "type": "section", + "text": { + "type": "mrkdwn", + "text": format!( + "*<{}|{}>*\n\n{}\n\n*Categories:* {}\n\n*Loaders:* {}\n\n*Versions:* {}", + metadata.project_url, + metadata.project_title, + metadata.project_summary, + metadata.categories_formatted.join(", "), + metadata.loaders_formatted.join(", "), + metadata.versions_formatted.join(", ") + ) + } + }); + + if let Some(icon_url) = metadata.project_icon_url { + if let Some(project_block) = project_block.as_object_mut() { + project_block.insert( + "accessory".to_string(), + serde_json::json!({ + "type": "image", + "image_url": icon_url, + "alt_text": metadata.project_title + }), + ); + } + } + + blocks.push(project_block); + + if let Some(gallery_image) = metadata.gallery_image { + blocks.push(serde_json::json!({ + "type": "image", + "image_url": gallery_image, + "alt_text": metadata.project_title + })); + } + + blocks.push( + serde_json::json!({ + "type": "context", + "elements": [ + { + "type": "image", + "image_url": "https://cdn-raw.modrinth.com/modrinth-new.png", + "alt_text": "Author" + }, + { + "type": "mrkdwn", + "text": format!("{} on Modrinth • ", metadata.display_project_type, Utc::now().timestamp()) + } + ] + }) + ); + + let client = reqwest::Client::new(); + + client + .post(&webhook_url) + .json(&serde_json::json!({ + "blocks": blocks, + })) + .send() + .await + .map_err(|_| { + ApiError::Discord( + "Error while sending projects webhook".to_string(), + ) + })?; + } + + Ok(()) +} + +#[derive(Serialize)] +struct DiscordEmbed { + pub author: Option, + pub title: String, + pub description: String, + pub url: String, + pub timestamp: DateTime, + pub color: u32, + pub fields: Vec, + pub thumbnail: DiscordEmbedThumbnail, + pub image: Option, + pub footer: Option, +} + +#[derive(Serialize)] +struct DiscordEmbedAuthor { + pub name: String, + pub url: Option, + pub icon_url: Option, +} + +#[derive(Serialize)] +struct DiscordEmbedField { + pub name: &'static str, + pub value: String, + pub inline: bool, +} + +#[derive(Serialize)] +struct DiscordEmbedImage { + pub url: Option, +} + +#[derive(Serialize)] +struct DiscordEmbedThumbnail { + pub url: Option, +} + +#[derive(Serialize)] +struct DiscordEmbedFooter { + pub text: String, + pub icon_url: Option, +} + +#[derive(Serialize)] +struct DiscordWebhook { + pub avatar_url: Option, + pub username: Option, + pub embeds: Vec, + pub content: Option, +} + +pub async fn send_discord_webhook( + project_id: ProjectId, + pool: &PgPool, + redis: &RedisPool, + webhook_url: String, + message: Option, +) -> Result<(), ApiError> { + let metadata = get_webhook_metadata(project_id, pool, redis, true).await?; + + if let Some(project) = metadata { + let mut fields = vec![]; + if !project.categories_formatted.is_empty() { + fields.push(DiscordEmbedField { + name: "Categories", + value: project.categories_formatted.join("\n"), + inline: true, + }); + } + + if !project.loaders_formatted.is_empty() { + fields.push(DiscordEmbedField { + name: "Loaders", + value: project.loaders_formatted.join("\n"), + inline: true, + }); + } + + if !project.versions_formatted.is_empty() { + fields.push(DiscordEmbedField { + name: "Versions", + value: project.versions_formatted.join("\n"), + inline: true, + }); + } + + let embed = DiscordEmbed { + author: project.author.map(|x| DiscordEmbedAuthor { + name: x.name, + url: Some(x.url), + icon_url: x.icon_url, + }), + url: project.project_url, + title: project.project_title, // Do not change DiscordEmbed + description: project.project_summary, + timestamp: Utc::now(), + color: project.color.unwrap_or(0x1bd96a), + fields, + thumbnail: DiscordEmbedThumbnail { + url: project.project_icon_url, + }, + image: project + .gallery_image + .map(|x| DiscordEmbedImage { url: Some(x) }), + footer: Some(DiscordEmbedFooter { + text: format!("{} on Modrinth", project.display_project_type), + icon_url: Some( + "https://cdn-raw.modrinth.com/modrinth-new.png".to_string(), + ), + }), + }; + + let client = reqwest::Client::new(); + + client + .post(&webhook_url) + .json(&DiscordWebhook { + avatar_url: Some( + "https://cdn.modrinth.com/Modrinth_Dark_Logo.png" + .to_string(), + ), + username: Some("Modrinth Release".to_string()), + embeds: vec![embed], + content: message, + }) + .send() + .await + .map_err(|_| { + ApiError::Discord( + "Error while sending projects webhook".to_string(), + ) + })?; + } + + Ok(()) +} + +fn get_gv_range( + mut game_versions: Vec, + mut all_game_versions: Vec, +) -> Vec { + // both -> least to greatest + game_versions.sort_by(|a, b| a.created.cmp(&b.created)); + game_versions.dedup_by(|a, b| a.version == b.version); + + all_game_versions.sort_by(|a, b| a.created.cmp(&b.created)); + + let all_releases = all_game_versions + .iter() + .filter(|x| &*x.type_ == "release") + .cloned() + .collect::>(); + + let mut intervals = Vec::new(); + let mut current_interval = 0; + + const MAX_VALUE: usize = 1000000; + + for (i, current_version) in game_versions.iter().enumerate() { + let current_version = ¤t_version.version; + + let index = all_game_versions + .iter() + .position(|x| &*x.version == current_version) + .unwrap_or(MAX_VALUE); + let release_index = all_releases + .iter() + .position(|x| &*x.version == current_version) + .unwrap_or(MAX_VALUE); + + if i == 0 { + intervals.push(vec![vec![i, index, release_index]]) + } else { + let interval_base = &intervals[current_interval]; + + if ((index as i32) + - (interval_base[interval_base.len() - 1][1] as i32) + == 1 + || (release_index as i32) + - (interval_base[interval_base.len() - 1][2] as i32) + == 1) + && (all_game_versions[interval_base[0][1]].type_ == "release" + || all_game_versions[index].type_ != "release") + { + if intervals[current_interval].get(1).is_some() { + intervals[current_interval][1] = + vec![i, index, release_index]; + } else { + intervals[current_interval] + .insert(1, vec![i, index, release_index]); + } + } else { + current_interval += 1; + intervals.push(vec![vec![i, index, release_index]]); + } + } + } + + let mut new_intervals = Vec::new(); + + for interval in intervals { + if interval.len() == 2 + && interval[0][2] != MAX_VALUE + && interval[1][2] == MAX_VALUE + { + let mut last_snapshot: Option = None; + + for j in ((interval[0][1] + 1)..=interval[1][1]).rev() { + if all_game_versions[j].type_ == "release" { + new_intervals.push(vec![ + interval[0].clone(), + vec![ + game_versions + .iter() + .position(|x| { + x.version == all_game_versions[j].version + }) + .unwrap_or(MAX_VALUE), + j, + all_releases + .iter() + .position(|x| { + x.version == all_game_versions[j].version + }) + .unwrap_or(MAX_VALUE), + ], + ]); + + if let Some(last_snapshot) = last_snapshot { + if last_snapshot != j + 1 { + new_intervals.push(vec![ + vec![ + game_versions + .iter() + .position(|x| { + x.version + == all_game_versions + [last_snapshot] + .version + }) + .unwrap_or(MAX_VALUE), + last_snapshot, + MAX_VALUE, + ], + interval[1].clone(), + ]) + } + } else { + new_intervals.push(vec![interval[1].clone()]) + } + + break; + } else { + last_snapshot = Some(j); + } + } + } else { + new_intervals.push(interval); + } + } + + let mut output = Vec::new(); + + for interval in new_intervals { + if interval.len() == 2 { + output.push(format!( + "{}—{}", + &game_versions[interval[0][0]].version, + &game_versions[interval[1][0]].version + )) + } else { + output.push(game_versions[interval[0][0]].version.clone()) + } + } + + output +} diff --git a/apps/labrinth/src/validate/datapack.rs b/apps/labrinth/src/validate/datapack.rs new file mode 100644 index 000000000..18cdd7e76 --- /dev/null +++ b/apps/labrinth/src/validate/datapack.rs @@ -0,0 +1,34 @@ +use crate::validate::{ + SupportedGameVersions, ValidationError, ValidationResult, +}; +use std::io::Cursor; +use zip::ZipArchive; + +pub struct DataPackValidator; + +impl super::Validator for DataPackValidator { + fn get_file_extensions(&self) -> &[&str] { + &["zip"] + } + + fn get_supported_loaders(&self) -> &[&str] { + &["datapack"] + } + + fn get_supported_game_versions(&self) -> SupportedGameVersions { + SupportedGameVersions::All + } + + fn validate( + &self, + archive: &mut ZipArchive>, + ) -> Result { + if archive.by_name("pack.mcmeta").is_err() { + return Ok(ValidationResult::Warning( + "No pack.mcmeta present for datapack file. Tip: Make sure pack.mcmeta is in the root directory of your datapack!", + )); + } + + Ok(ValidationResult::Pass) + } +} diff --git a/apps/labrinth/src/validate/fabric.rs b/apps/labrinth/src/validate/fabric.rs new file mode 100644 index 000000000..e5bc34c72 --- /dev/null +++ b/apps/labrinth/src/validate/fabric.rs @@ -0,0 +1,36 @@ +use crate::validate::{ + filter_out_packs, SupportedGameVersions, ValidationError, ValidationResult, +}; +use std::io::Cursor; +use zip::ZipArchive; + +pub struct FabricValidator; + +impl super::Validator for FabricValidator { + fn get_file_extensions(&self) -> &[&str] { + &["jar"] + } + + fn get_supported_loaders(&self) -> &[&str] { + &["fabric"] + } + + fn get_supported_game_versions(&self) -> SupportedGameVersions { + SupportedGameVersions::All + } + + fn validate( + &self, + archive: &mut ZipArchive>, + ) -> Result { + if archive.by_name("fabric.mod.json").is_err() { + return Ok(ValidationResult::Warning( + "No fabric.mod.json present for Fabric file.", + )); + } + + filter_out_packs(archive)?; + + Ok(ValidationResult::Pass) + } +} diff --git a/apps/labrinth/src/validate/forge.rs b/apps/labrinth/src/validate/forge.rs new file mode 100644 index 000000000..503b852b1 --- /dev/null +++ b/apps/labrinth/src/validate/forge.rs @@ -0,0 +1,81 @@ +use crate::validate::{ + filter_out_packs, SupportedGameVersions, ValidationError, ValidationResult, +}; +use chrono::DateTime; +use std::io::Cursor; +use zip::ZipArchive; + +pub struct ForgeValidator; + +impl super::Validator for ForgeValidator { + fn get_file_extensions(&self) -> &[&str] { + &["jar", "zip"] + } + + fn get_supported_loaders(&self) -> &[&str] { + &["forge"] + } + + fn get_supported_game_versions(&self) -> SupportedGameVersions { + // Time since release of 1.13, the first forge version which uses the new TOML system + SupportedGameVersions::PastDate( + DateTime::from_timestamp(1540122067, 0).unwrap(), + ) + } + + fn validate( + &self, + archive: &mut ZipArchive>, + ) -> Result { + if archive.by_name("META-INF/mods.toml").is_err() + && archive.by_name("META-INF/MANIFEST.MF").is_err() + && !archive.file_names().any(|x| x.ends_with(".class")) + { + return Ok(ValidationResult::Warning( + "No mods.toml or valid class files present for Forge file.", + )); + } + + filter_out_packs(archive)?; + + Ok(ValidationResult::Pass) + } +} + +pub struct LegacyForgeValidator; + +impl super::Validator for LegacyForgeValidator { + fn get_file_extensions(&self) -> &[&str] { + &["jar", "zip"] + } + + fn get_supported_loaders(&self) -> &[&str] { + &["forge"] + } + + fn get_supported_game_versions(&self) -> SupportedGameVersions { + // Times between versions 1.5.2 to 1.12.2, which all use the legacy way of defining mods + SupportedGameVersions::Range( + DateTime::from_timestamp(0, 0).unwrap(), + DateTime::from_timestamp(1540122066, 0).unwrap(), + ) + } + + fn validate( + &self, + archive: &mut ZipArchive>, + ) -> Result { + if archive.by_name("mcmod.info").is_err() + && archive.by_name("META-INF/MANIFEST.MF").is_err() + && !archive.file_names().any(|x| x.ends_with(".class")) + { + return Ok(ValidationResult::Warning( + "Forge mod file does not contain mcmod.info or valid class files!", + )); + }; + + filter_out_packs(archive)?; + + Ok(ValidationResult::Pass) + } +} diff --git a/apps/labrinth/src/validate/liteloader.rs b/apps/labrinth/src/validate/liteloader.rs new file mode 100644 index 000000000..f1a202c27 --- /dev/null +++ b/apps/labrinth/src/validate/liteloader.rs @@ -0,0 +1,36 @@ +use crate::validate::{ + filter_out_packs, SupportedGameVersions, ValidationError, ValidationResult, +}; +use std::io::Cursor; +use zip::ZipArchive; + +pub struct LiteLoaderValidator; + +impl super::Validator for LiteLoaderValidator { + fn get_file_extensions(&self) -> &[&str] { + &["litemod", "jar"] + } + + fn get_supported_loaders(&self) -> &[&str] { + &["liteloader"] + } + + fn get_supported_game_versions(&self) -> SupportedGameVersions { + SupportedGameVersions::All + } + + fn validate( + &self, + archive: &mut ZipArchive>, + ) -> Result { + if archive.by_name("litemod.json").is_err() { + return Ok(ValidationResult::Warning( + "No litemod.json present for LiteLoader file.", + )); + } + + filter_out_packs(archive)?; + + Ok(ValidationResult::Pass) + } +} diff --git a/apps/labrinth/src/validate/mod.rs b/apps/labrinth/src/validate/mod.rs new file mode 100644 index 000000000..7f699e940 --- /dev/null +++ b/apps/labrinth/src/validate/mod.rs @@ -0,0 +1,276 @@ +use crate::database::models::legacy_loader_fields::MinecraftGameVersion; +use crate::database::models::loader_fields::VersionField; +use crate::database::models::DatabaseError; +use crate::database::redis::RedisPool; +use crate::models::pack::PackFormat; +use crate::models::projects::{FileType, Loader}; +use crate::validate::datapack::DataPackValidator; +use crate::validate::fabric::FabricValidator; +use crate::validate::forge::{ForgeValidator, LegacyForgeValidator}; +use crate::validate::liteloader::LiteLoaderValidator; +use crate::validate::modpack::ModpackValidator; +use crate::validate::neoforge::NeoForgeValidator; +use crate::validate::plugin::*; +use crate::validate::quilt::QuiltValidator; +use crate::validate::resourcepack::{PackValidator, TexturePackValidator}; +use crate::validate::rift::RiftValidator; +use crate::validate::shader::{ + CanvasShaderValidator, CoreShaderValidator, ShaderValidator, +}; +use chrono::{DateTime, Utc}; +use std::io::Cursor; +use thiserror::Error; +use zip::ZipArchive; + +mod datapack; +mod fabric; +mod forge; +mod liteloader; +mod modpack; +mod neoforge; +pub mod plugin; +mod quilt; +mod resourcepack; +mod rift; +mod shader; + +#[derive(Error, Debug)] +pub enum ValidationError { + #[error("Unable to read Zip Archive: {0}")] + Zip(#[from] zip::result::ZipError), + #[error("IO Error: {0}")] + Io(#[from] std::io::Error), + #[error("Error while validating JSON for uploaded file: {0}")] + SerDe(#[from] serde_json::Error), + #[error("Invalid Input: {0}")] + InvalidInput(std::borrow::Cow<'static, str>), + #[error("Error while managing threads")] + Blocking(#[from] actix_web::error::BlockingError), + #[error("Error while querying database")] + Database(#[from] DatabaseError), +} + +#[derive(Eq, PartialEq, Debug)] +pub enum ValidationResult { + /// File should be marked as primary with pack file data + PassWithPackDataAndFiles { + format: PackFormat, + files: Vec, + }, + /// File should be marked as primary + Pass, + /// File should not be marked primary, the reason for which is inside the String + Warning(&'static str), +} + +impl ValidationResult { + pub fn is_passed(&self) -> bool { + match self { + ValidationResult::PassWithPackDataAndFiles { .. } => true, + ValidationResult::Pass => true, + ValidationResult::Warning(_) => false, + } + } +} + +pub enum SupportedGameVersions { + All, + PastDate(DateTime), + Range(DateTime, DateTime), + #[allow(dead_code)] + Custom(Vec), +} + +pub trait Validator: Sync { + fn get_file_extensions(&self) -> &[&str]; + fn get_supported_loaders(&self) -> &[&str]; + fn get_supported_game_versions(&self) -> SupportedGameVersions; + fn validate( + &self, + archive: &mut ZipArchive>, + ) -> Result; +} + +static ALWAYS_ALLOWED_EXT: &[&str] = &["zip", "txt"]; + +static VALIDATORS: &[&dyn Validator] = &[ + &ModpackValidator, + &FabricValidator, + &ForgeValidator, + &LegacyForgeValidator, + &QuiltValidator, + &LiteLoaderValidator, + &PackValidator, + &TexturePackValidator, + &PluginYmlValidator, + &BungeeCordValidator, + &VelocityValidator, + &SpongeValidator, + &CanvasShaderValidator, + &ShaderValidator, + &CoreShaderValidator, + &DataPackValidator, + &RiftValidator, + &NeoForgeValidator, +]; + +/// The return value is whether this file should be marked as primary or not, based on the analysis of the file +#[allow(clippy::too_many_arguments)] +pub async fn validate_file( + data: bytes::Bytes, + file_extension: String, + loaders: Vec, + file_type: Option, + version_fields: Vec, + transaction: &mut sqlx::Transaction<'_, sqlx::Postgres>, + redis: &RedisPool, +) -> Result { + let game_versions = version_fields + .into_iter() + .find_map(|v| MinecraftGameVersion::try_from_version_field(&v).ok()) + .unwrap_or_default(); + let all_game_versions = + MinecraftGameVersion::list(None, None, &mut *transaction, redis) + .await?; + + validate_minecraft_file( + data, + file_extension, + loaders, + game_versions, + all_game_versions, + file_type, + ) + .await +} + +async fn validate_minecraft_file( + data: bytes::Bytes, + file_extension: String, + loaders: Vec, + game_versions: Vec, + all_game_versions: Vec, + file_type: Option, +) -> Result { + actix_web::web::block(move || { + let reader = Cursor::new(data); + let mut zip = ZipArchive::new(reader)?; + + if let Some(file_type) = file_type { + match file_type { + FileType::RequiredResourcePack | FileType::OptionalResourcePack => { + return PackValidator.validate(&mut zip); + } + FileType::Unknown => {} + } + } + + let mut visited = false; + let mut saved_result = None; + for validator in VALIDATORS { + if loaders + .iter() + .any(|x| validator.get_supported_loaders().contains(&&*x.0)) + && game_version_supported( + &game_versions, + &all_game_versions, + validator.get_supported_game_versions(), + ) + { + if validator.get_file_extensions().contains(&&*file_extension) { + let result = validator.validate(&mut zip)?; + match result { + ValidationResult::PassWithPackDataAndFiles { .. } => { + saved_result = Some(result); + } + ValidationResult::Pass => { + if saved_result.is_none() { + saved_result = Some(result); + } + } + ValidationResult::Warning(_) => { + return Ok(result); + } + } + } else { + visited = true; + } + } + } + + if let Some(result) = saved_result { + return Ok(result); + } + + if visited { + if ALWAYS_ALLOWED_EXT.contains(&&*file_extension) { + Ok(ValidationResult::Warning( + "File extension is invalid for input file", + )) + } else { + Err(ValidationError::InvalidInput( + format!("File extension {file_extension} is invalid for input file").into(), + )) + } + } else { + Ok(ValidationResult::Pass) + } + }) + .await? +} + +// Write tests for this +fn game_version_supported( + game_versions: &[MinecraftGameVersion], + all_game_versions: &[MinecraftGameVersion], + supported_game_versions: SupportedGameVersions, +) -> bool { + match supported_game_versions { + SupportedGameVersions::All => true, + SupportedGameVersions::PastDate(date) => { + game_versions.iter().any(|x| { + all_game_versions + .iter() + .find(|y| y.version == x.version) + .map(|x| x.created > date) + .unwrap_or(false) + }) + } + SupportedGameVersions::Range(before, after) => { + game_versions.iter().any(|x| { + all_game_versions + .iter() + .find(|y| y.version == x.version) + .map(|x| x.created > before && x.created < after) + .unwrap_or(false) + }) + } + SupportedGameVersions::Custom(versions) => { + let version_ids = + versions.iter().map(|gv| gv.id).collect::>(); + let game_version_ids: Vec<_> = + game_versions.iter().map(|gv| gv.id).collect::>(); + version_ids.iter().any(|x| game_version_ids.contains(x)) + } + } +} + +pub fn filter_out_packs( + archive: &mut ZipArchive>, +) -> Result { + if (archive.by_name("modlist.html").is_ok() + && archive.by_name("manifest.json").is_ok()) + || archive + .file_names() + .any(|x| x.starts_with("mods/") && x.ends_with(".jar")) + || archive + .file_names() + .any(|x| x.starts_with("override/mods/") && x.ends_with(".jar")) + { + return Ok(ValidationResult::Warning( + "Invalid modpack file. You must upload a valid .MRPACK file.", + )); + } + + Ok(ValidationResult::Pass) +} diff --git a/apps/labrinth/src/validate/modpack.rs b/apps/labrinth/src/validate/modpack.rs new file mode 100644 index 000000000..7cc9733fb --- /dev/null +++ b/apps/labrinth/src/validate/modpack.rs @@ -0,0 +1,116 @@ +use crate::models::pack::{PackFileHash, PackFormat}; +use crate::util::validate::validation_errors_to_string; +use crate::validate::{ + SupportedGameVersions, ValidationError, ValidationResult, +}; +use std::io::{Cursor, Read}; +use std::path::Component; +use validator::Validate; +use zip::ZipArchive; + +pub struct ModpackValidator; + +impl super::Validator for ModpackValidator { + fn get_file_extensions(&self) -> &[&str] { + &["mrpack"] + } + + fn get_supported_loaders(&self) -> &[&str] { + &["mrpack"] + } + + fn get_supported_game_versions(&self) -> SupportedGameVersions { + SupportedGameVersions::All + } + + fn validate( + &self, + archive: &mut ZipArchive>, + ) -> Result { + let pack: PackFormat = { + let mut file = + if let Ok(file) = archive.by_name("modrinth.index.json") { + file + } else { + return Ok(ValidationResult::Warning( + "Pack manifest is missing.", + )); + }; + + let mut contents = String::new(); + file.read_to_string(&mut contents)?; + + serde_json::from_str(&contents)? + }; + + pack.validate().map_err(|err| { + ValidationError::InvalidInput( + validation_errors_to_string(err, None).into(), + ) + })?; + + if pack.game != "minecraft" { + return Err(ValidationError::InvalidInput( + format!("Game {0} does not exist!", pack.game).into(), + )); + } + + if pack.files.is_empty() + && !archive.file_names().any(|x| x.starts_with("overrides/")) + { + return Err(ValidationError::InvalidInput( + "Pack has no files!".into(), + )); + } + + for file in &pack.files { + if !file.hashes.contains_key(&PackFileHash::Sha1) { + return Err(ValidationError::InvalidInput( + "All pack files must provide a SHA1 hash!".into(), + )); + } + + if !file.hashes.contains_key(&PackFileHash::Sha512) { + return Err(ValidationError::InvalidInput( + "All pack files must provide a SHA512 hash!".into(), + )); + } + + let path = std::path::Path::new(&file.path) + .components() + .next() + .ok_or_else(|| { + ValidationError::InvalidInput( + "Invalid pack file path!".into(), + ) + })?; + + match path { + Component::CurDir | Component::Normal(_) => {} + _ => { + return Err(ValidationError::InvalidInput( + "Invalid pack file path!".into(), + )) + } + }; + } + + Ok(ValidationResult::PassWithPackDataAndFiles { + format: pack, + files: archive + .file_names() + .filter(|x| { + (x.ends_with("jar") || x.ends_with("zip")) + && (x.starts_with("overrides/mods") + || x.starts_with("client-overrides/mods") + || x.starts_with("server-overrides/mods") + || x.starts_with("overrides/resourcepacks") + || x.starts_with("server-overrides/resourcepacks") + || x.starts_with("overrides/shaderpacks") + || x.starts_with("client-overrides/shaderpacks")) + }) + .flat_map(|x| x.rsplit('/').next().map(|x| x.to_string())) + .collect::>(), + }) + } +} diff --git a/apps/labrinth/src/validate/neoforge.rs b/apps/labrinth/src/validate/neoforge.rs new file mode 100644 index 000000000..59670e8b7 --- /dev/null +++ b/apps/labrinth/src/validate/neoforge.rs @@ -0,0 +1,40 @@ +use crate::validate::{ + filter_out_packs, SupportedGameVersions, ValidationError, ValidationResult, +}; +use std::io::Cursor; +use zip::ZipArchive; + +pub struct NeoForgeValidator; + +impl super::Validator for NeoForgeValidator { + fn get_file_extensions(&self) -> &[&str] { + &["jar", "zip"] + } + + fn get_supported_loaders(&self) -> &[&str] { + &["neoforge"] + } + + fn get_supported_game_versions(&self) -> SupportedGameVersions { + SupportedGameVersions::All + } + + fn validate( + &self, + archive: &mut ZipArchive>, + ) -> Result { + if archive.by_name("META-INF/mods.toml").is_err() + && archive.by_name("META-INF/neoforge.mods.toml").is_err() + && archive.by_name("META-INF/MANIFEST.MF").is_err() + && !archive.file_names().any(|x| x.ends_with(".class")) + { + return Ok(ValidationResult::Warning( + "No neoforge.mods.toml, mods.toml, or valid class files present for NeoForge file.", + )); + } + + filter_out_packs(archive)?; + + Ok(ValidationResult::Pass) + } +} diff --git a/apps/labrinth/src/validate/plugin.rs b/apps/labrinth/src/validate/plugin.rs new file mode 100644 index 000000000..4f637c66f --- /dev/null +++ b/apps/labrinth/src/validate/plugin.rs @@ -0,0 +1,131 @@ +use crate::validate::{ + SupportedGameVersions, ValidationError, ValidationResult, +}; +use std::io::Cursor; +use zip::ZipArchive; + +pub struct PluginYmlValidator; + +impl super::Validator for PluginYmlValidator { + fn get_file_extensions(&self) -> &[&str] { + &["zip", "jar"] + } + + fn get_supported_loaders(&self) -> &[&str] { + &["bukkit", "spigot", "paper", "purpur", "folia"] + } + + fn get_supported_game_versions(&self) -> SupportedGameVersions { + SupportedGameVersions::All + } + + fn validate( + &self, + archive: &mut ZipArchive>, + ) -> Result { + if !archive + .file_names() + .any(|name| name == "plugin.yml" || name == "paper-plugin.yml") + { + return Ok(ValidationResult::Warning( + "No plugin.yml or paper-plugin.yml present for plugin file.", + )); + }; + + Ok(ValidationResult::Pass) + } +} + +pub struct BungeeCordValidator; + +impl super::Validator for BungeeCordValidator { + fn get_file_extensions(&self) -> &[&str] { + &["zip", "jar"] + } + + fn get_supported_loaders(&self) -> &[&str] { + &["bungeecord", "waterfall"] + } + + fn get_supported_game_versions(&self) -> SupportedGameVersions { + SupportedGameVersions::All + } + + fn validate( + &self, + archive: &mut ZipArchive>, + ) -> Result { + if !archive + .file_names() + .any(|name| name == "plugin.yml" || name == "bungee.yml") + { + return Ok(ValidationResult::Warning( + "No plugin.yml or bungee.yml present for plugin file.", + )); + }; + + Ok(ValidationResult::Pass) + } +} + +pub struct VelocityValidator; + +impl super::Validator for VelocityValidator { + fn get_file_extensions(&self) -> &[&str] { + &["zip", "jar"] + } + + fn get_supported_loaders(&self) -> &[&str] { + &["velocity"] + } + + fn get_supported_game_versions(&self) -> SupportedGameVersions { + SupportedGameVersions::All + } + + fn validate( + &self, + archive: &mut ZipArchive>, + ) -> Result { + if archive.by_name("velocity-plugin.json").is_err() { + return Ok(ValidationResult::Warning( + "No velocity-plugin.json present for plugin file.", + )); + } + + Ok(ValidationResult::Pass) + } +} + +pub struct SpongeValidator; + +impl super::Validator for SpongeValidator { + fn get_file_extensions(&self) -> &[&str] { + &["zip", "jar"] + } + + fn get_supported_loaders(&self) -> &[&str] { + &["sponge"] + } + + fn get_supported_game_versions(&self) -> SupportedGameVersions { + SupportedGameVersions::All + } + + fn validate( + &self, + archive: &mut ZipArchive>, + ) -> Result { + if !archive.file_names().any(|name| { + name == "sponge_plugins.json" + || name == "mcmod.info" + || name == "META-INF/sponge_plugins.json" + }) { + return Ok(ValidationResult::Warning( + "No sponge_plugins.json or mcmod.info present for Sponge plugin.", + )); + }; + + Ok(ValidationResult::Pass) + } +} diff --git a/apps/labrinth/src/validate/quilt.rs b/apps/labrinth/src/validate/quilt.rs new file mode 100644 index 000000000..0c3f50bcf --- /dev/null +++ b/apps/labrinth/src/validate/quilt.rs @@ -0,0 +1,41 @@ +use crate::validate::{ + filter_out_packs, SupportedGameVersions, ValidationError, ValidationResult, +}; +use chrono::DateTime; +use std::io::Cursor; +use zip::ZipArchive; + +pub struct QuiltValidator; + +impl super::Validator for QuiltValidator { + fn get_file_extensions(&self) -> &[&str] { + &["jar", "zip"] + } + + fn get_supported_loaders(&self) -> &[&str] { + &["quilt"] + } + + fn get_supported_game_versions(&self) -> SupportedGameVersions { + SupportedGameVersions::PastDate( + DateTime::from_timestamp(1646070100, 0).unwrap(), + ) + } + + fn validate( + &self, + archive: &mut ZipArchive>, + ) -> Result { + if archive.by_name("quilt.mod.json").is_err() + && archive.by_name("fabric.mod.json").is_err() + { + return Ok(ValidationResult::Warning( + "No quilt.mod.json present for Quilt file.", + )); + } + + filter_out_packs(archive)?; + + Ok(ValidationResult::Pass) + } +} diff --git a/apps/labrinth/src/validate/resourcepack.rs b/apps/labrinth/src/validate/resourcepack.rs new file mode 100644 index 000000000..687c5b4e8 --- /dev/null +++ b/apps/labrinth/src/validate/resourcepack.rs @@ -0,0 +1,71 @@ +use crate::validate::{ + SupportedGameVersions, ValidationError, ValidationResult, +}; +use chrono::DateTime; +use std::io::Cursor; +use zip::ZipArchive; + +pub struct PackValidator; + +impl super::Validator for PackValidator { + fn get_file_extensions(&self) -> &[&str] { + &["zip"] + } + + fn get_supported_loaders(&self) -> &[&str] { + &["minecraft"] + } + + fn get_supported_game_versions(&self) -> SupportedGameVersions { + // Time since release of 13w24a which replaced texture packs with resource packs + SupportedGameVersions::PastDate( + DateTime::from_timestamp(1371137542, 0).unwrap(), + ) + } + + fn validate( + &self, + archive: &mut ZipArchive>, + ) -> Result { + if archive.by_name("pack.mcmeta").is_err() { + return Ok(ValidationResult::Warning( + "No pack.mcmeta present for pack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!", + )); + } + + Ok(ValidationResult::Pass) + } +} + +pub struct TexturePackValidator; + +impl super::Validator for TexturePackValidator { + fn get_file_extensions(&self) -> &[&str] { + &["zip"] + } + + fn get_supported_loaders(&self) -> &[&str] { + &["minecraft"] + } + + fn get_supported_game_versions(&self) -> SupportedGameVersions { + // a1.2.2a to 13w23b + SupportedGameVersions::Range( + DateTime::from_timestamp(1289339999, 0).unwrap(), + DateTime::from_timestamp(1370651522, 0).unwrap(), + ) + } + + fn validate( + &self, + archive: &mut ZipArchive>, + ) -> Result { + if archive.by_name("pack.txt").is_err() { + return Ok(ValidationResult::Warning( + "No pack.txt present for pack file.", + )); + } + + Ok(ValidationResult::Pass) + } +} diff --git a/apps/labrinth/src/validate/rift.rs b/apps/labrinth/src/validate/rift.rs new file mode 100644 index 000000000..b24ff5007 --- /dev/null +++ b/apps/labrinth/src/validate/rift.rs @@ -0,0 +1,36 @@ +use crate::validate::{ + filter_out_packs, SupportedGameVersions, ValidationError, ValidationResult, +}; +use std::io::Cursor; +use zip::ZipArchive; + +pub struct RiftValidator; + +impl super::Validator for RiftValidator { + fn get_file_extensions(&self) -> &[&str] { + &["jar"] + } + + fn get_supported_loaders(&self) -> &[&str] { + &["rift"] + } + + fn get_supported_game_versions(&self) -> SupportedGameVersions { + SupportedGameVersions::All + } + + fn validate( + &self, + archive: &mut ZipArchive>, + ) -> Result { + if archive.by_name("riftmod.json").is_err() { + return Ok(ValidationResult::Warning( + "No riftmod.json present for Rift file.", + )); + } + + filter_out_packs(archive)?; + + Ok(ValidationResult::Pass) + } +} diff --git a/apps/labrinth/src/validate/shader.rs b/apps/labrinth/src/validate/shader.rs new file mode 100644 index 000000000..6a83a8195 --- /dev/null +++ b/apps/labrinth/src/validate/shader.rs @@ -0,0 +1,107 @@ +use crate::validate::{ + SupportedGameVersions, ValidationError, ValidationResult, +}; +use std::io::Cursor; +use zip::ZipArchive; + +pub struct ShaderValidator; + +impl super::Validator for ShaderValidator { + fn get_file_extensions(&self) -> &[&str] { + &["zip"] + } + + fn get_supported_loaders(&self) -> &[&str] { + &["optifine", "iris"] + } + + fn get_supported_game_versions(&self) -> SupportedGameVersions { + SupportedGameVersions::All + } + + fn validate( + &self, + archive: &mut ZipArchive>, + ) -> Result { + if !archive.file_names().any(|x| x.starts_with("shaders/")) { + return Ok(ValidationResult::Warning( + "No shaders folder present for OptiFine/Iris shader.", + )); + } + + Ok(ValidationResult::Pass) + } +} + +pub struct CanvasShaderValidator; + +impl super::Validator for CanvasShaderValidator { + fn get_file_extensions(&self) -> &[&str] { + &["zip"] + } + + fn get_supported_loaders(&self) -> &[&str] { + &["canvas"] + } + + fn get_supported_game_versions(&self) -> SupportedGameVersions { + SupportedGameVersions::All + } + + fn validate( + &self, + archive: &mut ZipArchive>, + ) -> Result { + if archive.by_name("pack.mcmeta").is_err() { + return Ok(ValidationResult::Warning( + "No pack.mcmeta present for pack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!", + )); + }; + + if !archive.file_names().any(|x| x.contains("/pipelines/")) { + return Ok(ValidationResult::Warning( + "No pipeline shaders folder present for canvas shaders.", + )); + } + + Ok(ValidationResult::Pass) + } +} + +pub struct CoreShaderValidator; + +impl super::Validator for CoreShaderValidator { + fn get_file_extensions(&self) -> &[&str] { + &["zip"] + } + + fn get_supported_loaders(&self) -> &[&str] { + &["vanilla"] + } + + fn get_supported_game_versions(&self) -> SupportedGameVersions { + SupportedGameVersions::All + } + + fn validate( + &self, + archive: &mut ZipArchive>, + ) -> Result { + if archive.by_name("pack.mcmeta").is_err() { + return Ok(ValidationResult::Warning( + "No pack.mcmeta present for pack file. Tip: Make sure pack.mcmeta is in the root directory of your pack!", + )); + }; + + if !archive + .file_names() + .any(|x| x.starts_with("assets/minecraft/shaders/")) + { + return Ok(ValidationResult::Warning( + "No shaders folder present for vanilla shaders.", + )); + } + + Ok(ValidationResult::Pass) + } +} diff --git a/apps/labrinth/tests/analytics.rs b/apps/labrinth/tests/analytics.rs new file mode 100644 index 000000000..96e2a440a --- /dev/null +++ b/apps/labrinth/tests/analytics.rs @@ -0,0 +1,247 @@ +use chrono::{DateTime, Duration, Utc}; +use common::permissions::PermissionsTest; +use common::permissions::PermissionsTestContext; +use common::{ + api_v3::ApiV3, + database::*, + environment::{with_test_environment, TestEnvironment}, +}; +use itertools::Itertools; +use labrinth::models::ids::base62_impl::parse_base62; +use labrinth::models::teams::ProjectPermissions; +use labrinth::queue::payouts; +use rust_decimal::{prelude::ToPrimitive, Decimal}; + +mod common; + +#[actix_rt::test] +pub async fn analytics_revenue() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + + let alpha_project_id = + test_env.dummy.project_alpha.project_id.clone(); + + let pool = test_env.db.pool.clone(); + + // Generate sample revenue data- directly insert into sql + let ( + mut insert_user_ids, + mut insert_project_ids, + mut insert_payouts, + mut insert_starts, + mut insert_availables, + ) = (Vec::new(), Vec::new(), Vec::new(), Vec::new(), Vec::new()); + + // Note: these go from most recent to least recent + let money_time_pairs: [(f64, DateTime); 10] = [ + (50.0, Utc::now() - Duration::minutes(5)), + (50.1, Utc::now() - Duration::minutes(10)), + (101.0, Utc::now() - Duration::days(1)), + (200.0, Utc::now() - Duration::days(2)), + (311.0, Utc::now() - Duration::days(3)), + (400.0, Utc::now() - Duration::days(4)), + (526.0, Utc::now() - Duration::days(5)), + (633.0, Utc::now() - Duration::days(6)), + (800.0, Utc::now() - Duration::days(14)), + (800.0, Utc::now() - Duration::days(800)), + ]; + + let project_id = parse_base62(&alpha_project_id).unwrap() as i64; + for (money, time) in money_time_pairs.iter() { + insert_user_ids.push(USER_USER_ID_PARSED); + insert_project_ids.push(project_id); + insert_payouts.push(Decimal::from_f64_retain(*money).unwrap()); + insert_starts.push(*time); + insert_availables.push(*time); + } + + let mut transaction = pool.begin().await.unwrap(); + payouts::insert_payouts( + insert_user_ids, + insert_project_ids, + insert_payouts, + insert_starts, + insert_availables, + &mut transaction, + ) + .await + .unwrap(); + transaction.commit().await.unwrap(); + + let day = 86400; + + // Test analytics endpoint with default values + // - all time points in the last 2 weeks + // - 1 day resolution + let analytics = api + .get_analytics_revenue_deserialized( + vec![&alpha_project_id], + false, + None, + None, + None, + USER_USER_PAT, + ) + .await; + assert_eq!(analytics.len(), 1); // 1 project + let project_analytics = analytics.get(&alpha_project_id).unwrap(); + assert_eq!(project_analytics.len(), 8); // 1 days cut off, and 2 points take place on the same day. note that the day exactly 14 days ago is included + // sorted_by_key, values in the order of smallest to largest key + let (sorted_keys, sorted_by_key): (Vec, Vec) = + project_analytics + .iter() + .sorted_by_key(|(k, _)| *k) + .rev() + .unzip(); + assert_eq!( + vec![100.1, 101.0, 200.0, 311.0, 400.0, 526.0, 633.0, 800.0], + to_f64_vec_rounded_up(sorted_by_key) + ); + // Ensure that the keys are in multiples of 1 day + for k in sorted_keys { + assert_eq!(k % day, 0); + } + + // Test analytics with last 900 days to include all data + // keep resolution at default + let analytics = api + .get_analytics_revenue_deserialized( + vec![&alpha_project_id], + false, + Some(Utc::now() - Duration::days(801)), + None, + None, + USER_USER_PAT, + ) + .await; + let project_analytics = analytics.get(&alpha_project_id).unwrap(); + assert_eq!(project_analytics.len(), 9); // and 2 points take place on the same day + let (sorted_keys, sorted_by_key): (Vec, Vec) = + project_analytics + .iter() + .sorted_by_key(|(k, _)| *k) + .rev() + .unzip(); + assert_eq!( + vec![ + 100.1, 101.0, 200.0, 311.0, 400.0, 526.0, 633.0, 800.0, + 800.0 + ], + to_f64_vec_rounded_up(sorted_by_key) + ); + for k in sorted_keys { + assert_eq!(k % day, 0); + } + }, + ) + .await; +} + +fn to_f64_rounded_up(d: Decimal) -> f64 { + d.round_dp_with_strategy( + 1, + rust_decimal::RoundingStrategy::MidpointAwayFromZero, + ) + .to_f64() + .unwrap() +} + +fn to_f64_vec_rounded_up(d: Vec) -> Vec { + d.into_iter().map(to_f64_rounded_up).collect_vec() +} + +#[actix_rt::test] +pub async fn permissions_analytics_revenue() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let alpha_project_id = + test_env.dummy.project_alpha.project_id.clone(); + let alpha_version_id = + test_env.dummy.project_alpha.version_id.clone(); + let alpha_team_id = test_env.dummy.project_alpha.team_id.clone(); + + let api = &test_env.api; + + let view_analytics = ProjectPermissions::VIEW_ANALYTICS; + + // first, do check with a project + let req_gen = |ctx: PermissionsTestContext| async move { + let project_id = ctx.project_id.unwrap(); + let ids_or_slugs = vec![project_id.as_str()]; + api.get_analytics_revenue( + ids_or_slugs, + false, + None, + None, + Some(5), + ctx.test_pat.as_deref(), + ) + .await + }; + + PermissionsTest::new(&test_env) + .with_failure_codes(vec![200, 401]) + .with_200_json_checks( + // On failure, should have 0 projects returned + |value: &serde_json::Value| { + let value = value.as_object().unwrap(); + assert_eq!(value.len(), 0); + }, + // On success, should have 1 project returned + |value: &serde_json::Value| { + let value = value.as_object().unwrap(); + assert_eq!(value.len(), 1); + }, + ) + .simple_project_permissions_test(view_analytics, req_gen) + .await + .unwrap(); + + // Now with a version + // Need to use alpha + let req_gen = |ctx: PermissionsTestContext| { + let alpha_version_id = alpha_version_id.clone(); + async move { + let ids_or_slugs = vec![alpha_version_id.as_str()]; + api.get_analytics_revenue( + ids_or_slugs, + true, + None, + None, + Some(5), + ctx.test_pat.as_deref(), + ) + .await + } + }; + + PermissionsTest::new(&test_env) + .with_failure_codes(vec![200, 401]) + .with_existing_project(&alpha_project_id, &alpha_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .with_200_json_checks( + // On failure, should have 0 versions returned + |value: &serde_json::Value| { + let value = value.as_object().unwrap(); + assert_eq!(value.len(), 0); + }, + // On success, should have 1 versions returned + |value: &serde_json::Value| { + let value = value.as_object().unwrap(); + assert_eq!(value.len(), 0); + }, + ) + .simple_project_permissions_test(view_analytics, req_gen) + .await + .unwrap(); + + // Cleanup test db + test_env.cleanup().await; + }, + ) + .await; +} diff --git a/apps/labrinth/tests/common/api_common/generic.rs b/apps/labrinth/tests/common/api_common/generic.rs new file mode 100644 index 000000000..f07a153e8 --- /dev/null +++ b/apps/labrinth/tests/common/api_common/generic.rs @@ -0,0 +1,171 @@ +use std::collections::HashMap; + +use actix_web::dev::ServiceResponse; +use async_trait::async_trait; +use labrinth::models::{ + projects::{ProjectId, VersionType}, + teams::{OrganizationPermissions, ProjectPermissions}, +}; + +use crate::common::{api_v2::ApiV2, api_v3::ApiV3, dummy_data::TestFile}; + +use super::{ + models::{CommonProject, CommonVersion}, + request_data::{ImageData, ProjectCreationRequestData}, + Api, ApiProject, ApiTags, ApiTeams, ApiUser, ApiVersion, +}; + +#[derive(Clone)] +pub enum GenericApi { + V2(ApiV2), + V3(ApiV3), +} + +macro_rules! delegate_api_variant { + ( + $(#[$meta:meta])* + impl $impl_name:ident for $struct_name:ident { + $( + [$method_name:ident, $ret:ty, $($param_name:ident: $param_type:ty),*] + ),* $(,)? + } + + ) => { + $(#[$meta])* + impl $impl_name for $struct_name { + $( + async fn $method_name(&self, $($param_name: $param_type),*) -> $ret { + match self { + $struct_name::V2(api) => api.$method_name($($param_name),*).await, + $struct_name::V3(api) => api.$method_name($($param_name),*).await, + } + } + )* + } + }; +} + +#[async_trait(?Send)] +impl Api for GenericApi { + async fn call(&self, req: actix_http::Request) -> ServiceResponse { + match self { + Self::V2(api) => api.call(req).await, + Self::V3(api) => api.call(req).await, + } + } + + async fn reset_search_index(&self) -> ServiceResponse { + match self { + Self::V2(api) => api.reset_search_index().await, + Self::V3(api) => api.reset_search_index().await, + } + } +} + +delegate_api_variant!( + #[async_trait(?Send)] + impl ApiProject for GenericApi { + [add_public_project, (CommonProject, Vec), slug: &str, version_jar: Option, modify_json: Option, pat: Option<&str>], + [get_public_project_creation_data_json, serde_json::Value, slug: &str, version_jar: Option<&TestFile>], + [create_project, ServiceResponse, creation_data: ProjectCreationRequestData, pat: Option<&str>], + [remove_project, ServiceResponse, project_slug_or_id: &str, pat: Option<&str>], + [get_project, ServiceResponse, id_or_slug: &str, pat: Option<&str>], + [get_project_deserialized_common, CommonProject, id_or_slug: &str, pat: Option<&str>], + [get_projects, ServiceResponse, ids_or_slugs: &[&str], pat: Option<&str>], + [get_project_dependencies, ServiceResponse, id_or_slug: &str, pat: Option<&str>], + [get_user_projects, ServiceResponse, user_id_or_username: &str, pat: Option<&str>], + [get_user_projects_deserialized_common, Vec, user_id_or_username: &str, pat: Option<&str>], + [edit_project, ServiceResponse, id_or_slug: &str, patch: serde_json::Value, pat: Option<&str>], + [edit_project_bulk, ServiceResponse, ids_or_slugs: &[&str], patch: serde_json::Value, pat: Option<&str>], + [edit_project_icon, ServiceResponse, id_or_slug: &str, icon: Option, pat: Option<&str>], + [add_gallery_item, ServiceResponse, id_or_slug: &str, image: ImageData, featured: bool, title: Option, description: Option, ordering: Option, pat: Option<&str>], + [remove_gallery_item, ServiceResponse, id_or_slug: &str, image_url: &str, pat: Option<&str>], + [edit_gallery_item, ServiceResponse, id_or_slug: &str, image_url: &str, patch: HashMap, pat: Option<&str>], + [create_report, ServiceResponse, report_type: &str, id: &str, item_type: crate::common::api_common::models::CommonItemType, body: &str, pat: Option<&str>], + [get_report, ServiceResponse, id: &str, pat: Option<&str>], + [get_reports, ServiceResponse, ids: &[&str], pat: Option<&str>], + [get_user_reports, ServiceResponse, pat: Option<&str>], + [edit_report, ServiceResponse, id: &str, patch: serde_json::Value, pat: Option<&str>], + [delete_report, ServiceResponse, id: &str, pat: Option<&str>], + [get_thread, ServiceResponse, id: &str, pat: Option<&str>], + [get_threads, ServiceResponse, ids: &[&str], pat: Option<&str>], + [write_to_thread, ServiceResponse, id: &str, r#type : &str, message: &str, pat: Option<&str>], + [get_moderation_inbox, ServiceResponse, pat: Option<&str>], + [read_thread, ServiceResponse, id: &str, pat: Option<&str>], + [delete_thread_message, ServiceResponse, id: &str, pat: Option<&str>], + } +); + +delegate_api_variant!( + #[async_trait(?Send)] + impl ApiTags for GenericApi { + [get_loaders, ServiceResponse,], + [get_loaders_deserialized_common, Vec,], + [get_categories, ServiceResponse,], + [get_categories_deserialized_common, Vec,], + } +); + +delegate_api_variant!( + #[async_trait(?Send)] + impl ApiTeams for GenericApi { + [get_team_members, ServiceResponse, team_id: &str, pat: Option<&str>], + [get_team_members_deserialized_common, Vec, team_id: &str, pat: Option<&str>], + [get_teams_members, ServiceResponse, ids: &[&str], pat: Option<&str>], + [get_project_members, ServiceResponse, id_or_slug: &str, pat: Option<&str>], + [get_project_members_deserialized_common, Vec, id_or_slug: &str, pat: Option<&str>], + [get_organization_members, ServiceResponse, id_or_title: &str, pat: Option<&str>], + [get_organization_members_deserialized_common, Vec, id_or_title: &str, pat: Option<&str>], + [join_team, ServiceResponse, team_id: &str, pat: Option<&str>], + [remove_from_team, ServiceResponse, team_id: &str, user_id: &str, pat: Option<&str>], + [edit_team_member, ServiceResponse, team_id: &str, user_id: &str, patch: serde_json::Value, pat: Option<&str>], + [transfer_team_ownership, ServiceResponse, team_id: &str, user_id: &str, pat: Option<&str>], + [get_user_notifications, ServiceResponse, user_id: &str, pat: Option<&str>], + [get_user_notifications_deserialized_common, Vec, user_id: &str, pat: Option<&str>], + [get_notification, ServiceResponse, notification_id: &str, pat: Option<&str>], + [get_notifications, ServiceResponse, ids: &[&str], pat: Option<&str>], + [mark_notification_read, ServiceResponse, notification_id: &str, pat: Option<&str>], + [mark_notifications_read, ServiceResponse, ids: &[&str], pat: Option<&str>], + [add_user_to_team, ServiceResponse, team_id: &str, user_id: &str, project_permissions: Option, organization_permissions: Option, pat: Option<&str>], + [delete_notification, ServiceResponse, notification_id: &str, pat: Option<&str>], + [delete_notifications, ServiceResponse, ids: &[&str], pat: Option<&str>], + } +); + +delegate_api_variant!( + #[async_trait(?Send)] + impl ApiUser for GenericApi { + [get_user, ServiceResponse, id_or_username: &str, pat: Option<&str>], + [get_current_user, ServiceResponse, pat: Option<&str>], + [edit_user, ServiceResponse, id_or_username: &str, patch: serde_json::Value, pat: Option<&str>], + [delete_user, ServiceResponse, id_or_username: &str, pat: Option<&str>], + } +); + +delegate_api_variant!( + #[async_trait(?Send)] + impl ApiVersion for GenericApi { + [add_public_version, ServiceResponse, project_id: ProjectId, version_number: &str, version_jar: TestFile, ordering: Option, modify_json: Option, pat: Option<&str>], + [add_public_version_deserialized_common, CommonVersion, project_id: ProjectId, version_number: &str, version_jar: TestFile, ordering: Option, modify_json: Option, pat: Option<&str>], + [get_version, ServiceResponse, id_or_slug: &str, pat: Option<&str>], + [get_version_deserialized_common, CommonVersion, id_or_slug: &str, pat: Option<&str>], + [get_versions, ServiceResponse, ids_or_slugs: Vec, pat: Option<&str>], + [get_versions_deserialized_common, Vec, ids_or_slugs: Vec, pat: Option<&str>], + [download_version_redirect, ServiceResponse, hash: &str, algorithm: &str, pat: Option<&str>], + [edit_version, ServiceResponse, id_or_slug: &str, patch: serde_json::Value, pat: Option<&str>], + [get_version_from_hash, ServiceResponse, id_or_slug: &str, hash: &str, pat: Option<&str>], + [get_version_from_hash_deserialized_common, CommonVersion, id_or_slug: &str, hash: &str, pat: Option<&str>], + [get_versions_from_hashes, ServiceResponse, hashes: &[&str], algorithm: &str, pat: Option<&str>], + [get_versions_from_hashes_deserialized_common, HashMap, hashes: &[&str], algorithm: &str, pat: Option<&str>], + [get_update_from_hash, ServiceResponse, hash: &str, algorithm: &str, loaders: Option>,game_versions: Option>, version_types: Option>, pat: Option<&str>], + [get_update_from_hash_deserialized_common, CommonVersion, hash: &str, algorithm: &str,loaders: Option>,game_versions: Option>,version_types: Option>, pat: Option<&str>], + [update_files, ServiceResponse, algorithm: &str, hashes: Vec, loaders: Option>, game_versions: Option>, version_types: Option>, pat: Option<&str>], + [update_files_deserialized_common, HashMap, algorithm: &str, hashes: Vec, loaders: Option>, game_versions: Option>, version_types: Option>, pat: Option<&str>], + [get_project_versions, ServiceResponse, project_id_slug: &str, game_versions: Option>,loaders: Option>,featured: Option, version_type: Option, limit: Option, offset: Option,pat: Option<&str>], + [get_project_versions_deserialized_common, Vec, project_id_slug: &str, game_versions: Option>, loaders: Option>,featured: Option,version_type: Option,limit: Option,offset: Option,pat: Option<&str>], + [edit_version_ordering, ServiceResponse, version_id: &str,ordering: Option,pat: Option<&str>], + [upload_file_to_version, ServiceResponse, version_id: &str, file: &TestFile, pat: Option<&str>], + [remove_version, ServiceResponse, version_id: &str, pat: Option<&str>], + [remove_version_file, ServiceResponse, hash: &str, pat: Option<&str>], + } +); diff --git a/apps/labrinth/tests/common/api_common/mod.rs b/apps/labrinth/tests/common/api_common/mod.rs new file mode 100644 index 000000000..aca326b37 --- /dev/null +++ b/apps/labrinth/tests/common/api_common/mod.rs @@ -0,0 +1,491 @@ +use std::collections::HashMap; + +use self::models::{ + CommonCategoryData, CommonItemType, CommonLoaderData, CommonNotification, + CommonProject, CommonTeamMember, CommonVersion, +}; +use self::request_data::{ImageData, ProjectCreationRequestData}; +use actix_web::dev::ServiceResponse; +use async_trait::async_trait; +use labrinth::{ + models::{ + projects::{ProjectId, VersionType}, + teams::{OrganizationPermissions, ProjectPermissions}, + }, + LabrinthConfig, +}; + +use super::dummy_data::TestFile; + +pub mod generic; +pub mod models; +pub mod request_data; +#[async_trait(?Send)] +pub trait ApiBuildable: Api { + async fn build(labrinth_config: LabrinthConfig) -> Self; +} + +#[async_trait(?Send)] +pub trait Api: ApiProject + ApiTags + ApiTeams + ApiUser + ApiVersion { + async fn call(&self, req: actix_http::Request) -> ServiceResponse; + async fn reset_search_index(&self) -> ServiceResponse; +} + +#[async_trait(?Send)] +pub trait ApiProject { + async fn add_public_project( + &self, + slug: &str, + version_jar: Option, + modify_json: Option, + pat: Option<&str>, + ) -> (CommonProject, Vec); + async fn create_project( + &self, + creation_data: ProjectCreationRequestData, + pat: Option<&str>, + ) -> ServiceResponse; + async fn get_public_project_creation_data_json( + &self, + slug: &str, + version_jar: Option<&TestFile>, + ) -> serde_json::Value; + + async fn remove_project( + &self, + id_or_slug: &str, + pat: Option<&str>, + ) -> ServiceResponse; + async fn get_project( + &self, + id_or_slug: &str, + pat: Option<&str>, + ) -> ServiceResponse; + async fn get_project_deserialized_common( + &self, + id_or_slug: &str, + pat: Option<&str>, + ) -> CommonProject; + async fn get_projects( + &self, + ids_or_slugs: &[&str], + pat: Option<&str>, + ) -> ServiceResponse; + async fn get_project_dependencies( + &self, + id_or_slug: &str, + pat: Option<&str>, + ) -> ServiceResponse; + async fn get_user_projects( + &self, + user_id_or_username: &str, + pat: Option<&str>, + ) -> ServiceResponse; + async fn get_user_projects_deserialized_common( + &self, + user_id_or_username: &str, + pat: Option<&str>, + ) -> Vec; + async fn edit_project( + &self, + id_or_slug: &str, + patch: serde_json::Value, + pat: Option<&str>, + ) -> ServiceResponse; + async fn edit_project_bulk( + &self, + ids_or_slugs: &[&str], + patch: serde_json::Value, + pat: Option<&str>, + ) -> ServiceResponse; + async fn edit_project_icon( + &self, + id_or_slug: &str, + icon: Option, + pat: Option<&str>, + ) -> ServiceResponse; + #[allow(clippy::too_many_arguments)] + async fn add_gallery_item( + &self, + id_or_slug: &str, + image: ImageData, + featured: bool, + title: Option, + description: Option, + ordering: Option, + pat: Option<&str>, + ) -> ServiceResponse; + async fn remove_gallery_item( + &self, + id_or_slug: &str, + url: &str, + pat: Option<&str>, + ) -> ServiceResponse; + async fn edit_gallery_item( + &self, + id_or_slug: &str, + url: &str, + patch: HashMap, + pat: Option<&str>, + ) -> ServiceResponse; + async fn create_report( + &self, + report_type: &str, + id: &str, + item_type: CommonItemType, + body: &str, + pat: Option<&str>, + ) -> ServiceResponse; + async fn get_report(&self, id: &str, pat: Option<&str>) -> ServiceResponse; + async fn get_reports( + &self, + ids: &[&str], + pat: Option<&str>, + ) -> ServiceResponse; + async fn get_user_reports(&self, pat: Option<&str>) -> ServiceResponse; + async fn edit_report( + &self, + id: &str, + patch: serde_json::Value, + pat: Option<&str>, + ) -> ServiceResponse; + async fn delete_report( + &self, + id: &str, + pat: Option<&str>, + ) -> ServiceResponse; + async fn get_thread(&self, id: &str, pat: Option<&str>) -> ServiceResponse; + async fn get_threads( + &self, + ids: &[&str], + pat: Option<&str>, + ) -> ServiceResponse; + async fn write_to_thread( + &self, + id: &str, + r#type: &str, + message: &str, + pat: Option<&str>, + ) -> ServiceResponse; + async fn get_moderation_inbox(&self, pat: Option<&str>) -> ServiceResponse; + async fn read_thread(&self, id: &str, pat: Option<&str>) + -> ServiceResponse; + async fn delete_thread_message( + &self, + id: &str, + pat: Option<&str>, + ) -> ServiceResponse; +} + +#[async_trait(?Send)] +pub trait ApiTags { + async fn get_loaders(&self) -> ServiceResponse; + async fn get_loaders_deserialized_common(&self) -> Vec; + async fn get_categories(&self) -> ServiceResponse; + async fn get_categories_deserialized_common( + &self, + ) -> Vec; +} + +#[async_trait(?Send)] +pub trait ApiTeams { + async fn get_team_members( + &self, + team_id: &str, + pat: Option<&str>, + ) -> ServiceResponse; + async fn get_team_members_deserialized_common( + &self, + team_id: &str, + pat: Option<&str>, + ) -> Vec; + async fn get_teams_members( + &self, + team_ids: &[&str], + pat: Option<&str>, + ) -> ServiceResponse; + async fn get_project_members( + &self, + id_or_slug: &str, + pat: Option<&str>, + ) -> ServiceResponse; + async fn get_project_members_deserialized_common( + &self, + id_or_slug: &str, + pat: Option<&str>, + ) -> Vec; + async fn get_organization_members( + &self, + id_or_title: &str, + pat: Option<&str>, + ) -> ServiceResponse; + async fn get_organization_members_deserialized_common( + &self, + id_or_title: &str, + pat: Option<&str>, + ) -> Vec; + async fn join_team( + &self, + team_id: &str, + pat: Option<&str>, + ) -> ServiceResponse; + async fn remove_from_team( + &self, + team_id: &str, + user_id: &str, + pat: Option<&str>, + ) -> ServiceResponse; + async fn edit_team_member( + &self, + team_id: &str, + user_id: &str, + patch: serde_json::Value, + pat: Option<&str>, + ) -> ServiceResponse; + async fn transfer_team_ownership( + &self, + team_id: &str, + user_id: &str, + pat: Option<&str>, + ) -> ServiceResponse; + async fn get_user_notifications( + &self, + user_id: &str, + pat: Option<&str>, + ) -> ServiceResponse; + async fn get_user_notifications_deserialized_common( + &self, + user_id: &str, + pat: Option<&str>, + ) -> Vec; + async fn get_notification( + &self, + notification_id: &str, + pat: Option<&str>, + ) -> ServiceResponse; + async fn get_notifications( + &self, + ids: &[&str], + pat: Option<&str>, + ) -> ServiceResponse; + async fn mark_notification_read( + &self, + notification_id: &str, + pat: Option<&str>, + ) -> ServiceResponse; + async fn mark_notifications_read( + &self, + ids: &[&str], + pat: Option<&str>, + ) -> ServiceResponse; + async fn add_user_to_team( + &self, + team_id: &str, + user_id: &str, + project_permissions: Option, + organization_permissions: Option, + pat: Option<&str>, + ) -> ServiceResponse; + async fn delete_notification( + &self, + notification_id: &str, + pat: Option<&str>, + ) -> ServiceResponse; + async fn delete_notifications( + &self, + ids: &[&str], + pat: Option<&str>, + ) -> ServiceResponse; +} + +#[async_trait(?Send)] +pub trait ApiUser { + async fn get_user( + &self, + id_or_username: &str, + pat: Option<&str>, + ) -> ServiceResponse; + async fn get_current_user(&self, pat: Option<&str>) -> ServiceResponse; + async fn edit_user( + &self, + id_or_username: &str, + patch: serde_json::Value, + pat: Option<&str>, + ) -> ServiceResponse; + async fn delete_user( + &self, + id_or_username: &str, + pat: Option<&str>, + ) -> ServiceResponse; +} + +#[async_trait(?Send)] +pub trait ApiVersion { + async fn add_public_version( + &self, + project_id: ProjectId, + version_number: &str, + version_jar: TestFile, + ordering: Option, + modify_json: Option, + pat: Option<&str>, + ) -> ServiceResponse; + async fn add_public_version_deserialized_common( + &self, + project_id: ProjectId, + version_number: &str, + version_jar: TestFile, + ordering: Option, + modify_json: Option, + pat: Option<&str>, + ) -> CommonVersion; + async fn get_version(&self, id: &str, pat: Option<&str>) + -> ServiceResponse; + async fn get_version_deserialized_common( + &self, + id_or_slug: &str, + pat: Option<&str>, + ) -> CommonVersion; + async fn get_versions( + &self, + ids: Vec, + pat: Option<&str>, + ) -> ServiceResponse; + async fn get_versions_deserialized_common( + &self, + ids: Vec, + pat: Option<&str>, + ) -> Vec; + async fn download_version_redirect( + &self, + hash: &str, + algorithm: &str, + pat: Option<&str>, + ) -> ServiceResponse; + async fn edit_version( + &self, + id: &str, + patch: serde_json::Value, + pat: Option<&str>, + ) -> ServiceResponse; + async fn get_version_from_hash( + &self, + hash: &str, + algorithm: &str, + pat: Option<&str>, + ) -> ServiceResponse; + async fn get_version_from_hash_deserialized_common( + &self, + hash: &str, + algorithm: &str, + pat: Option<&str>, + ) -> CommonVersion; + async fn get_versions_from_hashes( + &self, + hashes: &[&str], + algorithm: &str, + pat: Option<&str>, + ) -> ServiceResponse; + async fn get_versions_from_hashes_deserialized_common( + &self, + hashes: &[&str], + algorithm: &str, + pat: Option<&str>, + ) -> HashMap; + async fn get_update_from_hash( + &self, + hash: &str, + algorithm: &str, + loaders: Option>, + game_versions: Option>, + version_types: Option>, + pat: Option<&str>, + ) -> ServiceResponse; + async fn get_update_from_hash_deserialized_common( + &self, + hash: &str, + algorithm: &str, + loaders: Option>, + game_versions: Option>, + version_types: Option>, + pat: Option<&str>, + ) -> CommonVersion; + async fn update_files( + &self, + algorithm: &str, + hashes: Vec, + loaders: Option>, + game_versions: Option>, + version_types: Option>, + pat: Option<&str>, + ) -> ServiceResponse; + async fn update_files_deserialized_common( + &self, + algorithm: &str, + hashes: Vec, + loaders: Option>, + game_versions: Option>, + version_types: Option>, + pat: Option<&str>, + ) -> HashMap; + #[allow(clippy::too_many_arguments)] + async fn get_project_versions( + &self, + project_id_slug: &str, + game_versions: Option>, + loaders: Option>, + featured: Option, + version_type: Option, + limit: Option, + offset: Option, + pat: Option<&str>, + ) -> ServiceResponse; + #[allow(clippy::too_many_arguments)] + async fn get_project_versions_deserialized_common( + &self, + slug: &str, + game_versions: Option>, + loaders: Option>, + featured: Option, + version_type: Option, + limit: Option, + offset: Option, + pat: Option<&str>, + ) -> Vec; + async fn edit_version_ordering( + &self, + version_id: &str, + ordering: Option, + pat: Option<&str>, + ) -> ServiceResponse; + async fn upload_file_to_version( + &self, + version_id: &str, + file: &TestFile, + pat: Option<&str>, + ) -> ServiceResponse; + async fn remove_version( + &self, + id_or_slug: &str, + pat: Option<&str>, + ) -> ServiceResponse; + async fn remove_version_file( + &self, + hash: &str, + pat: Option<&str>, + ) -> ServiceResponse; +} + +pub trait AppendsOptionalPat { + fn append_pat(self, pat: Option<&str>) -> Self; +} +// Impl this on all actix_web::test::TestRequest +impl AppendsOptionalPat for actix_web::test::TestRequest { + fn append_pat(self, pat: Option<&str>) -> Self { + if let Some(pat) = pat { + self.append_header(("Authorization", pat)) + } else { + self + } + } +} diff --git a/apps/labrinth/tests/common/api_common/models.rs b/apps/labrinth/tests/common/api_common/models.rs new file mode 100644 index 000000000..d5850bcd1 --- /dev/null +++ b/apps/labrinth/tests/common/api_common/models.rs @@ -0,0 +1,245 @@ +use chrono::{DateTime, Utc}; +use labrinth::{ + auth::AuthProvider, + models::{ + images::ImageId, + notifications::NotificationId, + organizations::OrganizationId, + projects::{ + Dependency, GalleryItem, License, ModeratorMessage, + MonetizationStatus, ProjectId, ProjectStatus, VersionFile, + VersionId, VersionStatus, VersionType, + }, + reports::ReportId, + teams::{ProjectPermissions, TeamId}, + threads::{ThreadId, ThreadMessageId}, + users::{Badges, Role, User, UserId, UserPayoutData}, + }, +}; +use rust_decimal::Decimal; +use serde::Deserialize; + +// Fields shared by every version of the API. +// No struct in here should have ANY field that +// is not present in *every* version of the API. + +// Exceptions are fields that *should* be changing across the API, and older versions +// should be unsupported on API version increase- for example, payouts related financial fields. + +// These are used for common tests- tests that can be used on both V2 AND v3 of the API and have the same results. + +// Any test that requires version-specific fields should have its own test that is not done for each version, +// as the environment generator for both uses common fields. + +#[derive(Deserialize)] +#[allow(dead_code)] +pub struct CommonProject { + // For example, for CommonProject, we do not include: + // - game_versions (v2 only) + // - loader_fields (v3 only) + // - etc. + // For any tests that require those fields, we make a separate test with separate API functions tht do not use Common models. + pub id: ProjectId, + pub slug: Option, + pub organization: Option, + pub published: DateTime, + pub updated: DateTime, + pub approved: Option>, + pub queued: Option>, + pub status: ProjectStatus, + pub requested_status: Option, + pub moderator_message: Option, + pub license: License, + pub downloads: u32, + pub followers: u32, + pub categories: Vec, + pub additional_categories: Vec, + pub loaders: Vec, + pub versions: Vec, + pub icon_url: Option, + pub gallery: Vec, + pub color: Option, + pub thread_id: ThreadId, + pub monetization_status: MonetizationStatus, +} +#[derive(Deserialize, Clone)] +#[allow(dead_code)] +pub struct CommonVersion { + pub id: VersionId, + pub loaders: Vec, + pub project_id: ProjectId, + pub author_id: UserId, + pub featured: bool, + pub name: String, + pub version_number: String, + pub changelog: String, + pub date_published: DateTime, + pub downloads: u32, + pub version_type: VersionType, + pub status: VersionStatus, + pub requested_status: Option, + pub files: Vec, + pub dependencies: Vec, +} + +#[derive(Deserialize)] +#[allow(dead_code)] +pub struct CommonLoaderData { + pub icon: String, + pub name: String, + pub supported_project_types: Vec, +} + +#[derive(Deserialize)] +#[allow(dead_code)] +pub struct CommonCategoryData { + pub icon: String, + pub name: String, + pub project_type: String, + pub header: String, +} + +/// A member of a team +#[derive(Deserialize)] +#[allow(dead_code)] +pub struct CommonTeamMember { + pub team_id: TeamId, + pub user: User, + pub role: String, + + pub permissions: Option, + + pub accepted: bool, + pub payouts_split: Option, + pub ordering: i64, +} + +#[derive(Deserialize)] +#[allow(dead_code)] +pub struct CommonNotification { + pub id: NotificationId, + pub user_id: UserId, + pub read: bool, + pub created: DateTime, + // Body is absent as one of the variants differs + pub text: String, + pub link: String, + pub actions: Vec, +} + +#[derive(Deserialize)] +#[allow(dead_code)] +pub struct CommonNotificationAction { + pub action_route: (String, String), +} + +#[derive(Deserialize, Clone)] +#[serde(rename_all = "kebab-case")] +pub enum CommonItemType { + Project, + Version, + User, + Unknown, +} + +impl CommonItemType { + pub fn as_str(&self) -> &'static str { + match self { + CommonItemType::Project => "project", + CommonItemType::Version => "version", + CommonItemType::User => "user", + CommonItemType::Unknown => "unknown", + } + } +} + +#[derive(Deserialize)] +#[allow(dead_code)] +pub struct CommonReport { + pub id: ReportId, + pub report_type: String, + pub item_id: String, + pub item_type: CommonItemType, + pub reporter: UserId, + pub body: String, + pub created: DateTime, + pub closed: bool, + pub thread_id: ThreadId, +} + +#[derive(Deserialize)] +pub enum LegacyItemType { + Project, + Version, + User, + Unknown, +} + +#[derive(Deserialize)] +#[allow(dead_code)] +pub struct CommonThread { + pub id: ThreadId, + #[serde(rename = "type")] + pub type_: CommonThreadType, + pub project_id: Option, + pub report_id: Option, + pub messages: Vec, + pub members: Vec, +} + +#[derive(Deserialize)] +#[allow(dead_code)] +pub struct CommonThreadMessage { + pub id: ThreadMessageId, + pub author_id: Option, + pub body: CommonMessageBody, + pub created: DateTime, +} + +#[derive(Deserialize)] +#[allow(dead_code)] +pub enum CommonMessageBody { + Text { + body: String, + #[serde(default)] + private: bool, + replying_to: Option, + #[serde(default)] + associated_images: Vec, + }, + StatusChange { + new_status: ProjectStatus, + old_status: ProjectStatus, + }, + ThreadClosure, + ThreadReopen, + Deleted, +} + +#[derive(Deserialize)] +#[allow(dead_code)] +pub enum CommonThreadType { + Report, + Project, + DirectMessage, +} + +#[derive(Deserialize)] +#[allow(dead_code)] +pub struct CommonUser { + pub id: UserId, + pub username: String, + pub name: Option, + pub avatar_url: Option, + pub bio: Option, + pub created: DateTime, + pub role: Role, + pub badges: Badges, + pub auth_providers: Option>, + pub email: Option, + pub email_verified: Option, + pub has_password: Option, + pub has_totp: Option, + pub payout_data: Option, + pub github_id: Option, +} diff --git a/apps/labrinth/tests/common/api_common/request_data.rs b/apps/labrinth/tests/common/api_common/request_data.rs new file mode 100644 index 000000000..eb3a78136 --- /dev/null +++ b/apps/labrinth/tests/common/api_common/request_data.rs @@ -0,0 +1,27 @@ +// The structures for project/version creation. +// These are created differently, but are essentially the same between versions. + +use labrinth::util::actix::MultipartSegment; + +use crate::common::dummy_data::TestFile; + +#[allow(dead_code)] +pub struct ProjectCreationRequestData { + pub slug: String, + pub jar: Option, + pub segment_data: Vec, +} + +#[allow(dead_code)] +pub struct VersionCreationRequestData { + pub version: String, + pub jar: Option, + pub segment_data: Vec, +} + +#[allow(dead_code)] +pub struct ImageData { + pub filename: String, + pub extension: String, + pub icon: Vec, +} diff --git a/apps/labrinth/tests/common/api_v2/mod.rs b/apps/labrinth/tests/common/api_v2/mod.rs new file mode 100644 index 000000000..a3d52ba01 --- /dev/null +++ b/apps/labrinth/tests/common/api_v2/mod.rs @@ -0,0 +1,53 @@ +#![allow(dead_code)] + +use super::{ + api_common::{Api, ApiBuildable}, + environment::LocalService, +}; +use actix_web::{dev::ServiceResponse, test, App}; +use async_trait::async_trait; +use labrinth::LabrinthConfig; +use std::rc::Rc; + +pub mod project; +pub mod request_data; +pub mod tags; +pub mod team; +pub mod user; +pub mod version; + +#[derive(Clone)] +pub struct ApiV2 { + pub test_app: Rc, +} + +#[async_trait(?Send)] +impl ApiBuildable for ApiV2 { + async fn build(labrinth_config: LabrinthConfig) -> Self { + let app = App::new().configure(|cfg| { + labrinth::app_config(cfg, labrinth_config.clone()) + }); + let test_app: Rc = + Rc::new(test::init_service(app).await); + + Self { test_app } + } +} + +#[async_trait(?Send)] +impl Api for ApiV2 { + async fn call(&self, req: actix_http::Request) -> ServiceResponse { + self.test_app.call(req).await.unwrap() + } + + async fn reset_search_index(&self) -> ServiceResponse { + let req = actix_web::test::TestRequest::post() + .uri("/v2/admin/_force_reindex") + .append_header(( + "Modrinth-Admin", + dotenvy::var("LABRINTH_ADMIN_KEY").unwrap(), + )) + .to_request(); + self.call(req).await + } +} diff --git a/apps/labrinth/tests/common/api_v2/project.rs b/apps/labrinth/tests/common/api_v2/project.rs new file mode 100644 index 000000000..df268fcaf --- /dev/null +++ b/apps/labrinth/tests/common/api_v2/project.rs @@ -0,0 +1,554 @@ +use std::collections::HashMap; + +use crate::{ + assert_status, + common::{ + api_common::{ + models::{CommonItemType, CommonProject, CommonVersion}, + request_data::{ImageData, ProjectCreationRequestData}, + Api, ApiProject, AppendsOptionalPat, + }, + dummy_data::TestFile, + }, +}; +use actix_http::StatusCode; +use actix_web::{ + dev::ServiceResponse, + test::{self, TestRequest}, +}; +use async_trait::async_trait; +use bytes::Bytes; +use labrinth::{ + models::v2::{projects::LegacyProject, search::LegacySearchResults}, + util::actix::AppendsMultipart, +}; +use serde_json::json; + +use crate::common::database::MOD_USER_PAT; + +use super::{ + request_data::{self, get_public_project_creation_data}, + ApiV2, +}; + +impl ApiV2 { + pub async fn get_project_deserialized( + &self, + id_or_slug: &str, + pat: Option<&str>, + ) -> LegacyProject { + let resp = self.get_project(id_or_slug, pat).await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + pub async fn get_user_projects_deserialized( + &self, + user_id_or_username: &str, + pat: Option<&str>, + ) -> Vec { + let resp = self.get_user_projects(user_id_or_username, pat).await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + pub async fn search_deserialized( + &self, + query: Option<&str>, + facets: Option, + pat: Option<&str>, + ) -> LegacySearchResults { + let query_field = if let Some(query) = query { + format!("&query={}", urlencoding::encode(query)) + } else { + "".to_string() + }; + + let facets_field = if let Some(facets) = facets { + format!("&facets={}", urlencoding::encode(&facets.to_string())) + } else { + "".to_string() + }; + + let req = test::TestRequest::get() + .uri(&format!("/v2/search?{}{}", query_field, facets_field)) + .append_pat(pat) + .to_request(); + let resp = self.call(req).await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } +} + +#[async_trait(?Send)] +impl ApiProject for ApiV2 { + async fn add_public_project( + &self, + slug: &str, + version_jar: Option, + modify_json: Option, + pat: Option<&str>, + ) -> (CommonProject, Vec) { + let creation_data = + get_public_project_creation_data(slug, version_jar, modify_json); + + // Add a project. + let slug = creation_data.slug.clone(); + let resp = self.create_project(creation_data, pat).await; + assert_status!(&resp, StatusCode::OK); + + // Approve as a moderator. + let req = TestRequest::patch() + .uri(&format!("/v2/project/{}", slug)) + .append_pat(MOD_USER_PAT) + .set_json(json!( + { + "status": "approved" + } + )) + .to_request(); + let resp = self.call(req).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let project = self.get_project_deserialized_common(&slug, pat).await; + + // Get project's versions + let req = TestRequest::get() + .uri(&format!("/v2/project/{}/version", slug)) + .append_pat(pat) + .to_request(); + let resp = self.call(req).await; + let versions: Vec = test::read_body_json(resp).await; + + (project, versions) + } + + async fn get_public_project_creation_data_json( + &self, + slug: &str, + version_jar: Option<&TestFile>, + ) -> serde_json::Value { + request_data::get_public_project_creation_data_json(slug, version_jar) + } + + async fn create_project( + &self, + creation_data: ProjectCreationRequestData, + pat: Option<&str>, + ) -> ServiceResponse { + let req = TestRequest::post() + .uri("/v2/project") + .append_pat(pat) + .set_multipart(creation_data.segment_data) + .to_request(); + self.call(req).await + } + + async fn remove_project( + &self, + project_slug_or_id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::delete() + .uri(&format!("/v2/project/{project_slug_or_id}")) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + async fn get_project( + &self, + id_or_slug: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = TestRequest::get() + .uri(&format!("/v2/project/{id_or_slug}")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_project_deserialized_common( + &self, + id_or_slug: &str, + pat: Option<&str>, + ) -> CommonProject { + let resp = self.get_project(id_or_slug, pat).await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let project: LegacyProject = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(project).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn get_projects( + &self, + ids_or_slugs: &[&str], + pat: Option<&str>, + ) -> ServiceResponse { + let ids_or_slugs = serde_json::to_string(ids_or_slugs).unwrap(); + let req = test::TestRequest::get() + .uri(&format!( + "/v2/projects?ids={encoded}", + encoded = urlencoding::encode(&ids_or_slugs) + )) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_project_dependencies( + &self, + id_or_slug: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = TestRequest::get() + .uri(&format!("/v2/project/{id_or_slug}/dependencies")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_user_projects( + &self, + user_id_or_username: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v2/user/{}/projects", user_id_or_username)) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_user_projects_deserialized_common( + &self, + user_id_or_username: &str, + pat: Option<&str>, + ) -> Vec { + let resp = self.get_user_projects(user_id_or_username, pat).await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let projects: Vec = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(projects).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn edit_project( + &self, + id_or_slug: &str, + patch: serde_json::Value, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::patch() + .uri(&format!("/v2/project/{id_or_slug}")) + .append_pat(pat) + .set_json(patch) + .to_request(); + + self.call(req).await + } + + async fn edit_project_bulk( + &self, + ids_or_slugs: &[&str], + patch: serde_json::Value, + pat: Option<&str>, + ) -> ServiceResponse { + let projects_str = ids_or_slugs + .iter() + .map(|s| format!("\"{}\"", s)) + .collect::>() + .join(","); + let req = test::TestRequest::patch() + .uri(&format!( + "/v2/projects?ids={encoded}", + encoded = urlencoding::encode(&format!("[{projects_str}]")) + )) + .append_pat(pat) + .set_json(patch) + .to_request(); + + self.call(req).await + } + + async fn edit_project_icon( + &self, + id_or_slug: &str, + icon: Option, + pat: Option<&str>, + ) -> ServiceResponse { + if let Some(icon) = icon { + // If an icon is provided, upload it + let req = test::TestRequest::patch() + .uri(&format!( + "/v2/project/{id_or_slug}/icon?ext={ext}", + ext = icon.extension + )) + .append_pat(pat) + .set_payload(Bytes::from(icon.icon)) + .to_request(); + + self.call(req).await + } else { + // If no icon is provided, delete the icon + let req = test::TestRequest::delete() + .uri(&format!("/v2/project/{id_or_slug}/icon")) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + } + + async fn create_report( + &self, + report_type: &str, + id: &str, + item_type: CommonItemType, + body: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::post() + .uri("/v2/report") + .append_pat(pat) + .set_json(json!( + { + "report_type": report_type, + "item_id": id, + "item_type": item_type.as_str(), + "body": body, + } + )) + .to_request(); + + self.call(req).await + } + + async fn get_report(&self, id: &str, pat: Option<&str>) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v2/report/{id}")) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + async fn get_reports( + &self, + ids: &[&str], + pat: Option<&str>, + ) -> ServiceResponse { + let ids_str = serde_json::to_string(ids).unwrap(); + let req = test::TestRequest::get() + .uri(&format!( + "/v2/reports?ids={encoded}", + encoded = urlencoding::encode(&ids_str) + )) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + async fn get_user_reports(&self, pat: Option<&str>) -> ServiceResponse { + let req = test::TestRequest::get() + .uri("/v2/report") + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + async fn delete_report( + &self, + id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::delete() + .uri(&format!("/v2/report/{id}")) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + async fn edit_report( + &self, + id: &str, + patch: serde_json::Value, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::patch() + .uri(&format!("/v2/report/{id}")) + .append_pat(pat) + .set_json(patch) + .to_request(); + + self.call(req).await + } + + async fn get_thread(&self, id: &str, pat: Option<&str>) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v3/thread/{id}")) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + async fn get_threads( + &self, + ids: &[&str], + pat: Option<&str>, + ) -> ServiceResponse { + let ids_str = serde_json::to_string(ids).unwrap(); + let req = test::TestRequest::get() + .uri(&format!( + "/v3/threads?ids={encoded}", + encoded = urlencoding::encode(&ids_str) + )) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + async fn write_to_thread( + &self, + id: &str, + r#type: &str, + content: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::post() + .uri(&format!("/v2/thread/{id}")) + .append_pat(pat) + .set_json(json!({ + "body" : { + "type": r#type, + "body": content, + } + })) + .to_request(); + + self.call(req).await + } + + async fn get_moderation_inbox(&self, pat: Option<&str>) -> ServiceResponse { + let req = test::TestRequest::get() + .uri("/v2/thread/inbox") + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + async fn read_thread( + &self, + id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::post() + .uri(&format!("/v2/thread/{id}/read")) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + async fn delete_thread_message( + &self, + id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::delete() + .uri(&format!("/v2/message/{id}")) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + #[allow(clippy::too_many_arguments)] + async fn add_gallery_item( + &self, + id_or_slug: &str, + image: ImageData, + featured: bool, + title: Option, + description: Option, + ordering: Option, + pat: Option<&str>, + ) -> ServiceResponse { + let mut url = format!( + "/v2/project/{id_or_slug}/gallery?ext={ext}&featured={featured}", + ext = image.extension, + featured = featured + ); + if let Some(title) = title { + url.push_str(&format!("&title={}", title)); + } + if let Some(description) = description { + url.push_str(&format!("&description={}", description)); + } + if let Some(ordering) = ordering { + url.push_str(&format!("&ordering={}", ordering)); + } + + let req = test::TestRequest::post() + .uri(&url) + .append_pat(pat) + .set_payload(Bytes::from(image.icon)) + .to_request(); + + self.call(req).await + } + + async fn edit_gallery_item( + &self, + id_or_slug: &str, + image_url: &str, + patch: HashMap, + pat: Option<&str>, + ) -> ServiceResponse { + let mut url = format!( + "/v2/project/{id_or_slug}/gallery?url={image_url}", + image_url = urlencoding::encode(image_url) + ); + + for (key, value) in patch { + url.push_str(&format!( + "&{key}={value}", + key = key, + value = urlencoding::encode(&value) + )); + } + + let req = test::TestRequest::patch() + .uri(&url) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn remove_gallery_item( + &self, + id_or_slug: &str, + url: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::delete() + .uri(&format!( + "/v2/project/{id_or_slug}/gallery?url={url}", + url = url + )) + .append_pat(pat) + .to_request(); + + self.call(req).await + } +} diff --git a/apps/labrinth/tests/common/api_v2/request_data.rs b/apps/labrinth/tests/common/api_v2/request_data.rs new file mode 100644 index 000000000..8eced3618 --- /dev/null +++ b/apps/labrinth/tests/common/api_v2/request_data.rs @@ -0,0 +1,135 @@ +#![allow(dead_code)] +use serde_json::json; + +use crate::common::{ + api_common::request_data::{ + ProjectCreationRequestData, VersionCreationRequestData, + }, + dummy_data::TestFile, +}; +use labrinth::{ + models::projects::ProjectId, + util::actix::{MultipartSegment, MultipartSegmentData}, +}; + +pub fn get_public_project_creation_data( + slug: &str, + version_jar: Option, + modify_json: Option, +) -> ProjectCreationRequestData { + let mut json_data = + get_public_project_creation_data_json(slug, version_jar.as_ref()); + if let Some(modify_json) = modify_json { + json_patch::patch(&mut json_data, &modify_json).unwrap(); + } + let multipart_data = + get_public_creation_data_multipart(&json_data, version_jar.as_ref()); + ProjectCreationRequestData { + slug: slug.to_string(), + jar: version_jar, + segment_data: multipart_data, + } +} + +pub fn get_public_version_creation_data( + project_id: ProjectId, + version_number: &str, + version_jar: TestFile, + ordering: Option, + modify_json: Option, +) -> VersionCreationRequestData { + let mut json_data = get_public_version_creation_data_json( + version_number, + ordering, + &version_jar, + ); + json_data["project_id"] = json!(project_id); + if let Some(modify_json) = modify_json { + json_patch::patch(&mut json_data, &modify_json).unwrap(); + } + let multipart_data = + get_public_creation_data_multipart(&json_data, Some(&version_jar)); + VersionCreationRequestData { + version: version_number.to_string(), + jar: Some(version_jar), + segment_data: multipart_data, + } +} + +pub fn get_public_version_creation_data_json( + version_number: &str, + ordering: Option, + version_jar: &TestFile, +) -> serde_json::Value { + let mut j = json!({ + "file_parts": [version_jar.filename()], + "version_number": version_number, + "version_title": "start", + "dependencies": [], + "game_versions": ["1.20.1"] , + "release_channel": "release", + "loaders": ["fabric"], + "featured": true + }); + if let Some(ordering) = ordering { + j["ordering"] = json!(ordering); + } + j +} + +pub fn get_public_project_creation_data_json( + slug: &str, + version_jar: Option<&TestFile>, +) -> serde_json::Value { + let initial_versions = if let Some(jar) = version_jar { + json!([get_public_version_creation_data_json("1.2.3", None, jar)]) + } else { + json!([]) + }; + + let is_draft = version_jar.is_none(); + json!( + { + "title": format!("Test Project {slug}"), + "slug": slug, + "project_type": version_jar.as_ref().map(|f| f.project_type()).unwrap_or("mod".to_string()), + "description": "A dummy project for testing with.", + "body": "This project is approved, and versions are listed.", + "client_side": "required", + "server_side": "optional", + "initial_versions": initial_versions, + "is_draft": is_draft, + "categories": [], + "license_id": "MIT", + } + ) +} + +pub fn get_public_creation_data_multipart( + json_data: &serde_json::Value, + version_jar: Option<&TestFile>, +) -> Vec { + // Basic json + let json_segment = MultipartSegment { + name: "data".to_string(), + filename: None, + content_type: Some("application/json".to_string()), + data: MultipartSegmentData::Text( + serde_json::to_string(json_data).unwrap(), + ), + }; + + if let Some(jar) = version_jar { + // Basic file + let file_segment = MultipartSegment { + name: jar.filename(), + filename: Some(jar.filename()), + content_type: Some("application/java-archive".to_string()), + data: MultipartSegmentData::Binary(jar.bytes()), + }; + + vec![json_segment, file_segment] + } else { + vec![json_segment] + } +} diff --git a/apps/labrinth/tests/common/api_v2/tags.rs b/apps/labrinth/tests/common/api_v2/tags.rs new file mode 100644 index 000000000..b78a5c0d1 --- /dev/null +++ b/apps/labrinth/tests/common/api_v2/tags.rs @@ -0,0 +1,123 @@ +use actix_http::StatusCode; +use actix_web::{ + dev::ServiceResponse, + test::{self, TestRequest}, +}; +use async_trait::async_trait; +use labrinth::routes::v2::tags::{ + CategoryData, DonationPlatformQueryData, GameVersionQueryData, LoaderData, +}; + +use crate::{ + assert_status, + common::{ + api_common::{ + models::{CommonCategoryData, CommonLoaderData}, + Api, ApiTags, AppendsOptionalPat, + }, + database::ADMIN_USER_PAT, + }, +}; + +use super::ApiV2; + +impl ApiV2 { + async fn get_side_types(&self) -> ServiceResponse { + let req = TestRequest::get() + .uri("/v2/tag/side_type") + .append_pat(ADMIN_USER_PAT) + .to_request(); + self.call(req).await + } + + pub async fn get_side_types_deserialized(&self) -> Vec { + let resp = self.get_side_types().await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + pub async fn get_game_versions(&self) -> ServiceResponse { + let req = TestRequest::get() + .uri("/v2/tag/game_version") + .append_pat(ADMIN_USER_PAT) + .to_request(); + self.call(req).await + } + + pub async fn get_game_versions_deserialized( + &self, + ) -> Vec { + let resp = self.get_game_versions().await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + pub async fn get_loaders_deserialized(&self) -> Vec { + let resp = self.get_loaders().await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + pub async fn get_categories_deserialized(&self) -> Vec { + let resp = self.get_categories().await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + pub async fn get_donation_platforms(&self) -> ServiceResponse { + let req = TestRequest::get() + .uri("/v2/tag/donation_platform") + .append_pat(ADMIN_USER_PAT) + .to_request(); + self.call(req).await + } + + pub async fn get_donation_platforms_deserialized( + &self, + ) -> Vec { + let resp = self.get_donation_platforms().await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } +} + +#[async_trait(?Send)] +impl ApiTags for ApiV2 { + async fn get_loaders(&self) -> ServiceResponse { + let req = TestRequest::get() + .uri("/v2/tag/loader") + .append_pat(ADMIN_USER_PAT) + .to_request(); + self.call(req).await + } + + async fn get_loaders_deserialized_common(&self) -> Vec { + let resp = self.get_loaders().await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: Vec = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn get_categories(&self) -> ServiceResponse { + let req = TestRequest::get() + .uri("/v2/tag/category") + .append_pat(ADMIN_USER_PAT) + .to_request(); + self.call(req).await + } + + async fn get_categories_deserialized_common( + &self, + ) -> Vec { + let resp = self.get_categories().await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: Vec = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } +} diff --git a/apps/labrinth/tests/common/api_v2/team.rs b/apps/labrinth/tests/common/api_v2/team.rs new file mode 100644 index 000000000..8af4f0054 --- /dev/null +++ b/apps/labrinth/tests/common/api_v2/team.rs @@ -0,0 +1,333 @@ +use actix_http::StatusCode; +use actix_web::{dev::ServiceResponse, test}; +use async_trait::async_trait; +use labrinth::models::{ + teams::{OrganizationPermissions, ProjectPermissions}, + v2::{notifications::LegacyNotification, teams::LegacyTeamMember}, +}; +use serde_json::json; + +use crate::{ + assert_status, + common::api_common::{ + models::{CommonNotification, CommonTeamMember}, + Api, ApiTeams, AppendsOptionalPat, + }, +}; + +use super::ApiV2; + +impl ApiV2 { + pub async fn get_organization_members_deserialized( + &self, + id_or_title: &str, + pat: Option<&str>, + ) -> Vec { + let resp = self.get_organization_members(id_or_title, pat).await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + pub async fn get_team_members_deserialized( + &self, + team_id: &str, + pat: Option<&str>, + ) -> Vec { + let resp = self.get_team_members(team_id, pat).await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + pub async fn get_user_notifications_deserialized( + &self, + user_id: &str, + pat: Option<&str>, + ) -> Vec { + let resp = self.get_user_notifications(user_id, pat).await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } +} + +#[async_trait(?Send)] +impl ApiTeams for ApiV2 { + async fn get_team_members( + &self, + id_or_title: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v2/team/{id_or_title}/members")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_team_members_deserialized_common( + &self, + id_or_title: &str, + pat: Option<&str>, + ) -> Vec { + let resp = self.get_team_members(id_or_title, pat).await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: Vec = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn get_teams_members( + &self, + ids_or_titles: &[&str], + pat: Option<&str>, + ) -> ServiceResponse { + let ids_or_titles = serde_json::to_string(ids_or_titles).unwrap(); + let req = test::TestRequest::get() + .uri(&format!( + "/v2/teams?ids={}", + urlencoding::encode(&ids_or_titles) + )) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_project_members( + &self, + id_or_title: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v2/project/{id_or_title}/members")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_project_members_deserialized_common( + &self, + id_or_title: &str, + pat: Option<&str>, + ) -> Vec { + let resp = self.get_project_members(id_or_title, pat).await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: Vec = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn get_organization_members( + &self, + id_or_title: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v2/organization/{id_or_title}/members")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_organization_members_deserialized_common( + &self, + id_or_title: &str, + pat: Option<&str>, + ) -> Vec { + let resp = self.get_organization_members(id_or_title, pat).await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: Vec = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn join_team( + &self, + team_id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::post() + .uri(&format!("/v2/team/{team_id}/join")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn remove_from_team( + &self, + team_id: &str, + user_id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::delete() + .uri(&format!("/v2/team/{team_id}/members/{user_id}")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn edit_team_member( + &self, + team_id: &str, + user_id: &str, + patch: serde_json::Value, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::patch() + .uri(&format!("/v2/team/{team_id}/members/{user_id}")) + .append_pat(pat) + .set_json(patch) + .to_request(); + self.call(req).await + } + + async fn transfer_team_ownership( + &self, + team_id: &str, + user_id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::patch() + .uri(&format!("/v2/team/{team_id}/owner")) + .append_pat(pat) + .set_json(json!({ + "user_id": user_id, + })) + .to_request(); + self.call(req).await + } + + async fn get_user_notifications( + &self, + user_id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v2/user/{user_id}/notifications")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_user_notifications_deserialized_common( + &self, + user_id: &str, + pat: Option<&str>, + ) -> Vec { + let resp = self.get_user_notifications(user_id, pat).await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: Vec = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn get_notification( + &self, + notification_id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v2/notification/{notification_id}")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_notifications( + &self, + notification_ids: &[&str], + pat: Option<&str>, + ) -> ServiceResponse { + let notification_ids = serde_json::to_string(notification_ids).unwrap(); + let req = test::TestRequest::get() + .uri(&format!( + "/v2/notifications?ids={}", + urlencoding::encode(¬ification_ids) + )) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn mark_notification_read( + &self, + notification_id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::patch() + .uri(&format!("/v2/notification/{notification_id}")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn mark_notifications_read( + &self, + notification_ids: &[&str], + pat: Option<&str>, + ) -> ServiceResponse { + let notification_ids = serde_json::to_string(notification_ids).unwrap(); + let req = test::TestRequest::patch() + .uri(&format!( + "/v2/notifications?ids={}", + urlencoding::encode(¬ification_ids) + )) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn add_user_to_team( + &self, + team_id: &str, + user_id: &str, + project_permissions: Option, + organization_permissions: Option, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::post() + .uri(&format!("/v2/team/{team_id}/members")) + .append_pat(pat) + .set_json(json!( { + "user_id": user_id, + "permissions" : project_permissions.map(|p| p.bits()).unwrap_or_default(), + "organization_permissions" : organization_permissions.map(|p| p.bits()), + })) + .to_request(); + self.call(req).await + } + + async fn delete_notification( + &self, + notification_id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::delete() + .uri(&format!("/v2/notification/{notification_id}")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn delete_notifications( + &self, + notification_ids: &[&str], + pat: Option<&str>, + ) -> ServiceResponse { + let notification_ids = serde_json::to_string(notification_ids).unwrap(); + let req = test::TestRequest::delete() + .uri(&format!( + "/v2/notifications?ids={}", + urlencoding::encode(¬ification_ids) + )) + .append_pat(pat) + .to_request(); + self.call(req).await + } +} diff --git a/apps/labrinth/tests/common/api_v2/user.rs b/apps/labrinth/tests/common/api_v2/user.rs new file mode 100644 index 000000000..7031b66f4 --- /dev/null +++ b/apps/labrinth/tests/common/api_v2/user.rs @@ -0,0 +1,55 @@ +use super::ApiV2; +use crate::common::api_common::{Api, ApiUser, AppendsOptionalPat}; +use actix_web::{dev::ServiceResponse, test}; +use async_trait::async_trait; + +#[async_trait(?Send)] +impl ApiUser for ApiV2 { + async fn get_user( + &self, + user_id_or_username: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v2/user/{}", user_id_or_username)) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_current_user(&self, pat: Option<&str>) -> ServiceResponse { + let req = test::TestRequest::get() + .uri("/v2/user") + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn edit_user( + &self, + user_id_or_username: &str, + patch: serde_json::Value, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::patch() + .uri(&format!("/v2/user/{}", user_id_or_username)) + .append_pat(pat) + .set_json(patch) + .to_request(); + + self.call(req).await + } + + async fn delete_user( + &self, + user_id_or_username: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::delete() + .uri(&format!("/v2/user/{}", user_id_or_username)) + .append_pat(pat) + .to_request(); + + self.call(req).await + } +} diff --git a/apps/labrinth/tests/common/api_v2/version.rs b/apps/labrinth/tests/common/api_v2/version.rs new file mode 100644 index 000000000..abeab7e37 --- /dev/null +++ b/apps/labrinth/tests/common/api_v2/version.rs @@ -0,0 +1,546 @@ +use std::collections::HashMap; + +use super::{ + request_data::{self, get_public_version_creation_data}, + ApiV2, +}; +use crate::{ + assert_status, + common::{ + api_common::{ + models::CommonVersion, Api, ApiVersion, AppendsOptionalPat, + }, + dummy_data::TestFile, + }, +}; +use actix_http::StatusCode; +use actix_web::{ + dev::ServiceResponse, + test::{self, TestRequest}, +}; +use async_trait::async_trait; +use labrinth::{ + models::{ + projects::{ProjectId, VersionType}, + v2::projects::LegacyVersion, + }, + routes::v2::version_file::FileUpdateData, + util::actix::AppendsMultipart, +}; +use serde_json::json; + +pub fn url_encode_json_serialized_vec(elements: &[String]) -> String { + let serialized = serde_json::to_string(&elements).unwrap(); + urlencoding::encode(&serialized).to_string() +} + +impl ApiV2 { + pub async fn get_version_deserialized( + &self, + id: &str, + pat: Option<&str>, + ) -> LegacyVersion { + let resp = self.get_version(id, pat).await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + pub async fn get_version_from_hash_deserialized( + &self, + hash: &str, + algorithm: &str, + pat: Option<&str>, + ) -> LegacyVersion { + let resp = self.get_version_from_hash(hash, algorithm, pat).await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + pub async fn get_versions_from_hashes_deserialized( + &self, + hashes: &[&str], + algorithm: &str, + pat: Option<&str>, + ) -> HashMap { + let resp = self.get_versions_from_hashes(hashes, algorithm, pat).await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + pub async fn update_individual_files( + &self, + algorithm: &str, + hashes: Vec, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::post() + .uri("/v2/version_files/update_individual") + .append_pat(pat) + .set_json(json!({ + "algorithm": algorithm, + "hashes": hashes + })) + .to_request(); + self.call(req).await + } + + pub async fn update_individual_files_deserialized( + &self, + algorithm: &str, + hashes: Vec, + pat: Option<&str>, + ) -> HashMap { + let resp = self.update_individual_files(algorithm, hashes, pat).await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } +} + +#[async_trait(?Send)] +impl ApiVersion for ApiV2 { + async fn add_public_version( + &self, + project_id: ProjectId, + version_number: &str, + version_jar: TestFile, + ordering: Option, + modify_json: Option, + pat: Option<&str>, + ) -> ServiceResponse { + let creation_data = get_public_version_creation_data( + project_id, + version_number, + version_jar, + ordering, + modify_json, + ); + + // Add a project. + let req = TestRequest::post() + .uri("/v2/version") + .append_pat(pat) + .set_multipart(creation_data.segment_data) + .to_request(); + self.call(req).await + } + + async fn add_public_version_deserialized_common( + &self, + project_id: ProjectId, + version_number: &str, + version_jar: TestFile, + ordering: Option, + modify_json: Option, + pat: Option<&str>, + ) -> CommonVersion { + let resp = self + .add_public_version( + project_id, + version_number, + version_jar, + ordering, + modify_json, + pat, + ) + .await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: LegacyVersion = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn get_version( + &self, + id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = TestRequest::get() + .uri(&format!("/v2/version/{id}")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_version_deserialized_common( + &self, + id: &str, + pat: Option<&str>, + ) -> CommonVersion { + let resp = self.get_version(id, pat).await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: LegacyVersion = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn download_version_redirect( + &self, + hash: &str, + algorithm: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v2/version_file/{hash}/download",)) + .set_json(json!({ + "algorithm": algorithm, + })) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn edit_version( + &self, + version_id: &str, + patch: serde_json::Value, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::patch() + .uri(&format!("/v2/version/{version_id}")) + .append_pat(pat) + .set_json(patch) + .to_request(); + + self.call(req).await + } + + async fn get_version_from_hash( + &self, + hash: &str, + algorithm: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v2/version_file/{hash}?algorithm={algorithm}")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_version_from_hash_deserialized_common( + &self, + hash: &str, + algorithm: &str, + pat: Option<&str>, + ) -> CommonVersion { + let resp = self.get_version_from_hash(hash, algorithm, pat).await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: LegacyVersion = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn get_versions_from_hashes( + &self, + hashes: &[&str], + algorithm: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = TestRequest::post() + .uri("/v2/version_files") + .append_pat(pat) + .set_json(json!({ + "hashes": hashes, + "algorithm": algorithm, + })) + .to_request(); + self.call(req).await + } + + async fn get_versions_from_hashes_deserialized_common( + &self, + hashes: &[&str], + algorithm: &str, + pat: Option<&str>, + ) -> HashMap { + let resp = self.get_versions_from_hashes(hashes, algorithm, pat).await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: HashMap = + test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn get_update_from_hash( + &self, + hash: &str, + algorithm: &str, + loaders: Option>, + game_versions: Option>, + version_types: Option>, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::post() + .uri(&format!( + "/v2/version_file/{hash}/update?algorithm={algorithm}" + )) + .append_pat(pat) + .set_json(json!({ + "loaders": loaders, + "game_versions": game_versions, + "version_types": version_types, + })) + .to_request(); + self.call(req).await + } + + async fn get_update_from_hash_deserialized_common( + &self, + hash: &str, + algorithm: &str, + loaders: Option>, + game_versions: Option>, + version_types: Option>, + pat: Option<&str>, + ) -> CommonVersion { + let resp = self + .get_update_from_hash( + hash, + algorithm, + loaders, + game_versions, + version_types, + pat, + ) + .await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: LegacyVersion = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn update_files( + &self, + algorithm: &str, + hashes: Vec, + loaders: Option>, + game_versions: Option>, + version_types: Option>, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::post() + .uri("/v2/version_files/update") + .append_pat(pat) + .set_json(json!({ + "algorithm": algorithm, + "hashes": hashes, + "loaders": loaders, + "game_versions": game_versions, + "version_types": version_types, + })) + .to_request(); + self.call(req).await + } + + async fn update_files_deserialized_common( + &self, + algorithm: &str, + hashes: Vec, + loaders: Option>, + game_versions: Option>, + version_types: Option>, + pat: Option<&str>, + ) -> HashMap { + let resp = self + .update_files( + algorithm, + hashes, + loaders, + game_versions, + version_types, + pat, + ) + .await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: HashMap = + test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + // TODO: Not all fields are tested currently in the V2 tests, only the v2-v3 relevant ones are + #[allow(clippy::too_many_arguments)] + async fn get_project_versions( + &self, + project_id_slug: &str, + game_versions: Option>, + loaders: Option>, + featured: Option, + version_type: Option, + limit: Option, + offset: Option, + pat: Option<&str>, + ) -> ServiceResponse { + let mut query_string = String::new(); + if let Some(game_versions) = game_versions { + query_string.push_str(&format!( + "&game_versions={}", + urlencoding::encode( + &serde_json::to_string(&game_versions).unwrap() + ) + )); + } + if let Some(loaders) = loaders { + query_string.push_str(&format!( + "&loaders={}", + urlencoding::encode(&serde_json::to_string(&loaders).unwrap()) + )); + } + if let Some(featured) = featured { + query_string.push_str(&format!("&featured={}", featured)); + } + if let Some(version_type) = version_type { + query_string.push_str(&format!("&version_type={}", version_type)); + } + if let Some(limit) = limit { + let limit = limit.to_string(); + query_string.push_str(&format!("&limit={}", limit)); + } + if let Some(offset) = offset { + let offset = offset.to_string(); + query_string.push_str(&format!("&offset={}", offset)); + } + + let req = test::TestRequest::get() + .uri(&format!( + "/v2/project/{project_id_slug}/version?{}", + query_string.trim_start_matches('&') + )) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + #[allow(clippy::too_many_arguments)] + async fn get_project_versions_deserialized_common( + &self, + slug: &str, + game_versions: Option>, + loaders: Option>, + featured: Option, + version_type: Option, + limit: Option, + offset: Option, + pat: Option<&str>, + ) -> Vec { + let resp = self + .get_project_versions( + slug, + game_versions, + loaders, + featured, + version_type, + limit, + offset, + pat, + ) + .await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: Vec = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn edit_version_ordering( + &self, + version_id: &str, + ordering: Option, + pat: Option<&str>, + ) -> ServiceResponse { + let request = test::TestRequest::patch() + .uri(&format!("/v2/version/{version_id}")) + .set_json(json!( + { + "ordering": ordering + } + )) + .append_pat(pat) + .to_request(); + self.call(request).await + } + + async fn get_versions( + &self, + version_ids: Vec, + pat: Option<&str>, + ) -> ServiceResponse { + let ids = url_encode_json_serialized_vec(&version_ids); + let request = test::TestRequest::get() + .uri(&format!("/v2/versions?ids={}", ids)) + .append_pat(pat) + .to_request(); + self.call(request).await + } + + async fn get_versions_deserialized_common( + &self, + version_ids: Vec, + pat: Option<&str>, + ) -> Vec { + let resp = self.get_versions(version_ids, pat).await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: Vec = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn upload_file_to_version( + &self, + version_id: &str, + file: &TestFile, + pat: Option<&str>, + ) -> ServiceResponse { + let m = request_data::get_public_creation_data_multipart( + &json!({ + "file_parts": [file.filename()] + }), + Some(file), + ); + let request = test::TestRequest::post() + .uri(&format!("/v2/version/{version_id}/file")) + .append_pat(pat) + .set_multipart(m) + .to_request(); + self.call(request).await + } + + async fn remove_version( + &self, + version_id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let request = test::TestRequest::delete() + .uri(&format!("/v2/version/{version_id}")) + .append_pat(pat) + .to_request(); + self.call(request).await + } + + async fn remove_version_file( + &self, + hash: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let request = test::TestRequest::delete() + .uri(&format!("/v2/version_file/{hash}")) + .append_pat(pat) + .to_request(); + self.call(request).await + } +} diff --git a/apps/labrinth/tests/common/api_v3/collections.rs b/apps/labrinth/tests/common/api_v3/collections.rs new file mode 100644 index 000000000..81ca4bb6b --- /dev/null +++ b/apps/labrinth/tests/common/api_v3/collections.rs @@ -0,0 +1,179 @@ +use actix_http::StatusCode; +use actix_web::{ + dev::ServiceResponse, + test::{self, TestRequest}, +}; +use bytes::Bytes; +use labrinth::models::{collections::Collection, v3::projects::Project}; +use serde_json::json; + +use crate::{ + assert_status, + common::api_common::{request_data::ImageData, Api, AppendsOptionalPat}, +}; + +use super::ApiV3; + +impl ApiV3 { + pub async fn create_collection( + &self, + collection_title: &str, + description: &str, + projects: &[&str], + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::post() + .uri("/v3/collection") + .append_pat(pat) + .set_json(json!({ + "name": collection_title, + "description": description, + "projects": projects, + })) + .to_request(); + self.call(req).await + } + + pub async fn get_collection( + &self, + id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = TestRequest::get() + .uri(&format!("/v3/collection/{id}")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + pub async fn get_collection_deserialized( + &self, + id: &str, + pat: Option<&str>, + ) -> Collection { + let resp = self.get_collection(id, pat).await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + pub async fn get_collections( + &self, + ids: &[&str], + pat: Option<&str>, + ) -> ServiceResponse { + let ids = serde_json::to_string(ids).unwrap(); + let req = test::TestRequest::get() + .uri(&format!( + "/v3/collections?ids={}", + urlencoding::encode(&ids) + )) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + pub async fn get_collection_projects( + &self, + id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v3/collection/{id}/projects")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + pub async fn get_collection_projects_deserialized( + &self, + id: &str, + pat: Option<&str>, + ) -> Vec { + let resp = self.get_collection_projects(id, pat).await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + pub async fn edit_collection( + &self, + id: &str, + patch: serde_json::Value, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::patch() + .uri(&format!("/v3/collection/{id}")) + .append_pat(pat) + .set_json(patch) + .to_request(); + + self.call(req).await + } + + pub async fn edit_collection_icon( + &self, + id: &str, + icon: Option, + pat: Option<&str>, + ) -> ServiceResponse { + if let Some(icon) = icon { + // If an icon is provided, upload it + let req = test::TestRequest::patch() + .uri(&format!( + "/v3/collection/{id}/icon?ext={ext}", + ext = icon.extension + )) + .append_pat(pat) + .set_payload(Bytes::from(icon.icon)) + .to_request(); + + self.call(req).await + } else { + // If no icon is provided, delete the icon + let req = test::TestRequest::delete() + .uri(&format!("/v3/collection/{id}/icon")) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + } + + pub async fn delete_collection( + &self, + id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::delete() + .uri(&format!("/v3/collection/{id}")) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + pub async fn get_user_collections( + &self, + user_id_or_username: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v3/user/{}/collections", user_id_or_username)) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + pub async fn get_user_collections_deserialized_common( + &self, + user_id_or_username: &str, + pat: Option<&str>, + ) -> Vec { + let resp = self.get_user_collections(user_id_or_username, pat).await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let projects: Vec = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(projects).unwrap(); + serde_json::from_value(value).unwrap() + } +} diff --git a/apps/labrinth/tests/common/api_v3/mod.rs b/apps/labrinth/tests/common/api_v3/mod.rs new file mode 100644 index 000000000..f4a0d889a --- /dev/null +++ b/apps/labrinth/tests/common/api_v3/mod.rs @@ -0,0 +1,57 @@ +#![allow(dead_code)] + +use super::{ + api_common::{Api, ApiBuildable}, + environment::LocalService, +}; +use actix_web::{dev::ServiceResponse, test, App}; +use async_trait::async_trait; +use labrinth::LabrinthConfig; +use std::rc::Rc; + +pub mod collections; +pub mod oauth; +pub mod oauth_clients; +pub mod organization; +pub mod project; +pub mod request_data; +pub mod tags; +pub mod team; +pub mod user; +pub mod version; + +#[derive(Clone)] +pub struct ApiV3 { + pub test_app: Rc, +} + +#[async_trait(?Send)] +impl ApiBuildable for ApiV3 { + async fn build(labrinth_config: LabrinthConfig) -> Self { + let app = App::new().configure(|cfg| { + labrinth::app_config(cfg, labrinth_config.clone()) + }); + let test_app: Rc = + Rc::new(test::init_service(app).await); + + Self { test_app } + } +} + +#[async_trait(?Send)] +impl Api for ApiV3 { + async fn call(&self, req: actix_http::Request) -> ServiceResponse { + self.test_app.call(req).await.unwrap() + } + + async fn reset_search_index(&self) -> ServiceResponse { + let req = actix_web::test::TestRequest::post() + .uri("/_internal/admin/_force_reindex") + .append_header(( + "Modrinth-Admin", + dotenvy::var("LABRINTH_ADMIN_KEY").unwrap(), + )) + .to_request(); + self.call(req).await + } +} diff --git a/apps/labrinth/tests/common/api_v3/oauth.rs b/apps/labrinth/tests/common/api_v3/oauth.rs new file mode 100644 index 000000000..acd5e1732 --- /dev/null +++ b/apps/labrinth/tests/common/api_v3/oauth.rs @@ -0,0 +1,177 @@ +use std::collections::HashMap; + +use actix_http::StatusCode; +use actix_web::{ + dev::ServiceResponse, + test::{self, TestRequest}, +}; +use labrinth::auth::oauth::{ + OAuthClientAccessRequest, RespondToOAuthClientScopes, TokenRequest, + TokenResponse, +}; +use reqwest::header::{AUTHORIZATION, LOCATION}; + +use crate::{ + assert_status, + common::api_common::{Api, AppendsOptionalPat}, +}; + +use super::ApiV3; + +impl ApiV3 { + pub async fn complete_full_authorize_flow( + &self, + client_id: &str, + client_secret: &str, + scope: Option<&str>, + redirect_uri: Option<&str>, + state: Option<&str>, + user_pat: Option<&str>, + ) -> String { + let auth_resp = self + .oauth_authorize(client_id, scope, redirect_uri, state, user_pat) + .await; + let flow_id = get_authorize_accept_flow_id(auth_resp).await; + let redirect_resp = self.oauth_accept(&flow_id, user_pat).await; + let auth_code = + get_auth_code_from_redirect_params(&redirect_resp).await; + let token_resp = self + .oauth_token(auth_code, None, client_id.to_string(), client_secret) + .await; + get_access_token(token_resp).await + } + + pub async fn oauth_authorize( + &self, + client_id: &str, + scope: Option<&str>, + redirect_uri: Option<&str>, + state: Option<&str>, + pat: Option<&str>, + ) -> ServiceResponse { + let uri = generate_authorize_uri(client_id, scope, redirect_uri, state); + let req = TestRequest::get().uri(&uri).append_pat(pat).to_request(); + self.call(req).await + } + + pub async fn oauth_accept( + &self, + flow: &str, + pat: Option<&str>, + ) -> ServiceResponse { + self.call( + TestRequest::post() + .uri("/_internal/oauth/accept") + .append_pat(pat) + .set_json(RespondToOAuthClientScopes { + flow: flow.to_string(), + }) + .to_request(), + ) + .await + } + + pub async fn oauth_reject( + &self, + flow: &str, + pat: Option<&str>, + ) -> ServiceResponse { + self.call( + TestRequest::post() + .uri("/_internal/oauth/reject") + .append_pat(pat) + .set_json(RespondToOAuthClientScopes { + flow: flow.to_string(), + }) + .to_request(), + ) + .await + } + + pub async fn oauth_token( + &self, + auth_code: String, + original_redirect_uri: Option, + client_id: String, + client_secret: &str, + ) -> ServiceResponse { + self.call( + TestRequest::post() + .uri("/_internal/oauth/token") + .append_header((AUTHORIZATION, client_secret)) + .set_form(TokenRequest { + grant_type: "authorization_code".to_string(), + code: auth_code, + redirect_uri: original_redirect_uri, + client_id: serde_json::from_str(&format!( + "\"{}\"", + client_id + )) + .unwrap(), + }) + .to_request(), + ) + .await + } +} + +pub fn generate_authorize_uri( + client_id: &str, + scope: Option<&str>, + redirect_uri: Option<&str>, + state: Option<&str>, +) -> String { + format!( + "/_internal/oauth/authorize?client_id={}{}{}{}", + urlencoding::encode(client_id), + optional_query_param("redirect_uri", redirect_uri), + optional_query_param("scope", scope), + optional_query_param("state", state), + ) +} + +pub async fn get_authorize_accept_flow_id(response: ServiceResponse) -> String { + assert_status!(&response, StatusCode::OK); + test::read_body_json::(response) + .await + .flow_id +} + +pub async fn get_auth_code_from_redirect_params( + response: &ServiceResponse, +) -> String { + assert_status!(response, StatusCode::OK); + let query_params = get_redirect_location_query_params(response); + query_params.get("code").unwrap().to_string() +} + +pub async fn get_access_token(response: ServiceResponse) -> String { + assert_status!(&response, StatusCode::OK); + test::read_body_json::(response) + .await + .access_token +} + +pub fn get_redirect_location_query_params( + response: &ServiceResponse, +) -> actix_web::web::Query> { + let redirect_location = response + .headers() + .get(LOCATION) + .unwrap() + .to_str() + .unwrap() + .to_string(); + actix_web::web::Query::>::from_query( + redirect_location.split_once('?').unwrap().1, + ) + .unwrap() +} + +fn optional_query_param(key: &str, value: Option<&str>) -> String { + if let Some(val) = value { + format!("&{key}={}", urlencoding::encode(val)) + } else { + "".to_string() + } +} diff --git a/apps/labrinth/tests/common/api_v3/oauth_clients.rs b/apps/labrinth/tests/common/api_v3/oauth_clients.rs new file mode 100644 index 000000000..35b248821 --- /dev/null +++ b/apps/labrinth/tests/common/api_v3/oauth_clients.rs @@ -0,0 +1,131 @@ +use actix_http::StatusCode; +use actix_web::{ + dev::ServiceResponse, + test::{self, TestRequest}, +}; +use labrinth::{ + models::{ + oauth_clients::{OAuthClient, OAuthClientAuthorization}, + pats::Scopes, + }, + routes::v3::oauth_clients::OAuthClientEdit, +}; +use serde_json::json; + +use crate::{ + assert_status, + common::api_common::{Api, AppendsOptionalPat}, +}; + +use super::ApiV3; + +impl ApiV3 { + pub async fn add_oauth_client( + &self, + name: String, + max_scopes: Scopes, + redirect_uris: Vec, + pat: Option<&str>, + ) -> ServiceResponse { + let max_scopes = max_scopes.bits(); + let req = TestRequest::post() + .uri("/_internal/oauth/app") + .append_pat(pat) + .set_json(json!({ + "name": name, + "max_scopes": max_scopes, + "redirect_uris": redirect_uris + })) + .to_request(); + + self.call(req).await + } + + pub async fn get_user_oauth_clients( + &self, + user_id: &str, + pat: Option<&str>, + ) -> Vec { + let req = TestRequest::get() + .uri(&format!("/v3/user/{}/oauth_apps", user_id)) + .append_pat(pat) + .to_request(); + let resp = self.call(req).await; + assert_status!(&resp, StatusCode::OK); + + test::read_body_json(resp).await + } + + pub async fn get_oauth_client( + &self, + client_id: String, + pat: Option<&str>, + ) -> ServiceResponse { + let req = TestRequest::get() + .uri(&format!("/_internal/oauth/app/{}", client_id)) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + pub async fn edit_oauth_client( + &self, + client_id: &str, + edit: OAuthClientEdit, + pat: Option<&str>, + ) -> ServiceResponse { + let req = TestRequest::patch() + .uri(&format!( + "/_internal/oauth/app/{}", + urlencoding::encode(client_id) + )) + .set_json(edit) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + pub async fn delete_oauth_client( + &self, + client_id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = TestRequest::delete() + .uri(&format!("/_internal/oauth/app/{}", client_id)) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + pub async fn revoke_oauth_authorization( + &self, + client_id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = TestRequest::delete() + .uri(&format!( + "/_internal/oauth/authorizations?client_id={}", + urlencoding::encode(client_id) + )) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + pub async fn get_user_oauth_authorizations( + &self, + pat: Option<&str>, + ) -> Vec { + let req = TestRequest::get() + .uri("/_internal/oauth/authorizations") + .append_pat(pat) + .to_request(); + let resp = self.call(req).await; + assert_status!(&resp, StatusCode::OK); + + test::read_body_json(resp).await + } +} diff --git a/apps/labrinth/tests/common/api_v3/organization.rs b/apps/labrinth/tests/common/api_v3/organization.rs new file mode 100644 index 000000000..7f405cc03 --- /dev/null +++ b/apps/labrinth/tests/common/api_v3/organization.rs @@ -0,0 +1,192 @@ +use actix_http::StatusCode; +use actix_web::{ + dev::ServiceResponse, + test::{self, TestRequest}, +}; +use bytes::Bytes; +use labrinth::models::{ + organizations::Organization, users::UserId, v3::projects::Project, +}; +use serde_json::json; + +use crate::{ + assert_status, + common::api_common::{request_data::ImageData, Api, AppendsOptionalPat}, +}; + +use super::ApiV3; + +impl ApiV3 { + pub async fn create_organization( + &self, + organization_title: &str, + organization_slug: &str, + description: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::post() + .uri("/v3/organization") + .append_pat(pat) + .set_json(json!({ + "name": organization_title, + "slug": organization_slug, + "description": description, + })) + .to_request(); + self.call(req).await + } + + pub async fn get_organization( + &self, + id_or_title: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = TestRequest::get() + .uri(&format!("/v3/organization/{id_or_title}")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + pub async fn get_organization_deserialized( + &self, + id_or_title: &str, + pat: Option<&str>, + ) -> Organization { + let resp = self.get_organization(id_or_title, pat).await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + pub async fn get_organizations( + &self, + ids_or_titles: &[&str], + pat: Option<&str>, + ) -> ServiceResponse { + let ids_or_titles = serde_json::to_string(ids_or_titles).unwrap(); + let req = test::TestRequest::get() + .uri(&format!( + "/v3/organizations?ids={}", + urlencoding::encode(&ids_or_titles) + )) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + pub async fn get_organization_projects( + &self, + id_or_title: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v3/organization/{id_or_title}/projects")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + pub async fn get_organization_projects_deserialized( + &self, + id_or_title: &str, + pat: Option<&str>, + ) -> Vec { + let resp = self.get_organization_projects(id_or_title, pat).await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + pub async fn edit_organization( + &self, + id_or_title: &str, + patch: serde_json::Value, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::patch() + .uri(&format!("/v3/organization/{id_or_title}")) + .append_pat(pat) + .set_json(patch) + .to_request(); + + self.call(req).await + } + + pub async fn edit_organization_icon( + &self, + id_or_title: &str, + icon: Option, + pat: Option<&str>, + ) -> ServiceResponse { + if let Some(icon) = icon { + // If an icon is provided, upload it + let req = test::TestRequest::patch() + .uri(&format!( + "/v3/organization/{id_or_title}/icon?ext={ext}", + ext = icon.extension + )) + .append_pat(pat) + .set_payload(Bytes::from(icon.icon)) + .to_request(); + + self.call(req).await + } else { + // If no icon is provided, delete the icon + let req = test::TestRequest::delete() + .uri(&format!("/v3/organization/{id_or_title}/icon")) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + } + + pub async fn delete_organization( + &self, + id_or_title: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::delete() + .uri(&format!("/v3/organization/{id_or_title}")) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + pub async fn organization_add_project( + &self, + id_or_title: &str, + project_id_or_slug: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::post() + .uri(&format!("/v3/organization/{id_or_title}/projects")) + .append_pat(pat) + .set_json(json!({ + "project_id": project_id_or_slug, + })) + .to_request(); + + self.call(req).await + } + + pub async fn organization_remove_project( + &self, + id_or_title: &str, + project_id_or_slug: &str, + new_owner_user_id: UserId, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::delete() + .uri(&format!( + "/v3/organization/{id_or_title}/projects/{project_id_or_slug}" + )) + .set_json(json!({ + "new_owner": new_owner_user_id, + })) + .append_pat(pat) + .to_request(); + + self.call(req).await + } +} diff --git a/apps/labrinth/tests/common/api_v3/project.rs b/apps/labrinth/tests/common/api_v3/project.rs new file mode 100644 index 000000000..c59662ff9 --- /dev/null +++ b/apps/labrinth/tests/common/api_v3/project.rs @@ -0,0 +1,644 @@ +use std::collections::HashMap; + +use actix_http::StatusCode; +use actix_web::{ + dev::ServiceResponse, + test::{self, TestRequest}, +}; +use async_trait::async_trait; +use bytes::Bytes; +use chrono::{DateTime, Utc}; +use labrinth::{ + models::{organizations::Organization, projects::Project}, + search::SearchResults, + util::actix::AppendsMultipart, +}; +use rust_decimal::Decimal; +use serde_json::json; + +use crate::{ + assert_status, + common::{ + api_common::{ + models::{CommonItemType, CommonProject, CommonVersion}, + request_data::{ImageData, ProjectCreationRequestData}, + Api, ApiProject, AppendsOptionalPat, + }, + database::MOD_USER_PAT, + dummy_data::TestFile, + }, +}; + +use super::{ + request_data::{self, get_public_project_creation_data}, + ApiV3, +}; + +#[async_trait(?Send)] +impl ApiProject for ApiV3 { + async fn add_public_project( + &self, + slug: &str, + version_jar: Option, + modify_json: Option, + pat: Option<&str>, + ) -> (CommonProject, Vec) { + let creation_data = + get_public_project_creation_data(slug, version_jar, modify_json); + + // Add a project. + let slug = creation_data.slug.clone(); + let resp = self.create_project(creation_data, pat).await; + assert_status!(&resp, StatusCode::OK); + + // Approve as a moderator. + let req = TestRequest::patch() + .uri(&format!("/v3/project/{}", slug)) + .append_pat(MOD_USER_PAT) + .set_json(json!( + { + "status": "approved" + } + )) + .to_request(); + let resp = self.call(req).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let project = self.get_project(&slug, pat).await; + let project = test::read_body_json(project).await; + + // Get project's versions + let req = TestRequest::get() + .uri(&format!("/v3/project/{}/version", slug)) + .append_pat(pat) + .to_request(); + let resp = self.call(req).await; + let versions: Vec = test::read_body_json(resp).await; + + (project, versions) + } + + async fn get_public_project_creation_data_json( + &self, + slug: &str, + version_jar: Option<&TestFile>, + ) -> serde_json::Value { + request_data::get_public_project_creation_data_json(slug, version_jar) + } + + async fn create_project( + &self, + creation_data: ProjectCreationRequestData, + pat: Option<&str>, + ) -> ServiceResponse { + let req = TestRequest::post() + .uri("/v3/project") + .append_pat(pat) + .set_multipart(creation_data.segment_data) + .to_request(); + self.call(req).await + } + + async fn remove_project( + &self, + project_slug_or_id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::delete() + .uri(&format!("/v3/project/{project_slug_or_id}")) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + async fn get_project( + &self, + id_or_slug: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = TestRequest::get() + .uri(&format!("/v3/project/{id_or_slug}")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_project_deserialized_common( + &self, + id_or_slug: &str, + pat: Option<&str>, + ) -> CommonProject { + let resp = self.get_project(id_or_slug, pat).await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let project: Project = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(project).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn get_projects( + &self, + ids_or_slugs: &[&str], + pat: Option<&str>, + ) -> ServiceResponse { + let ids_or_slugs = serde_json::to_string(ids_or_slugs).unwrap(); + let req = test::TestRequest::get() + .uri(&format!( + "/v3/projects?ids={encoded}", + encoded = urlencoding::encode(&ids_or_slugs) + )) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_project_dependencies( + &self, + id_or_slug: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = TestRequest::get() + .uri(&format!("/v3/project/{id_or_slug}/dependencies")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_user_projects( + &self, + user_id_or_username: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v3/user/{}/projects", user_id_or_username)) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_user_projects_deserialized_common( + &self, + user_id_or_username: &str, + pat: Option<&str>, + ) -> Vec { + let resp = self.get_user_projects(user_id_or_username, pat).await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let projects: Vec = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(projects).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn edit_project( + &self, + id_or_slug: &str, + patch: serde_json::Value, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::patch() + .uri(&format!("/v3/project/{id_or_slug}")) + .append_pat(pat) + .set_json(patch) + .to_request(); + + self.call(req).await + } + + async fn edit_project_bulk( + &self, + ids_or_slugs: &[&str], + patch: serde_json::Value, + pat: Option<&str>, + ) -> ServiceResponse { + let projects_str = ids_or_slugs + .iter() + .map(|s| format!("\"{}\"", s)) + .collect::>() + .join(","); + let req = test::TestRequest::patch() + .uri(&format!( + "/v3/projects?ids={encoded}", + encoded = urlencoding::encode(&format!("[{projects_str}]")) + )) + .append_pat(pat) + .set_json(patch) + .to_request(); + + self.call(req).await + } + + async fn edit_project_icon( + &self, + id_or_slug: &str, + icon: Option, + pat: Option<&str>, + ) -> ServiceResponse { + if let Some(icon) = icon { + // If an icon is provided, upload it + let req = test::TestRequest::patch() + .uri(&format!( + "/v3/project/{id_or_slug}/icon?ext={ext}", + ext = icon.extension + )) + .append_pat(pat) + .set_payload(Bytes::from(icon.icon)) + .to_request(); + + self.call(req).await + } else { + // If no icon is provided, delete the icon + let req = test::TestRequest::delete() + .uri(&format!("/v3/project/{id_or_slug}/icon")) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + } + + async fn create_report( + &self, + report_type: &str, + id: &str, + item_type: CommonItemType, + body: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::post() + .uri("/v3/report") + .append_pat(pat) + .set_json(json!( + { + "report_type": report_type, + "item_id": id, + "item_type": item_type.as_str(), + "body": body, + } + )) + .to_request(); + + self.call(req).await + } + + async fn get_report(&self, id: &str, pat: Option<&str>) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v3/report/{id}")) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + async fn get_reports( + &self, + ids: &[&str], + pat: Option<&str>, + ) -> ServiceResponse { + let ids_str = serde_json::to_string(ids).unwrap(); + let req = test::TestRequest::get() + .uri(&format!( + "/v3/reports?ids={encoded}", + encoded = urlencoding::encode(&ids_str) + )) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + async fn get_user_reports(&self, pat: Option<&str>) -> ServiceResponse { + let req = test::TestRequest::get() + .uri("/v3/report") + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + async fn edit_report( + &self, + id: &str, + patch: serde_json::Value, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::patch() + .uri(&format!("/v3/report/{id}")) + .append_pat(pat) + .set_json(patch) + .to_request(); + + self.call(req).await + } + + async fn delete_report( + &self, + id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::delete() + .uri(&format!("/v3/report/{id}")) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + #[allow(clippy::too_many_arguments)] + async fn add_gallery_item( + &self, + id_or_slug: &str, + image: ImageData, + featured: bool, + title: Option, + description: Option, + ordering: Option, + pat: Option<&str>, + ) -> ServiceResponse { + let mut url = format!( + "/v3/project/{id_or_slug}/gallery?ext={ext}&featured={featured}", + ext = image.extension, + featured = featured + ); + if let Some(title) = title { + url.push_str(&format!("&title={}", title)); + } + if let Some(description) = description { + url.push_str(&format!("&description={}", description)); + } + if let Some(ordering) = ordering { + url.push_str(&format!("&ordering={}", ordering)); + } + + let req = test::TestRequest::post() + .uri(&url) + .append_pat(pat) + .set_payload(Bytes::from(image.icon)) + .to_request(); + + self.call(req).await + } + + async fn edit_gallery_item( + &self, + id_or_slug: &str, + image_url: &str, + patch: HashMap, + pat: Option<&str>, + ) -> ServiceResponse { + let mut url = format!( + "/v3/project/{id_or_slug}/gallery?url={image_url}", + image_url = urlencoding::encode(image_url) + ); + + for (key, value) in patch { + url.push_str(&format!( + "&{key}={value}", + key = key, + value = urlencoding::encode(&value) + )); + } + + let req = test::TestRequest::patch() + .uri(&url) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + async fn remove_gallery_item( + &self, + id_or_slug: &str, + url: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::delete() + .uri(&format!( + "/v3/project/{id_or_slug}/gallery?url={url}", + url = url + )) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + async fn get_thread(&self, id: &str, pat: Option<&str>) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v3/thread/{id}")) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + async fn get_threads( + &self, + ids: &[&str], + pat: Option<&str>, + ) -> ServiceResponse { + let ids_str = serde_json::to_string(ids).unwrap(); + let req = test::TestRequest::get() + .uri(&format!( + "/v3/threads?ids={encoded}", + encoded = urlencoding::encode(&ids_str) + )) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + async fn write_to_thread( + &self, + id: &str, + r#type: &str, + content: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::post() + .uri(&format!("/v3/thread/{id}")) + .append_pat(pat) + .set_json(json!({ + "body": { + "type": r#type, + "body": content + } + })) + .to_request(); + + self.call(req).await + } + + async fn get_moderation_inbox(&self, pat: Option<&str>) -> ServiceResponse { + let req = test::TestRequest::get() + .uri("/v3/thread/inbox") + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + async fn read_thread( + &self, + id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::post() + .uri(&format!("/v3/thread/{id}/read")) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + async fn delete_thread_message( + &self, + id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::delete() + .uri(&format!("/v3/message/{id}")) + .append_pat(pat) + .to_request(); + + self.call(req).await + } +} + +impl ApiV3 { + pub async fn get_project_deserialized( + &self, + id_or_slug: &str, + pat: Option<&str>, + ) -> Project { + let resp = self.get_project(id_or_slug, pat).await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + pub async fn get_project_organization( + &self, + id_or_slug: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v3/project/{id_or_slug}/organization")) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + pub async fn get_project_organization_deserialized( + &self, + id_or_slug: &str, + pat: Option<&str>, + ) -> Organization { + let resp = self.get_project_organization(id_or_slug, pat).await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + pub async fn search_deserialized( + &self, + query: Option<&str>, + facets: Option, + pat: Option<&str>, + ) -> SearchResults { + let query_field = if let Some(query) = query { + format!("&query={}", urlencoding::encode(query)) + } else { + "".to_string() + }; + + let facets_field = if let Some(facets) = facets { + format!("&facets={}", urlencoding::encode(&facets.to_string())) + } else { + "".to_string() + }; + + let req = test::TestRequest::get() + .uri(&format!("/v3/search?{}{}", query_field, facets_field)) + .append_pat(pat) + .to_request(); + let resp = self.call(req).await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + pub async fn get_analytics_revenue( + &self, + id_or_slugs: Vec<&str>, + ids_are_version_ids: bool, + start_date: Option>, + end_date: Option>, + resolution_minutes: Option, + pat: Option<&str>, + ) -> ServiceResponse { + let pv_string = if ids_are_version_ids { + let version_string: String = + serde_json::to_string(&id_or_slugs).unwrap(); + let version_string = urlencoding::encode(&version_string); + format!("version_ids={}", version_string) + } else { + let projects_string: String = + serde_json::to_string(&id_or_slugs).unwrap(); + let projects_string = urlencoding::encode(&projects_string); + format!("project_ids={}", projects_string) + }; + + let mut extra_args = String::new(); + if let Some(start_date) = start_date { + let start_date = start_date.to_rfc3339(); + // let start_date = serde_json::to_string(&start_date).unwrap(); + let start_date = urlencoding::encode(&start_date); + extra_args.push_str(&format!("&start_date={start_date}")); + } + if let Some(end_date) = end_date { + let end_date = end_date.to_rfc3339(); + // let end_date = serde_json::to_string(&end_date).unwrap(); + let end_date = urlencoding::encode(&end_date); + extra_args.push_str(&format!("&end_date={end_date}")); + } + if let Some(resolution_minutes) = resolution_minutes { + extra_args.push_str(&format!( + "&resolution_minutes={}", + resolution_minutes + )); + } + + let req = test::TestRequest::get() + .uri(&format!("/v3/analytics/revenue?{pv_string}{extra_args}",)) + .append_pat(pat) + .to_request(); + + self.call(req).await + } + + pub async fn get_analytics_revenue_deserialized( + &self, + id_or_slugs: Vec<&str>, + ids_are_version_ids: bool, + start_date: Option>, + end_date: Option>, + resolution_minutes: Option, + pat: Option<&str>, + ) -> HashMap> { + let resp = self + .get_analytics_revenue( + id_or_slugs, + ids_are_version_ids, + start_date, + end_date, + resolution_minutes, + pat, + ) + .await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } +} diff --git a/apps/labrinth/tests/common/api_v3/request_data.rs b/apps/labrinth/tests/common/api_v3/request_data.rs new file mode 100644 index 000000000..790503b86 --- /dev/null +++ b/apps/labrinth/tests/common/api_v3/request_data.rs @@ -0,0 +1,145 @@ +#![allow(dead_code)] +use serde_json::json; + +use crate::common::{ + api_common::request_data::{ + ProjectCreationRequestData, VersionCreationRequestData, + }, + dummy_data::TestFile, +}; +use labrinth::{ + models::projects::ProjectId, + util::actix::{MultipartSegment, MultipartSegmentData}, +}; + +pub fn get_public_project_creation_data( + slug: &str, + version_jar: Option, + modify_json: Option, +) -> ProjectCreationRequestData { + let mut json_data = + get_public_project_creation_data_json(slug, version_jar.as_ref()); + if let Some(modify_json) = modify_json { + json_patch::patch(&mut json_data, &modify_json).unwrap(); + } + let multipart_data = + get_public_creation_data_multipart(&json_data, version_jar.as_ref()); + ProjectCreationRequestData { + slug: slug.to_string(), + jar: version_jar, + segment_data: multipart_data, + } +} + +pub fn get_public_version_creation_data( + project_id: ProjectId, + version_number: &str, + version_jar: TestFile, + ordering: Option, + // closure that takes in a &mut serde_json::Value + // and modifies it before it is serialized and sent + modify_json: Option, +) -> VersionCreationRequestData { + let mut json_data = get_public_version_creation_data_json( + version_number, + ordering, + &version_jar, + ); + json_data["project_id"] = json!(project_id); + if let Some(modify_json) = modify_json { + json_patch::patch(&mut json_data, &modify_json).unwrap(); + } + + let multipart_data = + get_public_creation_data_multipart(&json_data, Some(&version_jar)); + VersionCreationRequestData { + version: version_number.to_string(), + jar: Some(version_jar), + segment_data: multipart_data, + } +} + +pub fn get_public_version_creation_data_json( + version_number: &str, + ordering: Option, + version_jar: &TestFile, +) -> serde_json::Value { + let is_modpack = version_jar.project_type() == "modpack"; + let mut j = json!({ + "file_parts": [version_jar.filename()], + "version_number": version_number, + "version_title": "start", + "dependencies": [], + "release_channel": "release", + "loaders": [if is_modpack { "mrpack" } else { "fabric" }], + "featured": true, + + // Loader fields + "game_versions": ["1.20.1"], + "singleplayer": true, + "client_and_server": true, + "client_only": true, + "server_only": false, + }); + if is_modpack { + j["mrpack_loaders"] = json!(["fabric"]); + } + if let Some(ordering) = ordering { + j["ordering"] = json!(ordering); + } + j +} + +pub fn get_public_project_creation_data_json( + slug: &str, + version_jar: Option<&TestFile>, +) -> serde_json::Value { + let initial_versions = if let Some(jar) = version_jar { + json!([get_public_version_creation_data_json("1.2.3", None, jar)]) + } else { + json!([]) + }; + + let is_draft = version_jar.is_none(); + json!( + { + "name": format!("Test Project {slug}"), + "slug": slug, + "summary": "A dummy project for testing with.", + "description": "This project is approved, and versions are listed.", + "initial_versions": initial_versions, + "is_draft": is_draft, + "categories": [], + "license_id": "MIT", + } + ) +} + +pub fn get_public_creation_data_multipart( + json_data: &serde_json::Value, + version_jar: Option<&TestFile>, +) -> Vec { + // Basic json + let json_segment = MultipartSegment { + name: "data".to_string(), + filename: None, + content_type: Some("application/json".to_string()), + data: MultipartSegmentData::Text( + serde_json::to_string(json_data).unwrap(), + ), + }; + + if let Some(jar) = version_jar { + // Basic file + let file_segment = MultipartSegment { + name: jar.filename(), + filename: Some(jar.filename()), + content_type: Some("application/java-archive".to_string()), + data: MultipartSegmentData::Binary(jar.bytes()), + }; + + vec![json_segment, file_segment] + } else { + vec![json_segment] + } +} diff --git a/apps/labrinth/tests/common/api_v3/tags.rs b/apps/labrinth/tests/common/api_v3/tags.rs new file mode 100644 index 000000000..6fa0b9a5b --- /dev/null +++ b/apps/labrinth/tests/common/api_v3/tags.rs @@ -0,0 +1,107 @@ +use actix_http::StatusCode; +use actix_web::{ + dev::ServiceResponse, + test::{self, TestRequest}, +}; +use async_trait::async_trait; +use labrinth::routes::v3::tags::{GameData, LoaderData}; +use labrinth::{ + database::models::loader_fields::LoaderFieldEnumValue, + routes::v3::tags::CategoryData, +}; + +use crate::{ + assert_status, + common::{ + api_common::{ + models::{CommonCategoryData, CommonLoaderData}, + Api, ApiTags, AppendsOptionalPat, + }, + database::ADMIN_USER_PAT, + }, +}; + +use super::ApiV3; + +#[async_trait(?Send)] +impl ApiTags for ApiV3 { + async fn get_loaders(&self) -> ServiceResponse { + let req = TestRequest::get() + .uri("/v3/tag/loader") + .append_pat(ADMIN_USER_PAT) + .to_request(); + self.call(req).await + } + + async fn get_loaders_deserialized_common(&self) -> Vec { + let resp = self.get_loaders().await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: Vec = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn get_categories(&self) -> ServiceResponse { + let req = TestRequest::get() + .uri("/v3/tag/category") + .append_pat(ADMIN_USER_PAT) + .to_request(); + self.call(req).await + } + + async fn get_categories_deserialized_common( + &self, + ) -> Vec { + let resp = self.get_categories().await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: Vec = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } +} + +impl ApiV3 { + pub async fn get_loaders_deserialized(&self) -> Vec { + let resp = self.get_loaders().await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + pub async fn get_loader_field_variants( + &self, + loader_field: &str, + ) -> ServiceResponse { + let req = TestRequest::get() + .uri(&format!("/v3/loader_field?loader_field={}", loader_field)) + .append_pat(ADMIN_USER_PAT) + .to_request(); + self.call(req).await + } + + pub async fn get_loader_field_variants_deserialized( + &self, + loader_field: &str, + ) -> Vec { + let resp = self.get_loader_field_variants(loader_field).await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + async fn get_games(&self) -> ServiceResponse { + let req = TestRequest::get() + .uri("/v3/games") + .append_pat(ADMIN_USER_PAT) + .to_request(); + self.call(req).await + } + + pub async fn get_games_deserialized(&self) -> Vec { + let resp = self.get_games().await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } +} diff --git a/apps/labrinth/tests/common/api_v3/team.rs b/apps/labrinth/tests/common/api_v3/team.rs new file mode 100644 index 000000000..0b188b593 --- /dev/null +++ b/apps/labrinth/tests/common/api_v3/team.rs @@ -0,0 +1,333 @@ +use actix_http::StatusCode; +use actix_web::{dev::ServiceResponse, test}; +use async_trait::async_trait; +use labrinth::models::{ + notifications::Notification, + teams::{OrganizationPermissions, ProjectPermissions, TeamMember}, +}; +use serde_json::json; + +use crate::{ + assert_status, + common::api_common::{ + models::{CommonNotification, CommonTeamMember}, + Api, ApiTeams, AppendsOptionalPat, + }, +}; + +use super::ApiV3; + +impl ApiV3 { + pub async fn get_organization_members_deserialized( + &self, + id_or_title: &str, + pat: Option<&str>, + ) -> Vec { + let resp = self.get_organization_members(id_or_title, pat).await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + pub async fn get_team_members_deserialized( + &self, + team_id: &str, + pat: Option<&str>, + ) -> Vec { + let resp = self.get_team_members(team_id, pat).await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + pub async fn get_project_members_deserialized( + &self, + project_id: &str, + pat: Option<&str>, + ) -> Vec { + let resp = self.get_project_members(project_id, pat).await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } +} + +#[async_trait(?Send)] +impl ApiTeams for ApiV3 { + async fn get_team_members( + &self, + id_or_title: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v3/team/{id_or_title}/members")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_team_members_deserialized_common( + &self, + id_or_title: &str, + pat: Option<&str>, + ) -> Vec { + let resp = self.get_team_members(id_or_title, pat).await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: Vec = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn get_teams_members( + &self, + ids_or_titles: &[&str], + pat: Option<&str>, + ) -> ServiceResponse { + let ids_or_titles = serde_json::to_string(ids_or_titles).unwrap(); + let req = test::TestRequest::get() + .uri(&format!( + "/v3/teams?ids={}", + urlencoding::encode(&ids_or_titles) + )) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_project_members( + &self, + id_or_title: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v3/project/{id_or_title}/members")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_project_members_deserialized_common( + &self, + id_or_title: &str, + pat: Option<&str>, + ) -> Vec { + let resp = self.get_project_members(id_or_title, pat).await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: Vec = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn get_organization_members( + &self, + id_or_title: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v3/organization/{id_or_title}/members")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_organization_members_deserialized_common( + &self, + id_or_title: &str, + pat: Option<&str>, + ) -> Vec { + let resp = self.get_organization_members(id_or_title, pat).await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: Vec = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn join_team( + &self, + team_id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::post() + .uri(&format!("/v3/team/{team_id}/join")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn remove_from_team( + &self, + team_id: &str, + user_id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::delete() + .uri(&format!("/v3/team/{team_id}/members/{user_id}")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn edit_team_member( + &self, + team_id: &str, + user_id: &str, + patch: serde_json::Value, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::patch() + .uri(&format!("/v3/team/{team_id}/members/{user_id}")) + .append_pat(pat) + .set_json(patch) + .to_request(); + self.call(req).await + } + + async fn transfer_team_ownership( + &self, + team_id: &str, + user_id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::patch() + .uri(&format!("/v3/team/{team_id}/owner")) + .append_pat(pat) + .set_json(json!({ + "user_id": user_id, + })) + .to_request(); + self.call(req).await + } + + async fn get_user_notifications( + &self, + user_id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v3/user/{user_id}/notifications")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_user_notifications_deserialized_common( + &self, + user_id: &str, + pat: Option<&str>, + ) -> Vec { + let resp = self.get_user_notifications(user_id, pat).await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: Vec = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn get_notification( + &self, + notification_id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v3/notification/{notification_id}")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_notifications( + &self, + notification_ids: &[&str], + pat: Option<&str>, + ) -> ServiceResponse { + let notification_ids = serde_json::to_string(notification_ids).unwrap(); + let req = test::TestRequest::get() + .uri(&format!( + "/v3/notifications?ids={}", + urlencoding::encode(¬ification_ids) + )) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn mark_notification_read( + &self, + notification_id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::patch() + .uri(&format!("/v3/notification/{notification_id}")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn mark_notifications_read( + &self, + notification_ids: &[&str], + pat: Option<&str>, + ) -> ServiceResponse { + let notification_ids = serde_json::to_string(notification_ids).unwrap(); + let req = test::TestRequest::patch() + .uri(&format!( + "/v3/notifications?ids={}", + urlencoding::encode(¬ification_ids) + )) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn add_user_to_team( + &self, + team_id: &str, + user_id: &str, + project_permissions: Option, + organization_permissions: Option, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::post() + .uri(&format!("/v3/team/{team_id}/members")) + .append_pat(pat) + .set_json(json!( { + "user_id": user_id, + "permissions" : project_permissions.map(|p| p.bits()).unwrap_or_default(), + "organization_permissions" : organization_permissions.map(|p| p.bits()), + })) + .to_request(); + self.call(req).await + } + + async fn delete_notification( + &self, + notification_id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::delete() + .uri(&format!("/v3/notification/{notification_id}")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn delete_notifications( + &self, + notification_ids: &[&str], + pat: Option<&str>, + ) -> ServiceResponse { + let notification_ids = serde_json::to_string(notification_ids).unwrap(); + let req = test::TestRequest::delete() + .uri(&format!( + "/v3/notifications?ids={}", + urlencoding::encode(¬ification_ids) + )) + .append_pat(pat) + .to_request(); + self.call(req).await + } +} diff --git a/apps/labrinth/tests/common/api_v3/user.rs b/apps/labrinth/tests/common/api_v3/user.rs new file mode 100644 index 000000000..9e2c9f7fd --- /dev/null +++ b/apps/labrinth/tests/common/api_v3/user.rs @@ -0,0 +1,56 @@ +use actix_web::{dev::ServiceResponse, test}; +use async_trait::async_trait; + +use crate::common::api_common::{Api, ApiUser, AppendsOptionalPat}; + +use super::ApiV3; + +#[async_trait(?Send)] +impl ApiUser for ApiV3 { + async fn get_user( + &self, + user_id_or_username: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v3/user/{}", user_id_or_username)) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_current_user(&self, pat: Option<&str>) -> ServiceResponse { + let req = test::TestRequest::get() + .uri("/v3/user") + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn edit_user( + &self, + user_id_or_username: &str, + patch: serde_json::Value, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::patch() + .uri(&format!("/v3/user/{}", user_id_or_username)) + .append_pat(pat) + .set_json(patch) + .to_request(); + + self.call(req).await + } + + async fn delete_user( + &self, + user_id_or_username: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::delete() + .uri(&format!("/v3/user/{}", user_id_or_username)) + .append_pat(pat) + .to_request(); + self.call(req).await + } +} diff --git a/apps/labrinth/tests/common/api_v3/version.rs b/apps/labrinth/tests/common/api_v3/version.rs new file mode 100644 index 000000000..c563396ef --- /dev/null +++ b/apps/labrinth/tests/common/api_v3/version.rs @@ -0,0 +1,585 @@ +use std::collections::HashMap; + +use super::{ + request_data::{self, get_public_version_creation_data}, + ApiV3, +}; +use crate::{ + assert_status, + common::{ + api_common::{ + models::CommonVersion, Api, ApiVersion, AppendsOptionalPat, + }, + dummy_data::TestFile, + }, +}; +use actix_http::StatusCode; +use actix_web::{ + dev::ServiceResponse, + test::{self, TestRequest}, +}; +use async_trait::async_trait; +use labrinth::{ + models::{ + projects::{ProjectId, VersionType}, + v3::projects::Version, + }, + routes::v3::version_file::FileUpdateData, + util::actix::AppendsMultipart, +}; +use serde_json::json; + +pub fn url_encode_json_serialized_vec(elements: &[String]) -> String { + let serialized = serde_json::to_string(&elements).unwrap(); + urlencoding::encode(&serialized).to_string() +} + +impl ApiV3 { + pub async fn add_public_version_deserialized( + &self, + project_id: ProjectId, + version_number: &str, + version_jar: TestFile, + ordering: Option, + modify_json: Option, + pat: Option<&str>, + ) -> Version { + let resp = self + .add_public_version( + project_id, + version_number, + version_jar, + ordering, + modify_json, + pat, + ) + .await; + assert_status!(&resp, StatusCode::OK); + let value: serde_json::Value = test::read_body_json(resp).await; + let version_id = value["id"].as_str().unwrap(); + let version = self.get_version(version_id, pat).await; + assert_status!(&version, StatusCode::OK); + test::read_body_json(version).await + } + + pub async fn get_version_deserialized( + &self, + id: &str, + pat: Option<&str>, + ) -> Version { + let resp = self.get_version(id, pat).await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + pub async fn get_versions_deserialized( + &self, + version_ids: Vec, + pat: Option<&str>, + ) -> Vec { + let resp = self.get_versions(version_ids, pat).await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } + + pub async fn update_individual_files( + &self, + algorithm: &str, + hashes: Vec, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::post() + .uri("/v3/version_files/update_individual") + .append_pat(pat) + .set_json(json!({ + "algorithm": algorithm, + "hashes": hashes + })) + .to_request(); + self.call(req).await + } + + pub async fn update_individual_files_deserialized( + &self, + algorithm: &str, + hashes: Vec, + pat: Option<&str>, + ) -> HashMap { + let resp = self.update_individual_files(algorithm, hashes, pat).await; + assert_status!(&resp, StatusCode::OK); + test::read_body_json(resp).await + } +} + +#[async_trait(?Send)] +impl ApiVersion for ApiV3 { + async fn add_public_version( + &self, + project_id: ProjectId, + version_number: &str, + version_jar: TestFile, + ordering: Option, + modify_json: Option, + pat: Option<&str>, + ) -> ServiceResponse { + let creation_data = get_public_version_creation_data( + project_id, + version_number, + version_jar, + ordering, + modify_json, + ); + + // Add a versiom. + let req = TestRequest::post() + .uri("/v3/version") + .append_pat(pat) + .set_multipart(creation_data.segment_data) + .to_request(); + self.call(req).await + } + + async fn add_public_version_deserialized_common( + &self, + project_id: ProjectId, + version_number: &str, + version_jar: TestFile, + ordering: Option, + modify_json: Option, + pat: Option<&str>, + ) -> CommonVersion { + let resp = self + .add_public_version( + project_id, + version_number, + version_jar, + ordering, + modify_json, + pat, + ) + .await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: Version = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn get_version( + &self, + id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = TestRequest::get() + .uri(&format!("/v3/version/{id}")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_version_deserialized_common( + &self, + id: &str, + pat: Option<&str>, + ) -> CommonVersion { + let resp = self.get_version(id, pat).await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: Version = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn edit_version( + &self, + version_id: &str, + patch: serde_json::Value, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::patch() + .uri(&format!("/v3/version/{version_id}")) + .append_pat(pat) + .set_json(patch) + .to_request(); + + self.call(req).await + } + + async fn download_version_redirect( + &self, + hash: &str, + algorithm: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v3/version_file/{hash}/download",)) + .set_json(json!({ + "algorithm": algorithm, + })) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_version_from_hash( + &self, + hash: &str, + algorithm: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = test::TestRequest::get() + .uri(&format!("/v3/version_file/{hash}?algorithm={algorithm}")) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + async fn get_version_from_hash_deserialized_common( + &self, + hash: &str, + algorithm: &str, + pat: Option<&str>, + ) -> CommonVersion { + let resp = self.get_version_from_hash(hash, algorithm, pat).await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: Version = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn get_versions_from_hashes( + &self, + hashes: &[&str], + algorithm: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let req = TestRequest::post() + .uri("/v3/version_files") + .append_pat(pat) + .set_json(json!({ + "hashes": hashes, + "algorithm": algorithm, + })) + .to_request(); + self.call(req).await + } + + async fn get_versions_from_hashes_deserialized_common( + &self, + hashes: &[&str], + algorithm: &str, + pat: Option<&str>, + ) -> HashMap { + let resp = self.get_versions_from_hashes(hashes, algorithm, pat).await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: HashMap = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn get_update_from_hash( + &self, + hash: &str, + algorithm: &str, + loaders: Option>, + game_versions: Option>, + version_types: Option>, + pat: Option<&str>, + ) -> ServiceResponse { + let mut json = json!({}); + if let Some(loaders) = loaders { + json["loaders"] = serde_json::to_value(loaders).unwrap(); + } + if let Some(game_versions) = game_versions { + json["loader_fields"] = json!({ + "game_versions": game_versions, + }); + } + if let Some(version_types) = version_types { + json["version_types"] = + serde_json::to_value(version_types).unwrap(); + } + + let req = test::TestRequest::post() + .uri(&format!( + "/v3/version_file/{hash}/update?algorithm={algorithm}" + )) + .append_pat(pat) + .set_json(json) + .to_request(); + self.call(req).await + } + + async fn get_update_from_hash_deserialized_common( + &self, + hash: &str, + algorithm: &str, + loaders: Option>, + game_versions: Option>, + version_types: Option>, + pat: Option<&str>, + ) -> CommonVersion { + let resp = self + .get_update_from_hash( + hash, + algorithm, + loaders, + game_versions, + version_types, + pat, + ) + .await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: Version = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn update_files( + &self, + algorithm: &str, + hashes: Vec, + loaders: Option>, + game_versions: Option>, + version_types: Option>, + pat: Option<&str>, + ) -> ServiceResponse { + let mut json = json!({ + "algorithm": algorithm, + "hashes": hashes, + }); + if let Some(loaders) = loaders { + json["loaders"] = serde_json::to_value(loaders).unwrap(); + } + if let Some(game_versions) = game_versions { + json["game_versions"] = + serde_json::to_value(game_versions).unwrap(); + } + if let Some(version_types) = version_types { + json["version_types"] = + serde_json::to_value(version_types).unwrap(); + } + + let req = test::TestRequest::post() + .uri("/v3/version_files/update") + .append_pat(pat) + .set_json(json) + .to_request(); + self.call(req).await + } + + async fn update_files_deserialized_common( + &self, + algorithm: &str, + hashes: Vec, + loaders: Option>, + game_versions: Option>, + version_types: Option>, + pat: Option<&str>, + ) -> HashMap { + let resp = self + .update_files( + algorithm, + hashes, + loaders, + game_versions, + version_types, + pat, + ) + .await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: HashMap = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + // TODO: Not all fields are tested currently in the v3 tests, only the v2-v3 relevant ones are + #[allow(clippy::too_many_arguments)] + async fn get_project_versions( + &self, + project_id_slug: &str, + game_versions: Option>, + loaders: Option>, + featured: Option, + version_type: Option, + limit: Option, + offset: Option, + pat: Option<&str>, + ) -> ServiceResponse { + let mut query_string = String::new(); + if let Some(game_versions) = game_versions { + query_string.push_str(&format!( + "&game_versions={}", + urlencoding::encode( + &serde_json::to_string(&game_versions).unwrap() + ) + )); + } + if let Some(loaders) = loaders { + query_string.push_str(&format!( + "&loaders={}", + urlencoding::encode(&serde_json::to_string(&loaders).unwrap()) + )); + } + if let Some(featured) = featured { + query_string.push_str(&format!("&featured={}", featured)); + } + if let Some(version_type) = version_type { + query_string.push_str(&format!("&version_type={}", version_type)); + } + if let Some(limit) = limit { + let limit = limit.to_string(); + query_string.push_str(&format!("&limit={}", limit)); + } + if let Some(offset) = offset { + let offset = offset.to_string(); + query_string.push_str(&format!("&offset={}", offset)); + } + + let req = test::TestRequest::get() + .uri(&format!( + "/v3/project/{project_id_slug}/version?{}", + query_string.trim_start_matches('&') + )) + .append_pat(pat) + .to_request(); + self.call(req).await + } + + #[allow(clippy::too_many_arguments)] + async fn get_project_versions_deserialized_common( + &self, + slug: &str, + game_versions: Option>, + loaders: Option>, + featured: Option, + version_type: Option, + limit: Option, + offset: Option, + pat: Option<&str>, + ) -> Vec { + let resp = self + .get_project_versions( + slug, + game_versions, + loaders, + featured, + version_type, + limit, + offset, + pat, + ) + .await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: Vec = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn edit_version_ordering( + &self, + version_id: &str, + ordering: Option, + pat: Option<&str>, + ) -> ServiceResponse { + let request = test::TestRequest::patch() + .uri(&format!("/v3/version/{version_id}")) + .set_json(json!( + { + "ordering": ordering + } + )) + .append_pat(pat) + .to_request(); + self.call(request).await + } + + async fn get_versions( + &self, + version_ids: Vec, + pat: Option<&str>, + ) -> ServiceResponse { + let ids = url_encode_json_serialized_vec(&version_ids); + let request = test::TestRequest::get() + .uri(&format!("/v3/versions?ids={}", ids)) + .append_pat(pat) + .to_request(); + self.call(request).await + } + + async fn get_versions_deserialized_common( + &self, + version_ids: Vec, + pat: Option<&str>, + ) -> Vec { + let resp = self.get_versions(version_ids, pat).await; + assert_status!(&resp, StatusCode::OK); + // First, deserialize to the non-common format (to test the response is valid for this api version) + let v: Vec = test::read_body_json(resp).await; + // Then, deserialize to the common format + let value = serde_json::to_value(v).unwrap(); + serde_json::from_value(value).unwrap() + } + + async fn upload_file_to_version( + &self, + version_id: &str, + file: &TestFile, + pat: Option<&str>, + ) -> ServiceResponse { + let m = request_data::get_public_creation_data_multipart( + &json!({ + "file_parts": [file.filename()] + }), + Some(file), + ); + let request = test::TestRequest::post() + .uri(&format!( + "/v3/version/{version_id}/file", + version_id = version_id + )) + .append_pat(pat) + .set_multipart(m) + .to_request(); + self.call(request).await + } + + async fn remove_version( + &self, + version_id: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let request = test::TestRequest::delete() + .uri(&format!( + "/v3/version/{version_id}", + version_id = version_id + )) + .append_pat(pat) + .to_request(); + self.call(request).await + } + + async fn remove_version_file( + &self, + hash: &str, + pat: Option<&str>, + ) -> ServiceResponse { + let request = test::TestRequest::delete() + .uri(&format!("/v3/version_file/{hash}")) + .append_pat(pat) + .to_request(); + self.call(request).await + } +} diff --git a/apps/labrinth/tests/common/asserts.rs b/apps/labrinth/tests/common/asserts.rs new file mode 100644 index 000000000..f4b7330e7 --- /dev/null +++ b/apps/labrinth/tests/common/asserts.rs @@ -0,0 +1,50 @@ +#![allow(dead_code)] + +use crate::common::get_json_val_str; +use itertools::Itertools; +use labrinth::models::v3::projects::Version; + +use super::api_common::models::CommonVersion; + +#[macro_export] +macro_rules! assert_status { + ($response:expr, $status:expr) => { + assert_eq!( + $response.status(), + $status, + "{:#?}", + $response.response().body() + ); + }; +} + +#[macro_export] +macro_rules! assert_any_status_except { + ($response:expr, $status:expr) => { + assert_ne!( + $response.status(), + $status, + "{:#?}", + $response.response().body() + ); + }; +} + +pub fn assert_version_ids(versions: &[Version], expected_ids: Vec) { + let version_ids = versions + .iter() + .map(|v| get_json_val_str(v.id)) + .collect_vec(); + assert_eq!(version_ids, expected_ids); +} + +pub fn assert_common_version_ids( + versions: &[CommonVersion], + expected_ids: Vec, +) { + let version_ids = versions + .iter() + .map(|v| get_json_val_str(v.id)) + .collect_vec(); + assert_eq!(version_ids, expected_ids); +} diff --git a/apps/labrinth/tests/common/database.rs b/apps/labrinth/tests/common/database.rs new file mode 100644 index 000000000..95e263c94 --- /dev/null +++ b/apps/labrinth/tests/common/database.rs @@ -0,0 +1,270 @@ +#![allow(dead_code)] + +use labrinth::{database::redis::RedisPool, search}; +use sqlx::{postgres::PgPoolOptions, PgPool}; +use std::time::Duration; +use url::Url; + +use crate::common::{dummy_data, environment::TestEnvironment}; + +use super::{api_v3::ApiV3, dummy_data::DUMMY_DATA_UPDATE}; + +// The dummy test database adds a fair bit of 'dummy' data to test with. +// Some constants are used to refer to that data, and are described here. +// The rest can be accessed in the TestEnvironment 'dummy' field. + +// The user IDs are as follows: +pub const ADMIN_USER_ID: &str = "1"; +pub const MOD_USER_ID: &str = "2"; +pub const USER_USER_ID: &str = "3"; // This is the 'main' user ID, and is used for most tests. +pub const FRIEND_USER_ID: &str = "4"; // This is exactly the same as USER_USER_ID, but could be used for testing friend-only endpoints (ie: teams, etc) +pub const ENEMY_USER_ID: &str = "5"; // This is exactly the same as USER_USER_ID, but could be used for testing friend-only endpoints (ie: teams, etc) + +pub const ADMIN_USER_ID_PARSED: i64 = 1; +pub const MOD_USER_ID_PARSED: i64 = 2; +pub const USER_USER_ID_PARSED: i64 = 3; +pub const FRIEND_USER_ID_PARSED: i64 = 4; +pub const ENEMY_USER_ID_PARSED: i64 = 5; + +// These are full-scoped PATs- as if the user was logged in (including illegal scopes). +pub const ADMIN_USER_PAT: Option<&str> = Some("mrp_patadmin"); +pub const MOD_USER_PAT: Option<&str> = Some("mrp_patmoderator"); +pub const USER_USER_PAT: Option<&str> = Some("mrp_patuser"); +pub const FRIEND_USER_PAT: Option<&str> = Some("mrp_patfriend"); +pub const ENEMY_USER_PAT: Option<&str> = Some("mrp_patenemy"); + +const TEMPLATE_DATABASE_NAME: &str = "labrinth_tests_template"; + +#[derive(Clone)] +pub struct TemporaryDatabase { + pub pool: PgPool, + pub redis_pool: RedisPool, + pub search_config: labrinth::search::SearchConfig, + pub database_name: String, +} + +impl TemporaryDatabase { + // Creates a temporary database like sqlx::test does (panics) + // 1. Logs into the main database + // 2. Creates a new randomly generated database + // 3. Runs migrations on the new database + // 4. (Optionally, by using create_with_dummy) adds dummy data to the database + // If a db is created with create_with_dummy, it must be cleaned up with cleanup. + // This means that dbs will only 'remain' if a test fails (for examination of the db), and will be cleaned up otherwise. + pub async fn create(max_connections: Option) -> Self { + let temp_database_name = generate_random_name("labrinth_tests_db_"); + println!("Creating temporary database: {}", &temp_database_name); + + let database_url = + dotenvy::var("DATABASE_URL").expect("No database URL"); + + // Create the temporary (and template datbase, if needed) + Self::create_temporary(&database_url, &temp_database_name).await; + + // Pool to the temporary database + let mut temporary_url = + Url::parse(&database_url).expect("Invalid database URL"); + + temporary_url.set_path(&format!("/{}", &temp_database_name)); + let temp_db_url = temporary_url.to_string(); + + let pool = PgPoolOptions::new() + .min_connections(0) + .max_connections(max_connections.unwrap_or(4)) + .max_lifetime(Some(Duration::from_secs(60))) + .connect(&temp_db_url) + .await + .expect("Connection to temporary database failed"); + + println!("Running migrations on temporary database"); + + // Performs migrations + let migrations = sqlx::migrate!("./migrations"); + migrations.run(&pool).await.expect("Migrations failed"); + + println!("Migrations complete"); + + // Gets new Redis pool + let redis_pool = RedisPool::new(Some(temp_database_name.clone())); + + // Create new meilisearch config + let search_config = + search::SearchConfig::new(Some(temp_database_name.clone())); + Self { + pool, + database_name: temp_database_name, + redis_pool, + search_config, + } + } + + // Creates a template and temporary databse (panics) + // 1. Waits to obtain a pg lock on the main database + // 2. Creates a new template database called 'TEMPLATE_DATABASE_NAME', if needed + // 3. Switches to the template database + // 4. Runs migrations on the new database (for most tests, this should not take time) + // 5. Creates dummy data on the new db + // 6. Creates a temporary database at 'temp_database_name' from the template + // 7. Drops lock and all created connections in the function + async fn create_temporary(database_url: &str, temp_database_name: &str) { + let main_pool = PgPool::connect(database_url) + .await + .expect("Connection to database failed"); + + loop { + // Try to acquire an advisory lock + let lock_acquired: bool = + sqlx::query_scalar("SELECT pg_try_advisory_lock(1)") + .fetch_one(&main_pool) + .await + .unwrap(); + + if lock_acquired { + // Create the db template if it doesn't exist + // Check if template_db already exists + let db_exists: Option = sqlx::query_scalar(&format!( + "SELECT 1 FROM pg_database WHERE datname = '{TEMPLATE_DATABASE_NAME}'" + )) + .fetch_optional(&main_pool) + .await + .unwrap(); + if db_exists.is_none() { + create_template_database(&main_pool).await; + } + + // Switch to template + let url = + dotenvy::var("DATABASE_URL").expect("No database URL"); + let mut template_url = + Url::parse(&url).expect("Invalid database URL"); + template_url.set_path(&format!("/{}", TEMPLATE_DATABASE_NAME)); + + let pool = PgPool::connect(template_url.as_str()) + .await + .expect("Connection to database failed"); + + // Check if dummy data exists- a fake 'dummy_data' table is created if it does + let mut dummy_data_exists: bool = sqlx::query_scalar( + "SELECT to_regclass('dummy_data') IS NOT NULL", + ) + .fetch_one(&pool) + .await + .unwrap(); + if dummy_data_exists { + // Check if the dummy data needs to be updated + let dummy_data_update = sqlx::query_scalar::<_, i64>( + "SELECT update_id FROM dummy_data", + ) + .fetch_optional(&pool) + .await + .unwrap(); + let needs_update = !dummy_data_update + .is_some_and(|d| d == DUMMY_DATA_UPDATE); + if needs_update { + println!("Dummy data updated, so template DB tables will be dropped and re-created"); + // Drop all tables in the database so they can be re-created and later filled with updated dummy data + sqlx::query("DROP SCHEMA public CASCADE;") + .execute(&pool) + .await + .unwrap(); + sqlx::query("CREATE SCHEMA public;") + .execute(&pool) + .await + .unwrap(); + dummy_data_exists = false; + } + } + + // Run migrations on the template + let migrations = sqlx::migrate!("./migrations"); + migrations.run(&pool).await.expect("Migrations failed"); + + if !dummy_data_exists { + // Add dummy data + let name = generate_random_name("test_template_"); + let db = TemporaryDatabase { + pool: pool.clone(), + database_name: TEMPLATE_DATABASE_NAME.to_string(), + redis_pool: RedisPool::new(Some(name.clone())), + search_config: search::SearchConfig::new(Some(name)), + }; + let setup_api = + TestEnvironment::::build_setup_api(&db).await; + dummy_data::add_dummy_data(&setup_api, db.clone()).await; + db.pool.close().await; + } + pool.close().await; + drop(pool); + + // Create the temporary database from the template + let create_db_query = format!( + "CREATE DATABASE {} TEMPLATE {}", + &temp_database_name, TEMPLATE_DATABASE_NAME + ); + + sqlx::query(&create_db_query) + .execute(&main_pool) + .await + .expect("Database creation failed"); + + // Release the advisory lock + sqlx::query("SELECT pg_advisory_unlock(1)") + .execute(&main_pool) + .await + .unwrap(); + + main_pool.close().await; + break; + } + // Wait for the lock to be released + tokio::time::sleep(tokio::time::Duration::from_millis(10)).await; + } + } + + // Deletes the temporary database (panics) + // If a temporary db is created, it must be cleaned up with cleanup. + // This means that dbs will only 'remain' if a test fails (for examination of the db), and will be cleaned up otherwise. + pub async fn cleanup(mut self) { + let database_url = + dotenvy::var("DATABASE_URL").expect("No database URL"); + self.pool.close().await; + + self.pool = PgPool::connect(&database_url) + .await + .expect("Connection to main database failed"); + + // Forcibly terminate all existing connections to this version of the temporary database + // We are done and deleting it, so we don't need them anymore + let terminate_query = format!( + "SELECT pg_terminate_backend(pg_stat_activity.pid) FROM pg_stat_activity WHERE datname = '{}' AND pid <> pg_backend_pid()", + &self.database_name + ); + sqlx::query(&terminate_query) + .execute(&self.pool) + .await + .unwrap(); + + // Execute the deletion query asynchronously + let drop_db_query = + format!("DROP DATABASE IF EXISTS {}", &self.database_name); + sqlx::query(&drop_db_query) + .execute(&self.pool) + .await + .expect("Database deletion failed"); + } +} + +async fn create_template_database(pool: &sqlx::Pool) { + let create_db_query = format!("CREATE DATABASE {TEMPLATE_DATABASE_NAME}"); + sqlx::query(&create_db_query) + .execute(pool) + .await + .expect("Database creation failed"); +} + +// Appends a random 8-digit number to the end of the str +pub fn generate_random_name(str: &str) -> String { + let mut str = String::from(str); + str.push_str(&rand::random::().to_string()[..8]); + str +} diff --git a/apps/labrinth/tests/common/dummy_data.rs b/apps/labrinth/tests/common/dummy_data.rs new file mode 100644 index 000000000..5dbe21a01 --- /dev/null +++ b/apps/labrinth/tests/common/dummy_data.rs @@ -0,0 +1,580 @@ +#![allow(dead_code)] +use std::io::{Cursor, Write}; + +use actix_http::StatusCode; +use actix_web::test::{self, TestRequest}; +use labrinth::models::{ + oauth_clients::OAuthClient, + organizations::Organization, + pats::Scopes, + projects::{Project, ProjectId, Version}, +}; +use serde_json::json; +use sqlx::Executor; +use zip::{write::FileOptions, CompressionMethod, ZipWriter}; + +use crate::{ + assert_status, + common::{api_common::Api, api_v3, database::USER_USER_PAT}, +}; + +use super::{ + api_common::{request_data::ImageData, ApiProject, AppendsOptionalPat}, + api_v3::ApiV3, + database::TemporaryDatabase, +}; + +use super::{database::USER_USER_ID, get_json_val_str}; + +pub const DUMMY_DATA_UPDATE: i64 = 7; + +#[allow(dead_code)] +pub const DUMMY_CATEGORIES: &[&str] = &[ + "combat", + "decoration", + "economy", + "food", + "magic", + "mobs", + "optimization", +]; + +pub const DUMMY_OAUTH_CLIENT_ALPHA_SECRET: &str = "abcdefghijklmnopqrstuvwxyz"; + +#[allow(dead_code)] +#[derive(Clone)] +pub enum TestFile { + DummyProjectAlpha, + DummyProjectBeta, + BasicZip, + BasicMod, + BasicModDifferent, + // Randomly generates a valid .jar with a random hash. + // Unlike the other dummy jar files, this one is not a static file. + // and BasicModRandom.bytes() will return a different file each time. + BasicModRandom { filename: String, bytes: Vec }, + BasicModpackRandom { filename: String, bytes: Vec }, +} + +impl TestFile { + pub fn build_random_jar() -> Self { + let filename = format!("random-mod-{}.jar", rand::random::()); + + let fabric_mod_json = serde_json::json!({ + "schemaVersion": 1, + "id": filename, + "version": "1.0.1", + + "name": filename, + "description": "Does nothing", + "authors": [ + "user" + ], + "contact": { + "homepage": "https://www.modrinth.com", + "sources": "https://www.modrinth.com", + "issues": "https://www.modrinth.com" + }, + + "license": "MIT", + "icon": "none.png", + + "environment": "client", + "entrypoints": { + "main": [ + "io.github.modrinth.Modrinth" + ] + }, + "depends": { + "minecraft": ">=1.20-" + } + } + ) + .to_string(); + + // Create a simulated zip file + let mut cursor = Cursor::new(Vec::new()); + { + let mut zip = ZipWriter::new(&mut cursor); + zip.start_file( + "fabric.mod.json", + FileOptions::default() + .compression_method(CompressionMethod::Stored), + ) + .unwrap(); + zip.write_all(fabric_mod_json.as_bytes()).unwrap(); + + zip.start_file( + "META-INF/mods.toml", + FileOptions::default() + .compression_method(CompressionMethod::Stored), + ) + .unwrap(); + zip.write_all(fabric_mod_json.as_bytes()).unwrap(); + + zip.finish().unwrap(); + } + let bytes = cursor.into_inner(); + + TestFile::BasicModRandom { filename, bytes } + } + + pub fn build_random_mrpack() -> Self { + let filename = + format!("random-modpack-{}.mrpack", rand::random::()); + + let modrinth_index_json = serde_json::json!({ + "formatVersion": 1, + "game": "minecraft", + "versionId": "1.20.1-9.6", + "name": filename, + "files": [ + { + "path": "mods/animatica-0.6+1.20.jar", + "hashes": { + "sha1": "3bcb19c759f313e69d3f7848b03c48f15167b88d", + "sha512": "7d50f3f34479f8b052bfb9e2482603b4906b8984039777dc2513ecf18e9af2b599c9d094e88cec774f8525345859e721a394c8cd7c14a789c9538d2533c71d65" + }, + "env": { + "client": "required", + "server": "required" + }, + "downloads": [ + "https://cdn.modrinth.com/data/PRN43VSY/versions/uNgEPb10/animatica-0.6%2B1.20.jar" + ], + "fileSize": 69810 + } + ], + "dependencies": { + "fabric-loader": "0.14.22", + "minecraft": "1.20.1" + } + } + ) + .to_string(); + + // Create a simulated zip file + let mut cursor = Cursor::new(Vec::new()); + { + let mut zip = ZipWriter::new(&mut cursor); + zip.start_file( + "modrinth.index.json", + FileOptions::default() + .compression_method(CompressionMethod::Stored), + ) + .unwrap(); + zip.write_all(modrinth_index_json.as_bytes()).unwrap(); + zip.finish().unwrap(); + } + let bytes = cursor.into_inner(); + + TestFile::BasicModpackRandom { filename, bytes } + } +} + +#[derive(Clone)] +#[allow(dead_code)] +pub enum DummyImage { + SmallIcon, // 200x200 +} + +#[derive(Clone)] +pub struct DummyData { + /// Alpha project: + /// This is a dummy project created by USER user. + /// It's approved, listed, and visible to the public. + pub project_alpha: DummyProjectAlpha, + + /// Beta project: + /// This is a dummy project created by USER user. + /// It's not approved, unlisted, and not visible to the public. + pub project_beta: DummyProjectBeta, + + /// Zeta organization: + /// This is a dummy organization created by USER user. + /// There are no projects in it. + pub organization_zeta: DummyOrganizationZeta, + + /// Alpha OAuth Client: + /// This is a dummy OAuth client created by USER user. + /// + /// All scopes are included in its max scopes + /// + /// It has one valid redirect URI + pub oauth_client_alpha: DummyOAuthClientAlpha, +} + +impl DummyData { + pub fn new( + project_alpha: Project, + project_alpha_version: Version, + project_beta: Project, + project_beta_version: Version, + organization_zeta: Organization, + oauth_client_alpha: OAuthClient, + ) -> Self { + DummyData { + project_alpha: DummyProjectAlpha { + team_id: project_alpha.team_id.to_string(), + project_id: project_alpha.id.to_string(), + project_slug: project_alpha.slug.unwrap(), + project_id_parsed: project_alpha.id, + version_id: project_alpha_version.id.to_string(), + thread_id: project_alpha.thread_id.to_string(), + file_hash: project_alpha_version.files[0].hashes["sha1"] + .clone(), + }, + + project_beta: DummyProjectBeta { + team_id: project_beta.team_id.to_string(), + project_id: project_beta.id.to_string(), + project_slug: project_beta.slug.unwrap(), + project_id_parsed: project_beta.id, + version_id: project_beta_version.id.to_string(), + thread_id: project_beta.thread_id.to_string(), + file_hash: project_beta_version.files[0].hashes["sha1"].clone(), + }, + + organization_zeta: DummyOrganizationZeta { + organization_id: organization_zeta.id.to_string(), + team_id: organization_zeta.team_id.to_string(), + organization_slug: organization_zeta.slug, + }, + + oauth_client_alpha: DummyOAuthClientAlpha { + client_id: get_json_val_str(oauth_client_alpha.id), + client_secret: DUMMY_OAUTH_CLIENT_ALPHA_SECRET.to_string(), + valid_redirect_uri: oauth_client_alpha + .redirect_uris + .first() + .unwrap() + .uri + .clone(), + }, + } + } +} + +#[derive(Clone)] +pub struct DummyProjectAlpha { + pub project_id: String, + pub project_slug: String, + pub project_id_parsed: ProjectId, + pub version_id: String, + pub thread_id: String, + pub file_hash: String, + pub team_id: String, +} + +#[derive(Clone)] +pub struct DummyProjectBeta { + pub project_id: String, + pub project_slug: String, + pub project_id_parsed: ProjectId, + pub version_id: String, + pub thread_id: String, + pub file_hash: String, + pub team_id: String, +} + +#[derive(Clone)] +pub struct DummyOrganizationZeta { + pub organization_id: String, + pub organization_slug: String, + pub team_id: String, +} + +#[derive(Clone)] +pub struct DummyOAuthClientAlpha { + pub client_id: String, + pub client_secret: String, + pub valid_redirect_uri: String, +} + +pub async fn add_dummy_data(api: &ApiV3, db: TemporaryDatabase) -> DummyData { + // Adds basic dummy data to the database directly with sql (user, pats) + let pool = &db.pool.clone(); + + pool.execute( + include_str!("../files/dummy_data.sql") + .replace("$1", &Scopes::all().bits().to_string()) + .as_str(), + ) + .await + .unwrap(); + + let (alpha_project, alpha_version) = add_project_alpha(api).await; + let (beta_project, beta_version) = add_project_beta(api).await; + + let zeta_organization = add_organization_zeta(api).await; + + let oauth_client_alpha = get_oauth_client_alpha(api).await; + + sqlx::query("INSERT INTO dummy_data (update_id) VALUES ($1)") + .bind(DUMMY_DATA_UPDATE) + .execute(pool) + .await + .unwrap(); + + DummyData::new( + alpha_project, + alpha_version, + beta_project, + beta_version, + zeta_organization, + oauth_client_alpha, + ) +} + +pub async fn get_dummy_data(api: &ApiV3) -> DummyData { + let (alpha_project, alpha_version) = get_project_alpha(api).await; + let (beta_project, beta_version) = get_project_beta(api).await; + + let zeta_organization = get_organization_zeta(api).await; + + let oauth_client_alpha = get_oauth_client_alpha(api).await; + + DummyData::new( + alpha_project, + alpha_version, + beta_project, + beta_version, + zeta_organization, + oauth_client_alpha, + ) +} + +pub async fn add_project_alpha(api: &ApiV3) -> (Project, Version) { + let (project, versions) = api + .add_public_project( + "alpha", + Some(TestFile::DummyProjectAlpha), + None, + USER_USER_PAT, + ) + .await; + let alpha_project = api + .get_project_deserialized( + project.id.to_string().as_str(), + USER_USER_PAT, + ) + .await; + let alpha_version = api + .get_version_deserialized( + &versions.into_iter().next().unwrap().id.to_string(), + USER_USER_PAT, + ) + .await; + (alpha_project, alpha_version) +} + +pub async fn add_project_beta(api: &ApiV3) -> (Project, Version) { + // Adds dummy data to the database with sqlx (projects, versions, threads) + // Generate test project data. + let jar = TestFile::DummyProjectBeta; + // TODO: this shouldnt be hardcoded (nor should other similar ones be) + + let modify_json = serde_json::from_value(json!([ + { "op": "add", "path": "/summary", "value": "A dummy project for testing with." }, + { "op": "add", "path": "/description", "value": "This project is not-yet-approved, and versions are draft." }, + { "op": "add", "path": "/initial_versions/0/status", "value": "unlisted" }, + { "op": "add", "path": "/status", "value": "private" }, + { "op": "add", "path": "/requested_status", "value": "private" }, + ])) + .unwrap(); + + let creation_data = api_v3::request_data::get_public_project_creation_data( + "beta", + Some(jar), + Some(modify_json), + ); + api.create_project(creation_data, USER_USER_PAT).await; + + get_project_beta(api).await +} + +pub async fn add_organization_zeta(api: &ApiV3) -> Organization { + // Add an organzation. + let req = TestRequest::post() + .uri("/v3/organization") + .append_pat(USER_USER_PAT) + .set_json(json!({ + "name": "Zeta", + "slug": "zeta", + "description": "A dummy organization for testing with." + })) + .to_request(); + let resp = api.call(req).await; + + assert_status!(&resp, StatusCode::OK); + + get_organization_zeta(api).await +} + +pub async fn get_project_alpha(api: &ApiV3) -> (Project, Version) { + // Get project + let req = TestRequest::get() + .uri("/v3/project/alpha") + .append_pat(USER_USER_PAT) + .to_request(); + let resp = api.call(req).await; + let project: Project = test::read_body_json(resp).await; + + // Get project's versions + let req = TestRequest::get() + .uri("/v3/project/alpha/version") + .append_pat(USER_USER_PAT) + .to_request(); + let resp = api.call(req).await; + let versions: Vec = test::read_body_json(resp).await; + let version = versions.into_iter().next().unwrap(); + + (project, version) +} + +pub async fn get_project_beta(api: &ApiV3) -> (Project, Version) { + // Get project + let req = TestRequest::get() + .uri("/v3/project/beta") + .append_pat(USER_USER_PAT) + .to_request(); + let resp = api.call(req).await; + assert_status!(&resp, StatusCode::OK); + let project: serde_json::Value = test::read_body_json(resp).await; + let project: Project = serde_json::from_value(project).unwrap(); + + // Get project's versions + let req = TestRequest::get() + .uri("/v3/project/beta/version") + .append_pat(USER_USER_PAT) + .to_request(); + let resp = api.call(req).await; + assert_status!(&resp, StatusCode::OK); + let versions: Vec = test::read_body_json(resp).await; + let version = versions.into_iter().next().unwrap(); + + (project, version) +} + +pub async fn get_organization_zeta(api: &ApiV3) -> Organization { + // Get organization + let req = TestRequest::get() + .uri("/v3/organization/zeta") + .append_pat(USER_USER_PAT) + .to_request(); + let resp = api.call(req).await; + let organization: Organization = test::read_body_json(resp).await; + + organization +} + +pub async fn get_oauth_client_alpha(api: &ApiV3) -> OAuthClient { + let oauth_clients = api + .get_user_oauth_clients(USER_USER_ID, USER_USER_PAT) + .await; + oauth_clients.into_iter().next().unwrap() +} + +impl TestFile { + pub fn filename(&self) -> String { + match self { + TestFile::DummyProjectAlpha => "dummy-project-alpha.jar", + TestFile::DummyProjectBeta => "dummy-project-beta.jar", + TestFile::BasicZip => "simple-zip.zip", + TestFile::BasicMod => "basic-mod.jar", + TestFile::BasicModDifferent => "basic-mod-different.jar", + TestFile::BasicModRandom { filename, .. } => filename, + TestFile::BasicModpackRandom { filename, .. } => filename, + } + .to_string() + } + + pub fn bytes(&self) -> Vec { + match self { + TestFile::DummyProjectAlpha => { + include_bytes!("../../tests/files/dummy-project-alpha.jar") + .to_vec() + } + TestFile::DummyProjectBeta => { + include_bytes!("../../tests/files/dummy-project-beta.jar") + .to_vec() + } + TestFile::BasicMod => { + include_bytes!("../../tests/files/basic-mod.jar").to_vec() + } + TestFile::BasicZip => { + include_bytes!("../../tests/files/simple-zip.zip").to_vec() + } + TestFile::BasicModDifferent => { + include_bytes!("../../tests/files/basic-mod-different.jar") + .to_vec() + } + TestFile::BasicModRandom { bytes, .. } => bytes.clone(), + TestFile::BasicModpackRandom { bytes, .. } => bytes.clone(), + } + } + + pub fn project_type(&self) -> String { + match self { + TestFile::DummyProjectAlpha => "mod", + TestFile::DummyProjectBeta => "mod", + TestFile::BasicMod => "mod", + TestFile::BasicModDifferent => "mod", + TestFile::BasicModRandom { .. } => "mod", + + TestFile::BasicZip => "resourcepack", + + TestFile::BasicModpackRandom { .. } => "modpack", + } + .to_string() + } + + pub fn content_type(&self) -> Option { + match self { + TestFile::DummyProjectAlpha => Some("application/java-archive"), + TestFile::DummyProjectBeta => Some("application/java-archive"), + TestFile::BasicMod => Some("application/java-archive"), + TestFile::BasicModDifferent => Some("application/java-archive"), + TestFile::BasicModRandom { .. } => Some("application/java-archive"), + + TestFile::BasicZip => Some("application/zip"), + + TestFile::BasicModpackRandom { .. } => { + Some("application/x-modrinth-modpack+zip") + } + } + .map(|s| s.to_string()) + } +} + +impl DummyImage { + pub fn filename(&self) -> String { + match self { + DummyImage::SmallIcon => "200x200.png", + } + .to_string() + } + + pub fn extension(&self) -> String { + match self { + DummyImage::SmallIcon => "png", + } + .to_string() + } + + pub fn bytes(&self) -> Vec { + match self { + DummyImage::SmallIcon => { + include_bytes!("../../tests/files/200x200.png").to_vec() + } + } + } + + pub fn get_icon_data(&self) -> ImageData { + ImageData { + filename: self.filename(), + extension: self.extension(), + icon: self.bytes(), + } + } +} diff --git a/apps/labrinth/tests/common/environment.rs b/apps/labrinth/tests/common/environment.rs new file mode 100644 index 000000000..7747916ac --- /dev/null +++ b/apps/labrinth/tests/common/environment.rs @@ -0,0 +1,175 @@ +#![allow(dead_code)] + +use super::{ + api_common::{generic::GenericApi, Api, ApiBuildable}, + api_v2::ApiV2, + api_v3::ApiV3, + database::{TemporaryDatabase, FRIEND_USER_ID, USER_USER_PAT}, + dummy_data, +}; +use crate::{assert_status, common::setup}; +use actix_http::StatusCode; +use actix_web::dev::ServiceResponse; +use futures::Future; + +pub async fn with_test_environment( + max_connections: Option, + f: impl FnOnce(TestEnvironment) -> Fut, +) where + Fut: Future, + A: ApiBuildable + 'static, +{ + let test_env: TestEnvironment = + TestEnvironment::build(max_connections).await; + let db = test_env.db.clone(); + f(test_env).await; + db.cleanup().await; +} + +pub async fn with_test_environment_all( + max_connections: Option, + f: F, +) where + Fut: Future, + F: Fn(TestEnvironment) -> Fut, +{ + println!("Test environment: API v3"); + let test_env_api_v3 = + TestEnvironment::::build(max_connections).await; + let test_env_api_v3 = TestEnvironment { + db: test_env_api_v3.db.clone(), + api: GenericApi::V3(test_env_api_v3.api), + setup_api: test_env_api_v3.setup_api, + dummy: test_env_api_v3.dummy, + }; + let db = test_env_api_v3.db.clone(); + f(test_env_api_v3).await; + db.cleanup().await; + + println!("Test environment: API v2"); + let test_env_api_v2 = + TestEnvironment::::build(max_connections).await; + let test_env_api_v2 = TestEnvironment { + db: test_env_api_v2.db.clone(), + api: GenericApi::V2(test_env_api_v2.api), + setup_api: test_env_api_v2.setup_api, + dummy: test_env_api_v2.dummy, + }; + let db = test_env_api_v2.db.clone(); + f(test_env_api_v2).await; + db.cleanup().await; +} + +// A complete test environment, with a test actix app and a database. +// Must be called in an #[actix_rt::test] context. It also simulates a +// temporary sqlx db like #[sqlx::test] would. +// Use .call(req) on it directly to make a test call as if test::call_service(req) were being used. +#[derive(Clone)] +pub struct TestEnvironment { + pub db: TemporaryDatabase, + pub api: A, + pub setup_api: ApiV3, // Used for setting up tests only (ie: in ScopesTest) + pub dummy: dummy_data::DummyData, +} + +impl TestEnvironment { + async fn build(max_connections: Option) -> Self { + let db = TemporaryDatabase::create(max_connections).await; + let labrinth_config = setup(&db).await; + let api = A::build(labrinth_config.clone()).await; + let setup_api = ApiV3::build(labrinth_config).await; + let dummy = dummy_data::get_dummy_data(&setup_api).await; + Self { + db, + api, + setup_api, + dummy, + } + } + pub async fn build_setup_api(db: &TemporaryDatabase) -> ApiV3 { + let labrinth_config = setup(db).await; + ApiV3::build(labrinth_config).await + } +} + +impl TestEnvironment { + pub async fn cleanup(self) { + self.db.cleanup().await; + } + + pub async fn call(&self, req: actix_http::Request) -> ServiceResponse { + self.api.call(req).await + } + + // Setup data, create a friend user notification + pub async fn generate_friend_user_notification(&self) { + let resp = self + .api + .add_user_to_team( + &self.dummy.project_alpha.team_id, + FRIEND_USER_ID, + None, + None, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + } + + // Setup data, assert that a user can read notifications + pub async fn assert_read_notifications_status( + &self, + user_id: &str, + pat: Option<&str>, + status_code: StatusCode, + ) { + let resp = self.api.get_user_notifications(user_id, pat).await; + assert_status!(&resp, status_code); + } + + // Setup data, assert that a user can read projects notifications + pub async fn assert_read_user_projects_status( + &self, + user_id: &str, + pat: Option<&str>, + status_code: StatusCode, + ) { + let resp = self.api.get_user_projects(user_id, pat).await; + assert_status!(&resp, status_code); + } +} + +pub trait LocalService { + fn call( + &self, + req: actix_http::Request, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = Result, + >, + >, + >; +} +impl LocalService for S +where + S: actix_web::dev::Service< + actix_http::Request, + Response = ServiceResponse, + Error = actix_web::Error, + >, + S::Future: 'static, +{ + fn call( + &self, + req: actix_http::Request, + ) -> std::pin::Pin< + Box< + dyn std::future::Future< + Output = Result, + >, + >, + > { + Box::pin(self.call(req)) + } +} diff --git a/apps/labrinth/tests/common/mod.rs b/apps/labrinth/tests/common/mod.rs new file mode 100644 index 000000000..840bad667 --- /dev/null +++ b/apps/labrinth/tests/common/mod.rs @@ -0,0 +1,54 @@ +use labrinth::{check_env_vars, clickhouse}; +use labrinth::{file_hosting, queue, LabrinthConfig}; +use std::sync::Arc; + +pub mod api_common; +pub mod api_v2; +pub mod api_v3; +pub mod asserts; +pub mod database; +pub mod dummy_data; +pub mod environment; +pub mod pats; +pub mod permissions; +pub mod scopes; +pub mod search; + +// Testing equivalent to 'setup' function, producing a LabrinthConfig +// If making a test, you should probably use environment::TestEnvironment::build() (which calls this) +pub async fn setup(db: &database::TemporaryDatabase) -> LabrinthConfig { + println!("Setting up labrinth config"); + + dotenvy::dotenv().ok(); + + if check_env_vars() { + println!("Some environment variables are missing!"); + } + + let pool = db.pool.clone(); + let redis_pool = db.redis_pool.clone(); + let search_config = db.search_config.clone(); + let file_host: Arc = + Arc::new(file_hosting::MockHost::new()); + let mut clickhouse = clickhouse::init_client().await.unwrap(); + + let maxmind_reader = + Arc::new(queue::maxmind::MaxMindIndexer::new().await.unwrap()); + + labrinth::app_setup( + pool.clone(), + redis_pool.clone(), + search_config, + &mut clickhouse, + file_host.clone(), + maxmind_reader, + ) +} + +pub fn get_json_val_str(val: impl serde::Serialize) -> String { + serde_json::to_value(val) + .unwrap() + .as_str() + .unwrap() + .to_string() +} diff --git a/apps/labrinth/tests/common/pats.rs b/apps/labrinth/tests/common/pats.rs new file mode 100644 index 000000000..0f5662155 --- /dev/null +++ b/apps/labrinth/tests/common/pats.rs @@ -0,0 +1,34 @@ +#![allow(dead_code)] + +use chrono::Utc; +use labrinth::{ + database::{self, models::generate_pat_id}, + models::pats::Scopes, +}; + +use super::database::TemporaryDatabase; + +// Creates a PAT with the given scopes, and returns the access token +// Interfacing with the db directly, rather than using a ourte, +// allows us to test with scopes that are not allowed to be created by PATs +pub async fn create_test_pat( + scopes: Scopes, + user_id: i64, + db: &TemporaryDatabase, +) -> String { + let mut transaction = db.pool.begin().await.unwrap(); + let id = generate_pat_id(&mut transaction).await.unwrap(); + let pat = database::models::pat_item::PersonalAccessToken { + id, + name: format!("test_pat_{}", scopes.bits()), + access_token: format!("mrp_{}", id.0), + scopes, + user_id: database::models::ids::UserId(user_id), + created: Utc::now(), + expires: Utc::now() + chrono::Duration::days(1), + last_used: None, + }; + pat.insert(&mut transaction).await.unwrap(); + transaction.commit().await.unwrap(); + pat.access_token +} diff --git a/apps/labrinth/tests/common/permissions.rs b/apps/labrinth/tests/common/permissions.rs new file mode 100644 index 000000000..02b119353 --- /dev/null +++ b/apps/labrinth/tests/common/permissions.rs @@ -0,0 +1,1223 @@ +#![allow(dead_code)] +use actix_http::StatusCode; +use actix_web::{dev::ServiceResponse, test}; +use futures::Future; +use itertools::Itertools; +use labrinth::models::teams::{OrganizationPermissions, ProjectPermissions}; +use serde_json::json; + +use crate::common::{ + api_common::ApiTeams, + database::{generate_random_name, ADMIN_USER_PAT}, +}; + +use super::{ + api_common::{Api, ApiProject}, + api_v3::ApiV3, + database::{ENEMY_USER_PAT, USER_USER_ID, USER_USER_PAT}, + environment::TestEnvironment, +}; + +// A reusable test type that works for any permissions test testing an endpoint that: +// - returns a known 'expected_failure_code' if the scope is not present (defaults to 401) +// - returns a 200-299 if the scope is present +// - returns failure and success JSON bodies for requests that are 200 (for performing non-simple follow-up tests on) +// This uses a builder format, so you can chain methods to set the parameters to non-defaults (most will probably be not need to be set). +type JsonCheck = Box; +pub struct PermissionsTest<'a, A: Api> { + test_env: &'a TestEnvironment, + // Permissions expected to fail on this test. By default, this is all permissions except the success permissions. + // (To ensure we have isolated the permissions we are testing) + failure_project_permissions: Option, + failure_organization_permissions: Option, + + // User ID to use for the test user, and their PAT + user_id: &'a str, + user_pat: Option<&'a str>, + + // Whether or not the user ID should be removed from the project/organization team after the test + // (This is mostly reelvant if you are also using an existing project/organization, and want to do + // multiple tests with the same user. + remove_user: bool, + + // ID to use for the test project (project, organization) + // By default, create a new project or organization to test upon. + // However, if we want, we can use an existing project or organization. + // (eg: if we want to test a specific project, or a project with a specific state) + project_id: Option, + project_team_id: Option, + organization_id: Option, + organization_team_id: Option, + + // The codes that is allow to be returned if the scope is not present. + // (for instance, we might expect a 401, but not a 400) + allowed_failure_codes: Vec, + + // Closures that check the JSON body of the response for failure and success cases. + // These are used to perform more complex tests than just checking the status code. + // (eg: checking that the response contains the correct data) + failure_json_check: Option, + success_json_check: Option, +} +#[derive(Clone, Debug)] +pub struct PermissionsTestContext { + pub test_pat: Option, + pub user_id: String, + pub project_id: Option, + pub team_id: Option, + pub organization_id: Option, + pub organization_team_id: Option, +} + +impl<'a, A: Api> PermissionsTest<'a, A> { + pub fn new(test_env: &'a TestEnvironment) -> Self { + Self { + test_env, + failure_project_permissions: None, + failure_organization_permissions: None, + user_id: USER_USER_ID, + user_pat: USER_USER_PAT, + remove_user: false, + project_id: None, + organization_id: None, + project_team_id: None, + organization_team_id: None, + allowed_failure_codes: vec![401, 404], + failure_json_check: None, + success_json_check: None, + } + } + + // Set non-standard failure permissions + // If not set, it will be set to all permissions except the success permissions + // (eg: if a combination of permissions is needed, but you want to make sure that the endpoint does not work with all-but-one of them) + pub fn with_failure_permissions( + mut self, + failure_project_permissions: Option, + failure_organization_permissions: Option, + ) -> Self { + self.failure_project_permissions = failure_project_permissions; + self.failure_organization_permissions = + failure_organization_permissions; + self + } + + // Set check closures for the JSON body of the response + // These are used to perform more complex tests than just checking the status code. + // If not set, no checks will be performed (and the status code is the only check). + // This is useful if, say, both expected status codes are 200. + pub fn with_200_json_checks( + mut self, + failure_json_check: impl Fn(&serde_json::Value) + Send + 'static, + success_json_check: impl Fn(&serde_json::Value) + Send + 'static, + ) -> Self { + self.failure_json_check = Some(Box::new(failure_json_check)); + self.success_json_check = Some(Box::new(success_json_check)); + self + } + + // Set the user ID to use + // (eg: a moderator, or friend) + // remove_user: Whether or not the user ID should be removed from the project/organization team after the test + pub fn with_user( + mut self, + user_id: &'a str, + user_pat: Option<&'a str>, + remove_user: bool, + ) -> Self { + self.user_id = user_id; + self.user_pat = user_pat; + self.remove_user = remove_user; + self + } + + // If a non-standard code is expected. + // (eg: perhaps 200 for a resource with hidden values deeper in) + pub fn with_failure_codes( + mut self, + allowed_failure_codes: impl IntoIterator, + ) -> Self { + self.allowed_failure_codes = + allowed_failure_codes.into_iter().collect(); + self + } + + // If an existing project or organization is intended to be used + // We will not create a new project, and will use the given project ID + // (But will still add the user to the project's team) + pub fn with_existing_project( + mut self, + project_id: &str, + team_id: &str, + ) -> Self { + self.project_id = Some(project_id.to_string()); + self.project_team_id = Some(team_id.to_string()); + self + } + pub fn with_existing_organization( + mut self, + organization_id: &str, + team_id: &str, + ) -> Self { + self.organization_id = Some(organization_id.to_string()); + self.organization_team_id = Some(team_id.to_string()); + self + } + + pub async fn simple_project_permissions_test( + &self, + success_permissions: ProjectPermissions, + req_gen: T, + ) -> Result<(), String> + where + T: Fn(PermissionsTestContext) -> Fut, + Fut: Future, // Ensure Fut is Send and 'static + { + let test_env = self.test_env; + let failure_project_permissions = self + .failure_project_permissions + .unwrap_or(ProjectPermissions::all() ^ success_permissions); + let test_context = PermissionsTestContext { + test_pat: None, + user_id: self.user_id.to_string(), + project_id: None, + team_id: None, + organization_id: None, + organization_team_id: None, + }; + + let (project_id, team_id) = + if self.project_id.is_some() && self.project_team_id.is_some() { + ( + self.project_id.clone().unwrap(), + self.project_team_id.clone().unwrap(), + ) + } else { + create_dummy_project(&test_env.setup_api).await + }; + + add_user_to_team( + self.user_id, + self.user_pat, + &team_id, + Some(failure_project_permissions), + None, + &test_env.setup_api, + ) + .await; + + // Failure test- not logged in + let resp = req_gen(PermissionsTestContext { + project_id: Some(project_id.clone()), + team_id: Some(team_id.clone()), + ..test_context.clone() + }) + .await; + if !self.allowed_failure_codes.contains(&resp.status().as_u16()) { + return Err(format!( + "Failure permissions test failed. Expected failure codes {} got {}", + self.allowed_failure_codes + .iter() + .map(|code| code.to_string()) + .join(","), + resp.status().as_u16() + )); + } + if resp.status() == StatusCode::OK { + if let Some(failure_json_check) = &self.failure_json_check { + failure_json_check(&test::read_body_json(resp).await); + } + } + + // Failure test- logged in on a non-team user + let resp = req_gen(PermissionsTestContext { + test_pat: ENEMY_USER_PAT.map(|s| s.to_string()), + project_id: Some(project_id.clone()), + team_id: Some(team_id.clone()), + ..test_context.clone() + }) + .await; + if !self.allowed_failure_codes.contains(&resp.status().as_u16()) { + return Err(format!( + "Failure permissions test failed. Expected failure codes {} got {}", + self.allowed_failure_codes + .iter() + .map(|code| code.to_string()) + .join(","), + resp.status().as_u16() + )); + } + if resp.status() == StatusCode::OK { + if let Some(failure_json_check) = &self.failure_json_check { + failure_json_check(&test::read_body_json(resp).await); + } + } + + // Failure test- logged in with EVERY non-relevant permission + let resp: ServiceResponse = req_gen(PermissionsTestContext { + test_pat: self.user_pat.map(|s| s.to_string()), + project_id: Some(project_id.clone()), + team_id: Some(team_id.clone()), + ..test_context.clone() + }) + .await; + if !self.allowed_failure_codes.contains(&resp.status().as_u16()) { + return Err(format!( + "Failure permissions test failed. Expected failure codes {} got {}", + self.allowed_failure_codes + .iter() + .map(|code| code.to_string()) + .join(","), + resp.status().as_u16() + )); + } + if resp.status() == StatusCode::OK { + if let Some(failure_json_check) = &self.failure_json_check { + failure_json_check(&test::read_body_json(resp).await); + } + } + + // Patch user's permissions to success permissions + modify_user_team_permissions( + self.user_id, + &team_id, + Some(success_permissions), + None, + &test_env.setup_api, + ) + .await; + + // Successful test + let resp = req_gen(PermissionsTestContext { + test_pat: self.user_pat.map(|s| s.to_string()), + project_id: Some(project_id.clone()), + team_id: Some(team_id.clone()), + ..test_context.clone() + }) + .await; + if !resp.status().is_success() { + return Err(format!( + "Success permissions test failed. Expected success, got {}", + resp.status().as_u16() + )); + } + if resp.status() == StatusCode::OK { + if let Some(success_json_check) = &self.success_json_check { + success_json_check(&test::read_body_json(resp).await); + } + } + + // If the remove_user flag is set, remove the user from the project + // Relevant for existing projects/users + if self.remove_user { + remove_user_from_team(self.user_id, &team_id, &test_env.setup_api) + .await; + } + Ok(()) + } + + pub async fn simple_organization_permissions_test( + &self, + success_permissions: OrganizationPermissions, + req_gen: T, + ) -> Result<(), String> + where + T: Fn(PermissionsTestContext) -> Fut, + Fut: Future, + { + let test_env = self.test_env; + let failure_organization_permissions = self + .failure_organization_permissions + .unwrap_or(OrganizationPermissions::all() ^ success_permissions); + let test_context = PermissionsTestContext { + test_pat: None, + user_id: self.user_id.to_string(), + project_id: None, + team_id: None, + organization_id: None, + organization_team_id: None, + }; + + let (organization_id, team_id) = if self.organization_id.is_some() + && self.organization_team_id.is_some() + { + ( + self.organization_id.clone().unwrap(), + self.organization_team_id.clone().unwrap(), + ) + } else { + create_dummy_org(&test_env.setup_api).await + }; + + add_user_to_team( + self.user_id, + self.user_pat, + &team_id, + None, + Some(failure_organization_permissions), + &test_env.setup_api, + ) + .await; + + // Failure test + let resp = req_gen(PermissionsTestContext { + test_pat: self.user_pat.map(|s| s.to_string()), + organization_id: Some(organization_id.clone()), + team_id: Some(team_id.clone()), + ..test_context.clone() + }) + .await; + if !self.allowed_failure_codes.contains(&resp.status().as_u16()) { + return Err(format!( + "Failure permissions test failed. Expected failure codes {} got {}. Body: {:#?}", + self.allowed_failure_codes + .iter() + .map(|code| code.to_string()) + .join(","), + resp.status().as_u16(), + resp.response().body() + )); + } + + // Patch user's permissions to success permissions + modify_user_team_permissions( + self.user_id, + &team_id, + None, + Some(success_permissions), + &test_env.setup_api, + ) + .await; + + // Successful test + let resp = req_gen(PermissionsTestContext { + test_pat: self.user_pat.map(|s| s.to_string()), + organization_id: Some(organization_id.clone()), + team_id: Some(team_id.clone()), + ..test_context.clone() + }) + .await; + if !resp.status().is_success() { + return Err(format!( + "Success permissions test failed. Expected success, got {}. Body: {:#?}", + resp.status().as_u16(), + resp.response().body() + )); + } + + // If the remove_user flag is set, remove the user from the organization + // Relevant for existing projects/users + if self.remove_user { + remove_user_from_team(self.user_id, &team_id, &test_env.setup_api) + .await; + } + Ok(()) + } + + pub async fn full_project_permissions_test( + &self, + success_permissions: ProjectPermissions, + req_gen: T, + ) -> Result<(), String> + where + T: Fn(PermissionsTestContext) -> Fut, + Fut: Future, + { + let test_env = self.test_env; + let failure_project_permissions = self + .failure_project_permissions + .unwrap_or(ProjectPermissions::all() ^ success_permissions); + let test_context = PermissionsTestContext { + test_pat: None, + user_id: self.user_id.to_string(), + project_id: None, + team_id: None, + organization_id: None, + organization_team_id: None, + }; + + // TEST 1: User not logged in - no PAT. + // This should always fail, regardless of permissions + // (As we are testing permissions-based failures) + let test_1 = async { + let (project_id, team_id) = + create_dummy_project(&test_env.setup_api).await; + + let resp = req_gen(PermissionsTestContext { + test_pat: None, + project_id: Some(project_id.clone()), + team_id: Some(team_id.clone()), + ..test_context.clone() + }) + .await; + if !self.allowed_failure_codes.contains(&resp.status().as_u16()) { + return Err(format!( + "Test 1 failed. Expected failure codes {} got {}", + self.allowed_failure_codes + .iter() + .map(|code| code.to_string()) + .join(","), + resp.status().as_u16() + )); + } + + let p = get_project_permissions( + self.user_id, + self.user_pat, + &project_id, + &test_env.setup_api, + ) + .await; + if p != ProjectPermissions::empty() { + return Err(format!( + "Test 1 failed. Expected no permissions, got {:?}", + p + )); + } + + Ok(()) + }; + + // TEST 2: Failure + // Random user, unaffiliated with the project, with no permissions + let test_2 = async { + let (project_id, team_id) = + create_dummy_project(&test_env.setup_api).await; + + let resp = req_gen(PermissionsTestContext { + test_pat: self.user_pat.map(|s| s.to_string()), + project_id: Some(project_id.clone()), + team_id: Some(team_id.clone()), + ..test_context.clone() + }) + .await; + if !self.allowed_failure_codes.contains(&resp.status().as_u16()) { + return Err(format!( + "Test 2 failed. Expected failure codes {} got {}", + self.allowed_failure_codes + .iter() + .map(|code| code.to_string()) + .join(","), + resp.status().as_u16() + )); + } + + let p = get_project_permissions( + self.user_id, + self.user_pat, + &project_id, + &test_env.setup_api, + ) + .await; + if p != ProjectPermissions::empty() { + return Err(format!( + "Test 2 failed. Expected no permissions, got {:?}", + p + )); + } + + Ok(()) + }; + + // TEST 3: Failure + // User affiliated with the project, with failure permissions + let test_3 = async { + let (project_id, team_id) = + create_dummy_project(&test_env.setup_api).await; + add_user_to_team( + self.user_id, + self.user_pat, + &team_id, + Some(failure_project_permissions), + None, + &test_env.setup_api, + ) + .await; + + let resp = req_gen(PermissionsTestContext { + test_pat: self.user_pat.map(|s| s.to_string()), + project_id: Some(project_id.clone()), + team_id: Some(team_id.clone()), + ..test_context.clone() + }) + .await; + if !self.allowed_failure_codes.contains(&resp.status().as_u16()) { + return Err(format!( + "Test 3 failed. Expected failure codes {} got {}", + self.allowed_failure_codes + .iter() + .map(|code| code.to_string()) + .join(","), + resp.status().as_u16() + )); + } + + let p = get_project_permissions( + self.user_id, + self.user_pat, + &project_id, + &test_env.setup_api, + ) + .await; + if p != failure_project_permissions { + return Err(format!( + "Test 3 failed. Expected {:?}, got {:?}", + failure_project_permissions, p + )); + } + + Ok(()) + }; + + // TEST 4: Success + // User affiliated with the project, with the given permissions + let test_4 = async { + let (project_id, team_id) = + create_dummy_project(&test_env.setup_api).await; + add_user_to_team( + self.user_id, + self.user_pat, + &team_id, + Some(success_permissions), + None, + &test_env.setup_api, + ) + .await; + + let resp = req_gen(PermissionsTestContext { + test_pat: self.user_pat.map(|s| s.to_string()), + project_id: Some(project_id.clone()), + team_id: Some(team_id.clone()), + ..test_context.clone() + }) + .await; + if !resp.status().is_success() { + return Err(format!( + "Test 4 failed. Expected success, got {}", + resp.status().as_u16() + )); + } + + let p = get_project_permissions( + self.user_id, + self.user_pat, + &project_id, + &test_env.setup_api, + ) + .await; + if p != success_permissions { + return Err(format!( + "Test 4 failed. Expected {:?}, got {:?}", + success_permissions, p + )); + } + + Ok(()) + }; + + // TEST 5: Failure + // Project has an organization + // User affiliated with the project's org, with default failure permissions + let test_5 = async { + let (project_id, team_id) = + create_dummy_project(&test_env.setup_api).await; + let (organization_id, organization_team_id) = + create_dummy_org(&test_env.setup_api).await; + add_project_to_org( + &test_env.setup_api, + &project_id, + &organization_id, + ) + .await; + add_user_to_team( + self.user_id, + self.user_pat, + &organization_team_id, + Some(failure_project_permissions), + None, + &test_env.setup_api, + ) + .await; + + let resp = req_gen(PermissionsTestContext { + test_pat: self.user_pat.map(|s| s.to_string()), + project_id: Some(project_id.clone()), + team_id: Some(team_id.clone()), + ..test_context.clone() + }) + .await; + if !self.allowed_failure_codes.contains(&resp.status().as_u16()) { + return Err(format!( + "Test 5 failed. Expected failure codes {} got {}", + self.allowed_failure_codes + .iter() + .map(|code| code.to_string()) + .join(","), + resp.status().as_u16() + )); + } + + let p = get_project_permissions( + self.user_id, + self.user_pat, + &project_id, + &test_env.setup_api, + ) + .await; + if p != failure_project_permissions { + return Err(format!( + "Test 5 failed. Expected {:?}, got {:?}", + failure_project_permissions, p + )); + } + + Ok(()) + }; + + // TEST 6: Success + // Project has an organization + // User affiliated with the project's org, with the default success + let test_6 = async { + let (project_id, team_id) = + create_dummy_project(&test_env.setup_api).await; + let (organization_id, organization_team_id) = + create_dummy_org(&test_env.setup_api).await; + add_project_to_org( + &test_env.setup_api, + &project_id, + &organization_id, + ) + .await; + add_user_to_team( + self.user_id, + self.user_pat, + &organization_team_id, + Some(success_permissions), + None, + &test_env.setup_api, + ) + .await; + + let resp = req_gen(PermissionsTestContext { + test_pat: self.user_pat.map(|s| s.to_string()), + project_id: Some(project_id.clone()), + team_id: Some(team_id.clone()), + ..test_context.clone() + }) + .await; + if !resp.status().is_success() { + return Err(format!( + "Test 6 failed. Expected success, got {}", + resp.status().as_u16() + )); + } + + let p = get_project_permissions( + self.user_id, + self.user_pat, + &project_id, + &test_env.setup_api, + ) + .await; + if p != success_permissions { + return Err(format!( + "Test 6 failed. Expected {:?}, got {:?}", + success_permissions, p + )); + } + + Ok(()) + }; + + // TEST 7: Failure + // Project has an organization + // User affiliated with the project's org (even can have successful permissions!) + // User overwritten on the project team with failure permissions + let test_7 = async { + let (project_id, team_id) = + create_dummy_project(&test_env.setup_api).await; + let (organization_id, organization_team_id) = + create_dummy_org(&test_env.setup_api).await; + add_project_to_org( + &test_env.setup_api, + &project_id, + &organization_id, + ) + .await; + add_user_to_team( + self.user_id, + self.user_pat, + &organization_team_id, + Some(success_permissions), + None, + &test_env.setup_api, + ) + .await; + add_user_to_team( + self.user_id, + self.user_pat, + &team_id, + Some(failure_project_permissions), + None, + &test_env.setup_api, + ) + .await; + + let resp = req_gen(PermissionsTestContext { + test_pat: self.user_pat.map(|s| s.to_string()), + project_id: Some(project_id.clone()), + team_id: Some(team_id.clone()), + ..test_context.clone() + }) + .await; + if !self.allowed_failure_codes.contains(&resp.status().as_u16()) { + return Err(format!( + "Test 7 failed. Expected failure codes {} got {}", + self.allowed_failure_codes + .iter() + .map(|code| code.to_string()) + .join(","), + resp.status().as_u16() + )); + } + + let p = get_project_permissions( + self.user_id, + self.user_pat, + &project_id, + &test_env.setup_api, + ) + .await; + if p != failure_project_permissions { + return Err(format!( + "Test 7 failed. Expected {:?}, got {:?}", + failure_project_permissions, p + )); + } + + Ok(()) + }; + + // TEST 8: Success + // Project has an organization + // User affiliated with the project's org with default failure permissions + // User overwritten to the project with the success permissions + let test_8 = async { + let (project_id, team_id) = + create_dummy_project(&test_env.setup_api).await; + let (organization_id, organization_team_id) = + create_dummy_org(&test_env.setup_api).await; + add_project_to_org( + &test_env.setup_api, + &project_id, + &organization_id, + ) + .await; + add_user_to_team( + self.user_id, + self.user_pat, + &organization_team_id, + Some(failure_project_permissions), + None, + &test_env.setup_api, + ) + .await; + add_user_to_team( + self.user_id, + self.user_pat, + &team_id, + Some(success_permissions), + None, + &test_env.setup_api, + ) + .await; + + let resp = req_gen(PermissionsTestContext { + test_pat: self.user_pat.map(|s| s.to_string()), + project_id: Some(project_id.clone()), + team_id: Some(team_id.clone()), + ..test_context.clone() + }) + .await; + + if !resp.status().is_success() { + return Err(format!( + "Test 8 failed. Expected success, got {}", + resp.status().as_u16() + )); + } + + let p = get_project_permissions( + self.user_id, + self.user_pat, + &project_id, + &test_env.setup_api, + ) + .await; + if p != success_permissions { + return Err(format!( + "Test 8 failed. Expected {:?}, got {:?}", + success_permissions, p + )); + } + + Ok(()) + }; + + tokio::try_join!( + test_1, test_2, test_3, test_4, test_5, test_6, test_7, test_8 + ) + .map_err(|e| e)?; + + Ok(()) + } + + pub async fn full_organization_permissions_tests( + &self, + success_permissions: OrganizationPermissions, + req_gen: T, + ) -> Result<(), String> + where + T: Fn(PermissionsTestContext) -> Fut, + Fut: Future, + { + let test_env = self.test_env; + let failure_organization_permissions = self + .failure_organization_permissions + .unwrap_or(OrganizationPermissions::all() ^ success_permissions); + let test_context = PermissionsTestContext { + test_pat: None, + user_id: self.user_id.to_string(), + project_id: None, // Will be overwritten on each test + team_id: None, // Will be overwritten on each test + organization_id: None, + organization_team_id: None, + }; + + // TEST 1: Failure + // Random user, entirely unaffliaited with the organization + let test_1 = async { + let (organization_id, organization_team_id) = + create_dummy_org(&test_env.setup_api).await; + + let resp = req_gen(PermissionsTestContext { + test_pat: self.user_pat.map(|s| s.to_string()), + organization_id: Some(organization_id.clone()), + organization_team_id: Some(organization_team_id), + ..test_context.clone() + }) + .await; + if !self.allowed_failure_codes.contains(&resp.status().as_u16()) { + return Err(format!( + "Test 1 failed. Expected failure codes {} got {}", + self.allowed_failure_codes + .iter() + .map(|code| code.to_string()) + .join(","), + resp.status().as_u16() + )); + } + + let p = get_organization_permissions( + self.user_id, + self.user_pat, + &organization_id, + &test_env.setup_api, + ) + .await; + if p != OrganizationPermissions::empty() { + return Err(format!( + "Test 1 failed. Expected no permissions, got {:?}", + p + )); + } + Ok(()) + }; + + // TEST 2: Failure + // User affiliated with the organization, with failure permissions + let test_2 = async { + let (organization_id, organization_team_id) = + create_dummy_org(&test_env.setup_api).await; + add_user_to_team( + self.user_id, + self.user_pat, + &organization_team_id, + None, + Some(failure_organization_permissions), + &test_env.setup_api, + ) + .await; + + let resp = req_gen(PermissionsTestContext { + test_pat: self.user_pat.map(|s| s.to_string()), + organization_id: Some(organization_id.clone()), + organization_team_id: Some(organization_team_id), + ..test_context.clone() + }) + .await; + if !self.allowed_failure_codes.contains(&resp.status().as_u16()) { + return Err(format!( + "Test 2 failed. Expected failure codes {} got {}", + self.allowed_failure_codes + .iter() + .map(|code| code.to_string()) + .join(","), + resp.status().as_u16() + )); + } + + let p = get_organization_permissions( + self.user_id, + self.user_pat, + &organization_id, + &test_env.setup_api, + ) + .await; + if p != failure_organization_permissions { + return Err(format!( + "Test 2 failed. Expected {:?}, got {:?}", + failure_organization_permissions, p + )); + } + Ok(()) + }; + + // TEST 3: Success + // User affiliated with the organization, with the given permissions + let test_3 = async { + let (organization_id, organization_team_id) = + create_dummy_org(&test_env.setup_api).await; + add_user_to_team( + self.user_id, + self.user_pat, + &organization_team_id, + None, + Some(success_permissions), + &test_env.setup_api, + ) + .await; + + let resp = req_gen(PermissionsTestContext { + test_pat: self.user_pat.map(|s| s.to_string()), + organization_id: Some(organization_id.clone()), + organization_team_id: Some(organization_team_id), + ..test_context.clone() + }) + .await; + if !resp.status().is_success() { + return Err(format!( + "Test 3 failed. Expected success, got {}", + resp.status().as_u16() + )); + } + + let p = get_organization_permissions( + self.user_id, + self.user_pat, + &organization_id, + &test_env.setup_api, + ) + .await; + if p != success_permissions { + return Err(format!( + "Test 3 failed. Expected {:?}, got {:?}", + success_permissions, p + )); + } + Ok(()) + }; + + tokio::try_join!(test_1, test_2, test_3,).map_err(|e| e)?; + + Ok(()) + } +} + +async fn create_dummy_project(setup_api: &ApiV3) -> (String, String) { + // Create a very simple project + let slug = generate_random_name("test_project"); + + let (project, _) = setup_api + .add_public_project(&slug, None, None, ADMIN_USER_PAT) + .await; + let project_id = project.id.to_string(); + + let project = setup_api + .get_project_deserialized(&project_id, ADMIN_USER_PAT) + .await; + let team_id = project.team_id.to_string(); + + (project_id, team_id) +} + +async fn create_dummy_org(setup_api: &ApiV3) -> (String, String) { + // Create a very simple organization + let slug = generate_random_name("test_org"); + + let resp = setup_api + .create_organization( + "Example org", + &slug, + "Example description.", + ADMIN_USER_PAT, + ) + .await; + assert!(resp.status().is_success()); + + let organization = setup_api + .get_organization_deserialized(&slug, ADMIN_USER_PAT) + .await; + let organizaion_id = organization.id.to_string(); + let team_id = organization.team_id.to_string(); + + (organizaion_id, team_id) +} + +async fn add_project_to_org( + setup_api: &ApiV3, + project_id: &str, + organization_id: &str, +) { + let resp = setup_api + .organization_add_project(organization_id, project_id, ADMIN_USER_PAT) + .await; + assert!(resp.status().is_success()); +} + +async fn add_user_to_team( + user_id: &str, + user_pat: Option<&str>, + team_id: &str, + project_permissions: Option, + organization_permissions: Option, + setup_api: &ApiV3, +) { + // Invite user + let resp = setup_api + .add_user_to_team( + team_id, + user_id, + project_permissions, + organization_permissions, + ADMIN_USER_PAT, + ) + .await; + assert!(resp.status().is_success()); + + // Accept invitation + setup_api.join_team(team_id, user_pat).await; + // This does not check if the join request was successful, + // as the join is not always needed- an org project + in-org invite + // will automatically go through. +} + +async fn modify_user_team_permissions( + user_id: &str, + team_id: &str, + permissions: Option, + organization_permissions: Option, + setup_api: &ApiV3, +) { + // Send invitation to user + let resp = setup_api + .edit_team_member( + team_id, + user_id, + json!({ + "permissions" : permissions.map(|p| p.bits()), + "organization_permissions" : organization_permissions.map(|p| p.bits()), + }), + ADMIN_USER_PAT, + ) + .await; + assert!(resp.status().is_success()); +} + +async fn remove_user_from_team( + user_id: &str, + team_id: &str, + setup_api: &ApiV3, +) { + // Send invitation to user + let resp = setup_api + .remove_from_team(team_id, user_id, ADMIN_USER_PAT) + .await; + assert!(resp.status().is_success()); +} + +async fn get_project_permissions( + user_id: &str, + user_pat: Option<&str>, + project_id: &str, + setup_api: &ApiV3, +) -> ProjectPermissions { + let project = setup_api + .get_project_deserialized(project_id, user_pat) + .await; + let project_team_id = project.team_id.to_string(); + let organization_id = project.organization.map(|id| id.to_string()); + + let organization = match organization_id { + Some(id) => { + Some(setup_api.get_organization_deserialized(&id, user_pat).await) + } + None => None, + }; + + let members = setup_api + .get_team_members_deserialized(&project_team_id, user_pat) + .await; + let permissions = members + .iter() + .find(|member| member.user.id.to_string() == user_id) + .and_then(|member| member.permissions); + + let organization_members = match organization { + Some(org) => Some( + setup_api + .get_team_members_deserialized( + &org.team_id.to_string(), + user_pat, + ) + .await, + ), + None => None, + }; + let organization_default_project_permissions = match organization_members { + Some(members) => members + .iter() + .find(|member| member.user.id.to_string() == user_id) + .and_then(|member| member.permissions), + None => None, + }; + + permissions + .or(organization_default_project_permissions) + .unwrap_or_default() +} + +async fn get_organization_permissions( + user_id: &str, + user_pat: Option<&str>, + organization_id: &str, + setup_api: &ApiV3, +) -> OrganizationPermissions { + let resp = setup_api + .get_organization_members(organization_id, user_pat) + .await; + let permissions = if resp.status().as_u16() == 200 { + let value: serde_json::Value = test::read_body_json(resp).await; + value + .as_array() + .unwrap() + .iter() + .find(|member| member["user"]["id"].as_str().unwrap() == user_id) + .map(|member| member["organization_permissions"].as_u64().unwrap()) + .unwrap_or_default() + } else { + 0 + }; + + OrganizationPermissions::from_bits_truncate(permissions) +} diff --git a/apps/labrinth/tests/common/scopes.rs b/apps/labrinth/tests/common/scopes.rs new file mode 100644 index 000000000..637914b81 --- /dev/null +++ b/apps/labrinth/tests/common/scopes.rs @@ -0,0 +1,126 @@ +#![allow(dead_code)] +use actix_web::{dev::ServiceResponse, test}; +use futures::Future; +use labrinth::models::pats::Scopes; + +use super::{ + api_common::Api, database::USER_USER_ID_PARSED, + environment::TestEnvironment, pats::create_test_pat, +}; + +// A reusable test type that works for any scope test testing an endpoint that: +// - returns a known 'expected_failure_code' if the scope is not present (defaults to 401) +// - returns a 200-299 if the scope is present +// - returns failure and success JSON bodies for requests that are 200 (for performing non-simple follow-up tests on) +// This uses a builder format, so you can chain methods to set the parameters to non-defaults (most will probably be not need to be set). +pub struct ScopeTest<'a, A> { + test_env: &'a TestEnvironment, + // Scopes expected to fail on this test. By default, this is all scopes except the success scopes. + // (To ensure we have isolated the scope we are testing) + failure_scopes: Option, + // User ID to use for the PATs. By default, this is the USER_USER_ID_PARSED constant. + user_id: i64, + // The code that is expected to be returned if the scope is not present. By default, this is 401 (Unauthorized) + expected_failure_code: u16, +} + +impl<'a, A: Api> ScopeTest<'a, A> { + pub fn new(test_env: &'a TestEnvironment) -> Self { + Self { + test_env, + failure_scopes: None, + user_id: USER_USER_ID_PARSED, + expected_failure_code: 401, + } + } + + // Set non-standard failure scopes + // If not set, it will be set to all scopes except the success scopes + // (eg: if a combination of scopes is needed, but you want to make sure that the endpoint does not work with all-but-one of them) + pub fn with_failure_scopes(mut self, scopes: Scopes) -> Self { + self.failure_scopes = Some(scopes); + self + } + + // Set the user ID to use + // (eg: a moderator, or friend) + pub fn with_user_id(mut self, user_id: i64) -> Self { + self.user_id = user_id; + self + } + + // If a non-401 code is expected. + // (eg: a 404 for a hidden resource, or 200 for a resource with hidden values deeper in) + pub fn with_failure_code(mut self, code: u16) -> Self { + self.expected_failure_code = code; + self + } + + // Call the endpoint generated by req_gen twice, once with a PAT with the failure scopes, and once with the success scopes. + // success_scopes : the scopes that we are testing that should succeed + // returns a tuple of (failure_body, success_body) + // Should return a String error if on unexpected status code, allowing unwrapping in tests. + pub async fn test( + &self, + req_gen: T, + success_scopes: Scopes, + ) -> Result<(serde_json::Value, serde_json::Value), String> + where + T: Fn(Option) -> Fut, + Fut: Future, // Ensure Fut is Send and 'static + { + // First, create a PAT with failure scopes + let failure_scopes = self + .failure_scopes + .unwrap_or(Scopes::all() ^ success_scopes); + let access_token_all_others = + create_test_pat(failure_scopes, self.user_id, &self.test_env.db) + .await; + + // Create a PAT with the success scopes + let access_token = + create_test_pat(success_scopes, self.user_id, &self.test_env.db) + .await; + + // Perform test twice, once with each PAT + // the first time, we expect a 401 (or known failure code) + let resp = req_gen(Some(access_token_all_others.clone())).await; + if resp.status().as_u16() != self.expected_failure_code { + return Err(format!( + "Expected failure code {}, got {} ({:#?})", + self.expected_failure_code, + resp.status().as_u16(), + resp.response() + )); + } + + let failure_body = if resp.status() == 200 + && resp.headers().contains_key("Content-Type") + && resp.headers().get("Content-Type").unwrap() == "application/json" + { + test::read_body_json(resp).await + } else { + serde_json::Value::Null + }; + + // The second time, we expect a success code + let resp = req_gen(Some(access_token.clone())).await; + if !(resp.status().is_success() || resp.status().is_redirection()) { + return Err(format!( + "Expected success code, got {} ({:#?})", + resp.status().as_u16(), + resp.response() + )); + } + + let success_body = if resp.status() == 200 + && resp.headers().contains_key("Content-Type") + && resp.headers().get("Content-Type").unwrap() == "application/json" + { + test::read_body_json(resp).await + } else { + serde_json::Value::Null + }; + Ok((failure_body, success_body)) + } +} diff --git a/apps/labrinth/tests/common/search.rs b/apps/labrinth/tests/common/search.rs new file mode 100644 index 000000000..6bfc8a504 --- /dev/null +++ b/apps/labrinth/tests/common/search.rs @@ -0,0 +1,238 @@ +#![allow(dead_code)] + +use std::{collections::HashMap, sync::Arc}; + +use actix_http::StatusCode; +use serde_json::json; + +use crate::{ + assert_status, + common::{ + api_common::{Api, ApiProject, ApiVersion}, + database::{FRIEND_USER_PAT, MOD_USER_PAT, USER_USER_PAT}, + dummy_data::{TestFile, DUMMY_CATEGORIES}, + }, +}; + +use super::{api_v3::ApiV3, environment::TestEnvironment}; + +pub async fn setup_search_projects( + test_env: &TestEnvironment, +) -> Arc> { + // Test setup and dummy data + let api = &test_env.api; + let test_name = test_env.db.database_name.clone(); + let zeta_organization_id = + &test_env.dummy.organization_zeta.organization_id; + + // Add dummy projects of various categories for searchability + let mut project_creation_futures = vec![]; + + let create_async_future = + |id: u64, + pat: Option<&'static str>, + is_modpack: bool, + modify_json: Option| { + let slug = format!("{test_name}-searchable-project-{id}"); + + let jar = if is_modpack { + TestFile::build_random_mrpack() + } else { + TestFile::build_random_jar() + }; + async move { + // Add a project- simple, should work. + let req = + api.add_public_project(&slug, Some(jar), modify_json, pat); + let (project, _) = req.await; + + // Approve, so that the project is searchable + let resp = api + .edit_project( + &project.id.to_string(), + json!({ + "status": "approved" + }), + MOD_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + (project.id.0, id) + } + }; + + // Test project 0 + let id = 0; + let modify_json = serde_json::from_value(json!([ + { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[4..6] }, + { "op": "add", "path": "/initial_versions/0/server_only", "value": true }, + { "op": "add", "path": "/license_id", "value": "LGPL-3.0-or-later" }, + ])) + .unwrap(); + project_creation_futures.push(create_async_future( + id, + USER_USER_PAT, + false, + Some(modify_json), + )); + + // Test project 1 + let id = 1; + let modify_json = serde_json::from_value(json!([ + { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[0..2] }, + { "op": "add", "path": "/initial_versions/0/client_only", "value": false }, + ])) + .unwrap(); + project_creation_futures.push(create_async_future( + id, + USER_USER_PAT, + false, + Some(modify_json), + )); + + // Test project 2 + let id = 2; + let modify_json = serde_json::from_value(json!([ + { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[0..2] }, + { "op": "add", "path": "/initial_versions/0/server_only", "value": true }, + { "op": "add", "path": "/name", "value": "Mysterious Project" }, + ])) + .unwrap(); + project_creation_futures.push(create_async_future( + id, + USER_USER_PAT, + false, + Some(modify_json), + )); + + // Test project 3 + let id = 3; + let modify_json = serde_json::from_value(json!([ + { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[0..3] }, + { "op": "add", "path": "/initial_versions/0/server_only", "value": true }, + { "op": "add", "path": "/initial_versions/0/game_versions", "value": ["1.20.4"] }, + { "op": "add", "path": "/name", "value": "Mysterious Project" }, + { "op": "add", "path": "/license_id", "value": "LicenseRef-All-Rights-Reserved" }, + ])) + .unwrap(); + project_creation_futures.push(create_async_future( + id, + FRIEND_USER_PAT, + false, + Some(modify_json), + )); + + // Test project 4 + let id = 4; + let modify_json = serde_json::from_value(json!([ + { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[0..3] }, + { "op": "add", "path": "/initial_versions/0/client_only", "value": false }, + { "op": "add", "path": "/initial_versions/0/game_versions", "value": ["1.20.5"] }, + ])) + .unwrap(); + project_creation_futures.push(create_async_future( + id, + USER_USER_PAT, + true, + Some(modify_json), + )); + + // Test project 5 + let id = 5; + let modify_json = serde_json::from_value(json!([ + { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[5..6] }, + { "op": "add", "path": "/initial_versions/0/client_only", "value": false }, + { "op": "add", "path": "/initial_versions/0/game_versions", "value": ["1.20.5"] }, + { "op": "add", "path": "/license_id", "value": "LGPL-3.0-or-later" }, + ])) + .unwrap(); + project_creation_futures.push(create_async_future( + id, + USER_USER_PAT, + false, + Some(modify_json), + )); + + // Test project 6 + let id = 6; + let modify_json = serde_json::from_value(json!([ + { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[5..6] }, + { "op": "add", "path": "/initial_versions/0/client_only", "value": false }, + { "op": "add", "path": "/initial_versions/0/server_only", "value": true }, + { "op": "add", "path": "/license_id", "value": "LGPL-3.0-or-later" }, + ])) + .unwrap(); + project_creation_futures.push(create_async_future( + id, + FRIEND_USER_PAT, + false, + Some(modify_json), + )); + + // Test project 7 (testing the search bug) + // This project has an initial private forge version that is 1.20.3, and a fabric 1.20.5 version. + // This means that a search for fabric + 1.20.3 or forge + 1.20.5 should not return this project. + let id = 7; + let modify_json = serde_json::from_value(json!([ + { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[5..6] }, + { "op": "add", "path": "/initial_versions/0/client_only", "value": false }, + { "op": "add", "path": "/initial_versions/0/server_only", "value": true }, + { "op": "add", "path": "/license_id", "value": "LGPL-3.0-or-later" }, + { "op": "add", "path": "/initial_versions/0/loaders", "value": ["forge"] }, + { "op": "add", "path": "/initial_versions/0/game_versions", "value": ["1.20.2"] }, + ])) + .unwrap(); + project_creation_futures.push(create_async_future( + id, + USER_USER_PAT, + false, + Some(modify_json), + )); + + // Test project 9 (organization) + // This project gets added to the Zeta organization automatically + let id = 9; + let modify_json = serde_json::from_value(json!([ + { "op": "add", "path": "/organization_id", "value": zeta_organization_id }, + ])) + .unwrap(); + project_creation_futures.push(create_async_future( + id, + USER_USER_PAT, + false, + Some(modify_json), + )); + + // Await all project creation + // Returns a mapping of: + // project id -> test id + let id_conversion: Arc> = Arc::new( + futures::future::join_all(project_creation_futures) + .await + .into_iter() + .collect(), + ); + + // Create a second version for project 7 + let project_7 = api + .get_project_deserialized_common( + &format!("{test_name}-searchable-project-7"), + USER_USER_PAT, + ) + .await; + api.add_public_version( + project_7.id, + "1.0.0", + TestFile::build_random_jar(), + None, + None, + USER_USER_PAT, + ) + .await; + + // Forcibly reset the search index + let resp = api.reset_search_index().await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + id_conversion +} diff --git a/apps/labrinth/tests/error.rs b/apps/labrinth/tests/error.rs new file mode 100644 index 000000000..b92e774c1 --- /dev/null +++ b/apps/labrinth/tests/error.rs @@ -0,0 +1,27 @@ +use actix_http::StatusCode; +use actix_web::test; +use bytes::Bytes; +use common::api_common::ApiProject; + +use common::api_v3::ApiV3; +use common::database::USER_USER_PAT; +use common::environment::{with_test_environment, TestEnvironment}; + +mod common; + +#[actix_rt::test] +pub async fn error_404_body() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + // 3 errors should have 404 as non-blank body, for missing resources + let api = &test_env.api; + let resp = api.get_project("does-not-exist", USER_USER_PAT).await; + assert_status!(&resp, StatusCode::NOT_FOUND); + let body = test::read_body(resp).await; + let empty_bytes = Bytes::from_static(b""); + assert_ne!(body, empty_bytes); + }, + ) + .await; +} diff --git a/apps/labrinth/tests/files/200x200.png b/apps/labrinth/tests/files/200x200.png new file mode 100644 index 000000000..bb923179e Binary files /dev/null and b/apps/labrinth/tests/files/200x200.png differ diff --git a/apps/labrinth/tests/files/basic-mod-different.jar b/apps/labrinth/tests/files/basic-mod-different.jar new file mode 100644 index 000000000..616131ae8 Binary files /dev/null and b/apps/labrinth/tests/files/basic-mod-different.jar differ diff --git a/apps/labrinth/tests/files/basic-mod.jar b/apps/labrinth/tests/files/basic-mod.jar new file mode 100644 index 000000000..0987832e9 Binary files /dev/null and b/apps/labrinth/tests/files/basic-mod.jar differ diff --git a/apps/labrinth/tests/files/dummy-project-alpha.jar b/apps/labrinth/tests/files/dummy-project-alpha.jar new file mode 100644 index 000000000..61f82078c Binary files /dev/null and b/apps/labrinth/tests/files/dummy-project-alpha.jar differ diff --git a/apps/labrinth/tests/files/dummy-project-beta.jar b/apps/labrinth/tests/files/dummy-project-beta.jar new file mode 100644 index 000000000..1b072b207 Binary files /dev/null and b/apps/labrinth/tests/files/dummy-project-beta.jar differ diff --git a/apps/labrinth/tests/files/dummy_data.sql b/apps/labrinth/tests/files/dummy_data.sql new file mode 100644 index 000000000..f3fb1e47d --- /dev/null +++ b/apps/labrinth/tests/files/dummy_data.sql @@ -0,0 +1,113 @@ +-- Dummy test data for use in tests. +-- IDs are listed as integers, followed by their equivalent base 62 representation. + +-- Inserts 5 dummy users for testing, with slight differences +-- 'Friend' and 'enemy' function like 'user', but we can use them to simulate 'other' users that may or may not be able to access certain things +-- IDs 1-5, 1-5 +INSERT INTO users (id, username, email, role) VALUES (1, 'Admin', 'admin@modrinth.com', 'admin'); +INSERT INTO users (id, username, email, role) VALUES (2, 'Moderator', 'moderator@modrinth.com', 'moderator'); +INSERT INTO users (id, username, email, role) VALUES (3, 'User', 'user@modrinth.com', 'developer'); +INSERT INTO users (id, username, email, role) VALUES (4, 'Friend', 'friend@modrinth.com', 'developer'); +INSERT INTO users (id, username, email, role) VALUES (5, 'Enemy', 'enemy@modrinth.com', 'developer'); + +-- Full PATs for each user, with different scopes +-- These are not legal PATs, as they contain all scopes- they mimic permissions of a logged in user +-- IDs: 50-54, o p q r s +INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (50, 1, 'admin-pat', 'mrp_patadmin', $1, '2030-08-18 15:48:58.435729+00'); +INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (51, 2, 'moderator-pat', 'mrp_patmoderator', $1, '2030-08-18 15:48:58.435729+00'); +INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (52, 3, 'user-pat', 'mrp_patuser', $1, '2030-08-18 15:48:58.435729+00'); +INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (53, 4, 'friend-pat', 'mrp_patfriend', $1, '2030-08-18 15:48:58.435729+00'); +INSERT INTO pats (id, user_id, name, access_token, scopes, expires) VALUES (54, 5, 'enemy-pat', 'mrp_patenemy', $1, '2030-08-18 15:48:58.435729+00'); + +INSERT INTO loaders (id, loader) VALUES (5, 'fabric'); +INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) VALUES (5,1); + +INSERT INTO loaders (id, loader) VALUES (6, 'forge'); +INSERT INTO loaders_project_types (joining_loader_id, joining_project_type_id) VALUES (6,1); + +INSERT INTO loaders (id, loader, metadata) VALUES (7, 'bukkit', '{"platform":false}'::jsonb); +INSERT INTO loaders (id, loader, metadata) VALUES (8, 'waterfall', '{"platform":true}'::jsonb); + +-- Adds dummies to mrpack_loaders +INSERT INTO loader_field_enum_values (enum_id, value) SELECT id, 'fabric' FROM loader_field_enums WHERE enum_name = 'mrpack_loaders'; +INSERT INTO loader_field_enum_values (enum_id, value) SELECT id, 'forge' FROM loader_field_enums WHERE enum_name = 'mrpack_loaders'; + +INSERT INTO loaders_project_types_games (loader_id, project_type_id, game_id) SELECT joining_loader_id, joining_project_type_id, 1 FROM loaders_project_types WHERE joining_loader_id = 5; +INSERT INTO loaders_project_types_games (loader_id, project_type_id, game_id) SELECT joining_loader_id, joining_project_type_id, 1 FROM loaders_project_types WHERE joining_loader_id = 6; + +-- Dummy-data only optional field, as we don't have any yet +INSERT INTO loader_fields ( + field, + field_type, + optional +) VALUES ( + 'test_fabric_optional', + 'integer', + true +); +INSERT INTO loader_fields_loaders(loader_id, loader_field_id) +SELECT l.id, lf.id FROM loaders l CROSS JOIN loader_fields lf WHERE lf.field = 'test_fabric_optional' AND l.loader = 'fabric' ON CONFLICT DO NOTHING; + +-- Sample game versions, loaders, categories +-- Game versions is '2' +INSERT INTO loader_field_enum_values(enum_id, value, metadata, created) +VALUES (2, '1.20.1', '{"type":"release","major":false}', '2021-08-18 15:48:58.435729+00'); +INSERT INTO loader_field_enum_values(enum_id, value, metadata, created) +VALUES (2, '1.20.2', '{"type":"release","major":false}', '2021-08-18 15:48:59.435729+00'); +INSERT INTO loader_field_enum_values(enum_id, value, metadata, created) +VALUES (2, '1.20.3', '{"type":"release","major":false}', '2021-08-18 15:49:00.435729+00'); +INSERT INTO loader_field_enum_values(enum_id, value, metadata, created) +VALUES (2, '1.20.4', '{"type":"beta","major":false}', '2021-08-18 15:49:01.435729+00'); +INSERT INTO loader_field_enum_values(enum_id, value, metadata, created) +VALUES (2, '1.20.5', '{"type":"release","major":true}', '2061-08-18 15:49:02.435729+00'); + +-- Also add 'Ordering_Negative1' and 'Ordering_Positive100' to game versions (to test ordering override) +INSERT INTO loader_field_enum_values(enum_id, value, metadata, ordering) +VALUES (2, 'Ordering_Negative1', '{"type":"release","major":false}', -1); +INSERT INTO loader_field_enum_values(enum_id, value, metadata, ordering) +VALUES (2, 'Ordering_Positive100', '{"type":"release","major":false}', 100); + +INSERT INTO loader_fields_loaders(loader_id, loader_field_id) +SELECT l.id, lf.id FROM loaders l CROSS JOIN loader_fields lf WHERE lf.field IN ('game_versions','singleplayer', 'client_and_server', 'client_only', 'server_only') ON CONFLICT DO NOTHING; + +INSERT INTO categories (id, category, project_type) VALUES + (51, 'combat', 1), + (52, 'decoration', 1), + (53, 'economy', 1), + (54, 'food', 1), + (55, 'magic', 1), + (56, 'mobs', 1), + (57, 'optimization', 1); + +INSERT INTO categories (id, category, project_type) VALUES + (101, 'combat', 2), + (102, 'decoration', 2), + (103, 'economy', 2), + (104, 'food', 2), + (105, 'magic', 2), + (106, 'mobs', 2), + (107, 'optimization', 2); + +-- Create dummy oauth client, secret_hash is SHA512 hash of full lowercase alphabet +INSERT INTO oauth_clients ( + id, + name, + icon_url, + max_scopes, + secret_hash, + created_by + ) +VALUES ( + 1, + 'oauth_client_alpha', + NULL, + $1, + '4dbff86cc2ca1bae1e16468a05cb9881c97f1753bce3619034898faa1aabe429955a1bf8ec483d7421fe3c1646613a59ed5441fb0f321389f77f48a879c7b1f1', + 3 + ); +INSERT INTO oauth_client_redirect_uris (id, client_id, uri) VALUES (1, 1, 'https://modrinth.com/oauth_callback'); + +-- Create dummy data table to mark that this file has been run +CREATE TABLE dummy_data ( + update_id bigint PRIMARY KEY + ); diff --git a/apps/labrinth/tests/files/simple-zip.zip b/apps/labrinth/tests/files/simple-zip.zip new file mode 100644 index 000000000..20bf64b85 Binary files /dev/null and b/apps/labrinth/tests/files/simple-zip.zip differ diff --git a/apps/labrinth/tests/games.rs b/apps/labrinth/tests/games.rs new file mode 100644 index 000000000..c078f9946 --- /dev/null +++ b/apps/labrinth/tests/games.rs @@ -0,0 +1,29 @@ +// TODO: fold this into loader_fields.rs or tags.rs of other v3 testing PR + +use common::{ + api_v3::ApiV3, + environment::{with_test_environment, TestEnvironment}, +}; + +mod common; + +#[actix_rt::test] +async fn get_games() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = test_env.api; + + let games = api.get_games_deserialized().await; + + // There should be 2 games in the dummy data + assert_eq!(games.len(), 2); + assert_eq!(games[0].name, "minecraft-java"); + assert_eq!(games[1].name, "minecraft-bedrock"); + + assert_eq!(games[0].slug, "minecraft-java"); + assert_eq!(games[1].slug, "minecraft-bedrock"); + }, + ) + .await; +} diff --git a/apps/labrinth/tests/loader_fields.rs b/apps/labrinth/tests/loader_fields.rs new file mode 100644 index 000000000..78ee67a40 --- /dev/null +++ b/apps/labrinth/tests/loader_fields.rs @@ -0,0 +1,653 @@ +use std::collections::HashSet; + +use actix_http::StatusCode; +use actix_web::test; +use common::api_v3::ApiV3; +use common::environment::{with_test_environment, TestEnvironment}; +use itertools::Itertools; +use labrinth::database::models::legacy_loader_fields::MinecraftGameVersion; +use labrinth::models::v3; +use serde_json::json; + +use crate::common::api_common::{ApiProject, ApiVersion}; +use crate::common::api_v3::request_data::get_public_project_creation_data; +use crate::common::database::*; + +use crate::common::dummy_data::{ + DummyProjectAlpha, DummyProjectBeta, TestFile, +}; + +// importing common module. +mod common; + +#[actix_rt::test] + +async fn creating_loader_fields() { + with_test_environment(None, |test_env: TestEnvironment| async move { + let api = &test_env.api; + let DummyProjectAlpha { + project_id: alpha_project_id, + project_id_parsed: alpha_project_id_parsed, + version_id: alpha_version_id, + .. + } = &test_env.dummy.project_alpha; + let DummyProjectBeta { + project_id_parsed: beta_project_id_parsed, + .. + } = &test_env.dummy.project_beta; + + // ALL THE FOLLOWING FOR CREATE AND PATCH + // Cannot create a version with an extra argument that cannot be tied to a loader field ("invalid loader field") + // TODO: - Create project + // - Create version + let resp = api + .add_public_version( + *alpha_project_id_parsed, + "1.0.0", + TestFile::build_random_jar(), + None, + Some( + serde_json::from_value(json!([{ + "op": "add", + "path": "/invalid", + "value": "invalid" + }])) + .unwrap(), + ), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + // - Patch + let resp = api + .edit_version( + alpha_version_id, + json!({ + "invalid": "invalid" + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // Cannot create a version with a loader field that isnt used by the loader + // TODO: - Create project + // - Create version + let resp = api + .add_public_version( + *alpha_project_id_parsed, + "1.0.0", + TestFile::build_random_jar(), + None, + Some( + serde_json::from_value(json!([{ + "op": "add", + "path": "/mrpack_loaders", + "value": ["fabric"] + }])) + .unwrap(), + ), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + // - Patch + let resp = api + .edit_version( + alpha_version_id, + json!({ + "mrpack_loaders": ["fabric"] + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // Cannot create a version without an applicable loader field that is not optional + // TODO: - Create project + // - Create version + let resp = api + .add_public_version( + *alpha_project_id_parsed, + "1.0.0", + TestFile::build_random_jar(), + None, + Some( + serde_json::from_value(json!([{ + "op": "remove", + "path": "/singleplayer" + }])) + .unwrap(), + ), + USER_USER_PAT, + ) + .await; + + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // Cannot create a version without a loader field array that has a minimum of 1 + // TODO: - Create project + // - Create version + let resp = api + .add_public_version( + *alpha_project_id_parsed, + "1.0.0", + TestFile::build_random_jar(), + None, + Some( + serde_json::from_value(json!([{ + "op": "remove", + "path": "/game_versions" + }])) + .unwrap(), + ), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // TODO: Create a test for too many elements in the array when we have a LF that has a max (past max) + // Cannot create a version with a loader field array that has fewer than the minimum elements + // TODO: - Create project + // - Create version + let resp: actix_web::dev::ServiceResponse = api + .add_public_version( + *alpha_project_id_parsed, + "1.0.0", + TestFile::build_random_jar(), + None, + Some( + serde_json::from_value(json!([{ + "op": "add", + "path": "/game_versions", + "value": [] + }])) + .unwrap(), + ), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // - Patch + let resp = api + .edit_version( + alpha_version_id, + json!({ + "game_versions": [] + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // Cannot create an invalid data type for the loader field type (including bad variant for the type) + for bad_type_game_versions in [ + json!(1), + json!([1]), + json!("1.20.1"), + json!(["singleplayer"]), + ] { + // TODO: - Create project + // - Create version + let resp = api + .add_public_version( + *alpha_project_id_parsed, + "1.0.0", + TestFile::build_random_jar(), + None, + Some( + serde_json::from_value(json!([{ + "op": "add", + "path": "/game_versions", + "value": bad_type_game_versions + }])) + .unwrap(), + ), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // - Patch + let resp = api + .edit_version( + alpha_version_id, + json!({ + "game_versions": bad_type_game_versions + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + } + + // Can create with optional loader fields (other tests have checked if we can create without them) + // TODO: - Create project + // - Create version + let v = api + .add_public_version_deserialized( + *alpha_project_id_parsed, + "1.0.0", + TestFile::build_random_jar(), + None, + Some( + serde_json::from_value(json!([{ + "op": "add", + "path": "/test_fabric_optional", + "value": 555 + }])) + .unwrap(), + ), + USER_USER_PAT, + ) + .await; + assert_eq!(v.fields.get("test_fabric_optional").unwrap(), &json!(555)); + // - Patch + let resp = api + .edit_version( + alpha_version_id, + json!({ + "test_fabric_optional": 555 + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + let v = api + .get_version_deserialized(alpha_version_id, USER_USER_PAT) + .await; + assert_eq!(v.fields.get("test_fabric_optional").unwrap(), &json!(555)); + + // Simply setting them as expected works + // - Create + let v = api + .add_public_version_deserialized( + *alpha_project_id_parsed, + "1.0.0", + TestFile::build_random_jar(), + None, + Some( + serde_json::from_value(json!([{ + "op": "add", + "path": "/game_versions", + "value": ["1.20.1", "1.20.2"] + }, { + "op": "add", + "path": "/singleplayer", + "value": false + }, { + "op": "add", + "path": "/server_only", + "value": true + }])) + .unwrap(), + ), + USER_USER_PAT, + ) + .await; + assert_eq!( + v.fields.get("game_versions").unwrap(), + &json!(["1.20.1", "1.20.2"]) + ); + assert_eq!(v.fields.get("singleplayer").unwrap(), &json!(false)); + assert_eq!(v.fields.get("server_only").unwrap(), &json!(true)); + // - Patch + let resp = api + .edit_version( + alpha_version_id, + json!({ + "game_versions": ["1.20.1", "1.20.2"], + "singleplayer": false, + "server_only": true + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + let v = api + .get_version_deserialized(alpha_version_id, USER_USER_PAT) + .await; + assert_eq!( + v.fields.get("game_versions").unwrap(), + &json!(["1.20.1", "1.20.2"]) + ); + + // Now that we've created a version, we need to make sure that the Project's loader fields are updated (aggregate) + // First, add a new version + api.add_public_version_deserialized( + *alpha_project_id_parsed, + "1.0.1", + TestFile::build_random_jar(), + None, + Some( + serde_json::from_value(json!([{ + "op": "add", + "path": "/game_versions", + "value": ["1.20.5"] + }, { + "op": "add", + "path": "/singleplayer", + "value": false + }])) + .unwrap(), + ), + USER_USER_PAT, + ) + .await; + + // Also, add one to the beta project + api.add_public_version_deserialized( + *beta_project_id_parsed, + "1.0.1", + TestFile::build_random_jar(), + None, + Some( + serde_json::from_value(json!([{ + "op": "add", + "path": "/game_versions", + "value": ["1.20.4"] + }])) + .unwrap(), + ), + USER_USER_PAT, + ) + .await; + + let project = api + .get_project_deserialized( + &alpha_project_id.to_string(), + USER_USER_PAT, + ) + .await; + assert_eq!( + project.fields.get("game_versions").unwrap(), + &[json!("1.20.1"), json!("1.20.2"), json!("1.20.5")] + ); + assert!(project + .fields + .get("singleplayer") + .unwrap() + .contains(&json!(false))); + assert!(project + .fields + .get("singleplayer") + .unwrap() + .contains(&json!(true))); + }) + .await +} + +#[actix_rt::test] +async fn get_loader_fields_variants() { + with_test_environment(None, |test_env: TestEnvironment| async move { + let api = &test_env.api; + + let game_versions = api + .get_loader_field_variants_deserialized("game_versions") + .await; + + // These tests match dummy data and will need to be updated if the dummy data changes + // Versions should be ordered by: + // - ordering + // - ordering ties settled by date added to database + // - We also expect presentation of NEWEST to OLDEST + // - All null orderings are treated as older than any non-null ordering + // (for this test, the 1.20.1, etc, versions are all null ordering) + let game_version_versions = game_versions + .into_iter() + .map(|x| x.value) + .collect::>(); + assert_eq!( + game_version_versions, + [ + "Ordering_Negative1", + "Ordering_Positive100", + "1.20.5", + "1.20.4", + "1.20.3", + "1.20.2", + "1.20.1" + ] + ); + }) + .await +} + +#[actix_rt::test] +async fn get_available_loader_fields() { + // Get available loader fields for a given loader + // (ie: which fields are relevant for 'fabric', etc) + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + let loaders = api.get_loaders_deserialized().await; + + let fabric_loader_fields = loaders + .iter() + .find(|x| x.name == "fabric") + .unwrap() + .supported_fields + .clone() + .into_iter() + .collect::>(); + assert_eq!( + fabric_loader_fields, + [ + "game_versions", + "singleplayer", + "client_and_server", + "client_only", + "server_only", + "test_fabric_optional" // exists for testing + ] + .iter() + .map(|s| s.to_string()) + .collect() + ); + + let mrpack_loader_fields = loaders + .iter() + .find(|x| x.name == "mrpack") + .unwrap() + .supported_fields + .clone() + .into_iter() + .collect::>(); + assert_eq!( + mrpack_loader_fields, + [ + "game_versions", + "singleplayer", + "client_and_server", + "client_only", + "server_only", + // mrpack has all the general fields as well as this + "mrpack_loaders" + ] + .iter() + .map(|s| s.to_string()) + .collect() + ); + }, + ) + .await; +} + +#[actix_rt::test] +async fn test_multi_get_redis_cache() { + // Ensures a multi-project get including both modpacks and mods ddoes not + // incorrectly cache loader fields + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + + // Create 5 modpacks + let mut modpacks = Vec::new(); + for i in 0..5 { + let slug = format!("test-modpack-{}", i); + + let creation_data = get_public_project_creation_data( + &slug, + Some(TestFile::build_random_mrpack()), + None, + ); + let resp = + api.create_project(creation_data, USER_USER_PAT).await; + assert_status!(&resp, StatusCode::OK); + modpacks.push(slug); + } + + // Create 5 mods + let mut mods = Vec::new(); + for i in 0..5 { + let slug = format!("test-mod-{}", i); + + let creation_data = get_public_project_creation_data( + &slug, + Some(TestFile::build_random_jar()), + None, + ); + let resp = + api.create_project(creation_data, USER_USER_PAT).await; + assert_status!(&resp, StatusCode::OK); + mods.push(slug); + } + + // Get all 10 projects + let project_slugs = modpacks + .iter() + .map(|x| x.as_str()) + .chain(mods.iter().map(|x| x.as_str())) + .collect_vec(); + let resp = api.get_projects(&project_slugs, USER_USER_PAT).await; + assert_status!(&resp, StatusCode::OK); + let projects: Vec = + test::read_body_json(resp).await; + assert_eq!(projects.len(), 10); + + // Ensure all 5 modpacks have 'mrpack_loaders', and all 5 mods do not + for project in projects.iter() { + if modpacks.contains(project.slug.as_ref().unwrap()) { + assert!(project.fields.contains_key("mrpack_loaders")); + } else if mods.contains(project.slug.as_ref().unwrap()) { + assert!(!project.fields.contains_key("mrpack_loaders")); + } else { + panic!("Unexpected project slug: {:?}", project.slug); + } + } + + // Get a version from each project + let version_ids_modpacks = projects + .iter() + .filter(|x| modpacks.contains(x.slug.as_ref().unwrap())) + .map(|x| x.versions[0]) + .collect_vec(); + let version_ids_mods = projects + .iter() + .filter(|x| mods.contains(x.slug.as_ref().unwrap())) + .map(|x| x.versions[0]) + .collect_vec(); + let version_ids = version_ids_modpacks + .iter() + .chain(version_ids_mods.iter()) + .map(|x| x.to_string()) + .collect_vec(); + let resp = api.get_versions(version_ids, USER_USER_PAT).await; + assert_status!(&resp, StatusCode::OK); + let versions: Vec = + test::read_body_json(resp).await; + assert_eq!(versions.len(), 10); + + // Ensure all 5 versions from modpacks have 'mrpack_loaders', and all 5 versions from mods do not + for version in versions.iter() { + if version_ids_modpacks.contains(&version.id) { + assert!(version.fields.contains_key("mrpack_loaders")); + } else if version_ids_mods.contains(&version.id) { + assert!(!version.fields.contains_key("mrpack_loaders")); + } else { + panic!("Unexpected version id: {:?}", version.id); + } + } + }, + ) + .await; +} + +#[actix_rt::test] +async fn minecraft_game_version_update() { + // We simulate adding a Minecraft game version, to ensure other data doesn't get overwritten + // This is basically a test for the insertion/concatenation query + // This doesn't use a route (as this behaviour isn't exposed via a route, but a scheduled URL call) + // We just interact with the labrinth functions directly + with_test_environment(None, |test_env: TestEnvironment| async move { + let api = &test_env.api; + + // First, get a list of all gameversions + let game_versions = api + .get_loader_field_variants_deserialized("game_versions") + .await; + + // A couple specific checks- in the dummy data, all game versions are marked as major=false except 1.20.5 + let name_to_major = game_versions + .iter() + .map(|x| { + ( + x.value.clone(), + x.metadata.get("major").unwrap().as_bool().unwrap(), + ) + }) + .collect::>(); + for (name, major) in name_to_major { + if name == "1.20.5" { + assert!(major); + } else { + assert!(!major); + } + } + + // Now, we add a new game version, directly to the db + let pool = test_env.db.pool.clone(); + let redis = test_env.db.redis_pool.clone(); + MinecraftGameVersion::builder() + .version("1.20.6") + .unwrap() + .version_type("release") + .unwrap() + .created( + // now + &chrono::Utc::now(), + ) + .insert(&pool, &redis) + .await + .unwrap(); + + // Check again + let game_versions = api + .get_loader_field_variants_deserialized("game_versions") + .await; + + let name_to_major = game_versions + .iter() + .map(|x| { + ( + x.value.clone(), + x.metadata.get("major").unwrap().as_bool().unwrap(), + ) + }) + .collect::>(); + // Confirm that the new version is there + assert!(name_to_major.contains_key("1.20.6")); + // Confirm metadata is unaltered + for (name, major) in name_to_major { + if name == "1.20.5" { + assert!(major); + } else { + assert!(!major); + } + } + }) + .await +} diff --git a/apps/labrinth/tests/notifications.rs b/apps/labrinth/tests/notifications.rs new file mode 100644 index 000000000..d63fc819a --- /dev/null +++ b/apps/labrinth/tests/notifications.rs @@ -0,0 +1,99 @@ +use common::{ + database::{FRIEND_USER_ID, FRIEND_USER_PAT, USER_USER_PAT}, + environment::with_test_environment_all, +}; + +use crate::common::api_common::ApiTeams; + +mod common; + +#[actix_rt::test] +pub async fn get_user_notifications_after_team_invitation_returns_notification() +{ + with_test_environment_all(None, |test_env| async move { + let alpha_team_id = test_env.dummy.project_alpha.team_id.clone(); + let api = test_env.api; + api.get_user_notifications_deserialized_common( + FRIEND_USER_ID, + FRIEND_USER_PAT, + ) + .await; + + api.add_user_to_team( + &alpha_team_id, + FRIEND_USER_ID, + None, + None, + USER_USER_PAT, + ) + .await; + + let notifications = api + .get_user_notifications_deserialized_common( + FRIEND_USER_ID, + FRIEND_USER_PAT, + ) + .await; + assert_eq!(1, notifications.len()); + }) + .await; +} + +#[actix_rt::test] +pub async fn get_user_notifications_after_reading_indicates_notification_read() +{ + with_test_environment_all(None, |test_env| async move { + test_env.generate_friend_user_notification().await; + let api = test_env.api; + let notifications = api + .get_user_notifications_deserialized_common( + FRIEND_USER_ID, + FRIEND_USER_PAT, + ) + .await; + assert_eq!(1, notifications.len()); + let notification_id = notifications[0].id.to_string(); + + api.mark_notification_read(¬ification_id, FRIEND_USER_PAT) + .await; + + let notifications = api + .get_user_notifications_deserialized_common( + FRIEND_USER_ID, + FRIEND_USER_PAT, + ) + .await; + assert_eq!(1, notifications.len()); + assert!(notifications[0].read); + }) + .await; +} + +#[actix_rt::test] +pub async fn get_user_notifications_after_deleting_does_not_show_notification() +{ + with_test_environment_all(None, |test_env| async move { + test_env.generate_friend_user_notification().await; + let api = test_env.api; + let notifications = api + .get_user_notifications_deserialized_common( + FRIEND_USER_ID, + FRIEND_USER_PAT, + ) + .await; + assert_eq!(1, notifications.len()); + let notification_id = notifications[0].id.to_string(); + + api.delete_notification(¬ification_id, FRIEND_USER_PAT) + .await; + + let notifications = api + .get_user_notifications_deserialized_common( + FRIEND_USER_ID, + FRIEND_USER_PAT, + ) + .await; + assert_eq!(0, notifications.len()); + }) + .await; +} diff --git a/apps/labrinth/tests/oauth.rs b/apps/labrinth/tests/oauth.rs new file mode 100644 index 000000000..4ff3fc5c6 --- /dev/null +++ b/apps/labrinth/tests/oauth.rs @@ -0,0 +1,327 @@ +use actix_http::StatusCode; +use actix_web::test; +use common::{ + api_v3::oauth::get_redirect_location_query_params, + api_v3::{ + oauth::{ + get_auth_code_from_redirect_params, get_authorize_accept_flow_id, + }, + ApiV3, + }, + database::FRIEND_USER_ID, + database::{FRIEND_USER_PAT, USER_USER_ID, USER_USER_PAT}, + dummy_data::DummyOAuthClientAlpha, + environment::{with_test_environment, TestEnvironment}, +}; +use labrinth::auth::oauth::TokenResponse; +use reqwest::header::{CACHE_CONTROL, PRAGMA}; + +mod common; + +#[actix_rt::test] +async fn oauth_flow_happy_path() { + with_test_environment(None, |env: TestEnvironment| async move { + let DummyOAuthClientAlpha { + valid_redirect_uri: base_redirect_uri, + client_id, + client_secret, + } = &env.dummy.oauth_client_alpha; + + // Initiate authorization + let redirect_uri = format!("{}?foo=bar", base_redirect_uri); + let original_state = "1234"; + let resp = env + .api + .oauth_authorize( + client_id, + Some("USER_READ NOTIFICATION_READ"), + Some(&redirect_uri), + Some(original_state), + FRIEND_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::OK); + let flow_id = get_authorize_accept_flow_id(resp).await; + + // Accept the authorization request + let resp = env.api.oauth_accept(&flow_id, FRIEND_USER_PAT).await; + assert_status!(&resp, StatusCode::OK); + let query = get_redirect_location_query_params(&resp); + + let auth_code = query.get("code").unwrap(); + let state = query.get("state").unwrap(); + let foo_val = query.get("foo").unwrap(); + assert_eq!(state, original_state); + assert_eq!(foo_val, "bar"); + + // Get the token + let resp = env + .api + .oauth_token( + auth_code.to_string(), + Some(redirect_uri.clone()), + client_id.to_string(), + client_secret, + ) + .await; + assert_status!(&resp, StatusCode::OK); + assert_eq!(resp.headers().get(CACHE_CONTROL).unwrap(), "no-store"); + assert_eq!(resp.headers().get(PRAGMA).unwrap(), "no-cache"); + let token_resp: TokenResponse = test::read_body_json(resp).await; + + // Validate the token works + env.assert_read_notifications_status( + FRIEND_USER_ID, + Some(&token_resp.access_token), + StatusCode::OK, + ) + .await; + }) + .await; +} + +#[actix_rt::test] +async fn oauth_authorize_for_already_authorized_scopes_returns_auth_code() { + with_test_environment(None, |env: TestEnvironment| async move { + let DummyOAuthClientAlpha { client_id, .. } = + env.dummy.oauth_client_alpha; + + let resp = env + .api + .oauth_authorize( + &client_id, + Some("USER_READ NOTIFICATION_READ"), + None, + Some("1234"), + USER_USER_PAT, + ) + .await; + let flow_id = get_authorize_accept_flow_id(resp).await; + env.api.oauth_accept(&flow_id, USER_USER_PAT).await; + + let resp = env + .api + .oauth_authorize( + &client_id, + Some("USER_READ"), + None, + Some("5678"), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::OK); + }) + .await; +} + +#[actix_rt::test] +async fn get_oauth_token_with_already_used_auth_code_fails() { + with_test_environment(None, |env: TestEnvironment| async move { + let DummyOAuthClientAlpha { + client_id, + client_secret, + .. + } = env.dummy.oauth_client_alpha; + + let resp = env + .api + .oauth_authorize(&client_id, None, None, None, USER_USER_PAT) + .await; + let flow_id = get_authorize_accept_flow_id(resp).await; + + let resp = env.api.oauth_accept(&flow_id, USER_USER_PAT).await; + let auth_code = get_auth_code_from_redirect_params(&resp).await; + + let resp = env + .api + .oauth_token( + auth_code.clone(), + None, + client_id.clone(), + &client_secret, + ) + .await; + assert_status!(&resp, StatusCode::OK); + + let resp = env + .api + .oauth_token(auth_code, None, client_id, &client_secret) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + }) + .await; +} + +#[actix_rt::test] +async fn authorize_with_broader_scopes_can_complete_flow() { + with_test_environment(None, |env: TestEnvironment| async move { + let DummyOAuthClientAlpha { + client_id, + client_secret, + .. + } = env.dummy.oauth_client_alpha.clone(); + + let first_access_token = env + .api + .complete_full_authorize_flow( + &client_id, + &client_secret, + Some("PROJECT_READ"), + None, + None, + USER_USER_PAT, + ) + .await; + let second_access_token = env + .api + .complete_full_authorize_flow( + &client_id, + &client_secret, + Some("PROJECT_READ NOTIFICATION_READ"), + None, + None, + USER_USER_PAT, + ) + .await; + + env.assert_read_notifications_status( + USER_USER_ID, + Some(&first_access_token), + StatusCode::UNAUTHORIZED, + ) + .await; + env.assert_read_user_projects_status( + USER_USER_ID, + Some(&first_access_token), + StatusCode::OK, + ) + .await; + + env.assert_read_notifications_status( + USER_USER_ID, + Some(&second_access_token), + StatusCode::OK, + ) + .await; + env.assert_read_user_projects_status( + USER_USER_ID, + Some(&second_access_token), + StatusCode::OK, + ) + .await; + }) + .await; +} + +#[actix_rt::test] +async fn oauth_authorize_with_broader_scopes_requires_user_accept() { + with_test_environment(None, |env: TestEnvironment| async move { + let client_id = env.dummy.oauth_client_alpha.client_id; + let resp = env + .api + .oauth_authorize( + &client_id, + Some("USER_READ"), + None, + None, + USER_USER_PAT, + ) + .await; + let flow_id = get_authorize_accept_flow_id(resp).await; + env.api.oauth_accept(&flow_id, USER_USER_PAT).await; + + let resp = env + .api + .oauth_authorize( + &client_id, + Some("USER_READ NOTIFICATION_READ"), + None, + None, + USER_USER_PAT, + ) + .await; + + assert_status!(&resp, StatusCode::OK); + get_authorize_accept_flow_id(resp).await; // ensure we can deser this without error to really confirm + }) + .await; +} + +#[actix_rt::test] +async fn reject_authorize_ends_authorize_flow() { + with_test_environment(None, |env: TestEnvironment| async move { + let client_id = env.dummy.oauth_client_alpha.client_id; + let resp = env + .api + .oauth_authorize(&client_id, None, None, None, USER_USER_PAT) + .await; + let flow_id = get_authorize_accept_flow_id(resp).await; + + let resp = env.api.oauth_reject(&flow_id, USER_USER_PAT).await; + assert_status!(&resp, StatusCode::OK); + + let resp = env.api.oauth_accept(&flow_id, USER_USER_PAT).await; + assert_any_status_except!(&resp, StatusCode::OK); + }) + .await; +} + +#[actix_rt::test] +async fn accept_authorize_after_already_accepting_fails() { + with_test_environment(None, |env: TestEnvironment| async move { + let client_id = env.dummy.oauth_client_alpha.client_id; + let resp = env + .api + .oauth_authorize(&client_id, None, None, None, USER_USER_PAT) + .await; + let flow_id = get_authorize_accept_flow_id(resp).await; + let resp = env.api.oauth_accept(&flow_id, USER_USER_PAT).await; + assert_status!(&resp, StatusCode::OK); + + let resp = env.api.oauth_accept(&flow_id, USER_USER_PAT).await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + }) + .await; +} + +#[actix_rt::test] +async fn revoke_authorization_after_issuing_token_revokes_token() { + with_test_environment(None, |env: TestEnvironment| async move { + let DummyOAuthClientAlpha { + client_id, + client_secret, + .. + } = &env.dummy.oauth_client_alpha; + let access_token = env + .api + .complete_full_authorize_flow( + client_id, + client_secret, + Some("NOTIFICATION_READ"), + None, + None, + USER_USER_PAT, + ) + .await; + env.assert_read_notifications_status( + USER_USER_ID, + Some(&access_token), + StatusCode::OK, + ) + .await; + + let resp = env + .api + .revoke_oauth_authorization(client_id, USER_USER_PAT) + .await; + assert_status!(&resp, StatusCode::OK); + + env.assert_read_notifications_status( + USER_USER_ID, + Some(&access_token), + StatusCode::UNAUTHORIZED, + ) + .await; + }) + .await; +} diff --git a/apps/labrinth/tests/oauth_clients.rs b/apps/labrinth/tests/oauth_clients.rs new file mode 100644 index 000000000..335dbca44 --- /dev/null +++ b/apps/labrinth/tests/oauth_clients.rs @@ -0,0 +1,187 @@ +use actix_http::StatusCode; +use actix_web::test; +use common::{ + api_v3::ApiV3, + database::{FRIEND_USER_ID, FRIEND_USER_PAT, USER_USER_ID, USER_USER_PAT}, + dummy_data::DummyOAuthClientAlpha, + environment::{with_test_environment, TestEnvironment}, + get_json_val_str, +}; +use labrinth::{ + models::{ + oauth_clients::{OAuthClient, OAuthClientCreationResult}, + pats::Scopes, + }, + routes::v3::oauth_clients::OAuthClientEdit, +}; + +use common::database::USER_USER_ID_PARSED; + +mod common; + +#[actix_rt::test] +async fn can_create_edit_get_oauth_client() { + with_test_environment(None, |env: TestEnvironment| async move { + let client_name = "test_client".to_string(); + let redirect_uris = vec![ + "https://modrinth.com".to_string(), + "https://modrinth.com/a".to_string(), + ]; + let resp = env + .api + .add_oauth_client( + client_name.clone(), + Scopes::all() - Scopes::restricted(), + redirect_uris.clone(), + FRIEND_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::OK); + let creation_result: OAuthClientCreationResult = + test::read_body_json(resp).await; + let client_id = get_json_val_str(creation_result.client.id); + + let url = Some("https://modrinth.com".to_string()); + let description = Some("test description".to_string()); + let edited_redirect_uris = vec![ + redirect_uris[0].clone(), + "https://modrinth.com/b".to_string(), + ]; + let edit = OAuthClientEdit { + name: None, + max_scopes: None, + redirect_uris: Some(edited_redirect_uris.clone()), + url: Some(url.clone()), + description: Some(description.clone()), + }; + let resp = env + .api + .edit_oauth_client(&client_id, edit, FRIEND_USER_PAT) + .await; + assert_status!(&resp, StatusCode::OK); + + let clients = env + .api + .get_user_oauth_clients(FRIEND_USER_ID, FRIEND_USER_PAT) + .await; + assert_eq!(1, clients.len()); + assert_eq!(url, clients[0].url); + assert_eq!(description, clients[0].description); + assert_eq!(client_name, clients[0].name); + assert_eq!(2, clients[0].redirect_uris.len()); + assert_eq!(edited_redirect_uris[0], clients[0].redirect_uris[0].uri); + assert_eq!(edited_redirect_uris[1], clients[0].redirect_uris[1].uri); + }) + .await; +} + +#[actix_rt::test] +async fn create_oauth_client_with_restricted_scopes_fails() { + with_test_environment(None, |env: TestEnvironment| async move { + let resp = env + .api + .add_oauth_client( + "test_client".to_string(), + Scopes::restricted(), + vec!["https://modrinth.com".to_string()], + FRIEND_USER_PAT, + ) + .await; + + assert_status!(&resp, StatusCode::BAD_REQUEST); + }) + .await; +} + +#[actix_rt::test] +async fn get_oauth_client_for_client_creator_succeeds() { + with_test_environment(None, |env: TestEnvironment| async move { + let DummyOAuthClientAlpha { client_id, .. } = + env.dummy.oauth_client_alpha.clone(); + + let resp = env + .api + .get_oauth_client(client_id.clone(), USER_USER_PAT) + .await; + + assert_status!(&resp, StatusCode::OK); + let client: OAuthClient = test::read_body_json(resp).await; + assert_eq!(get_json_val_str(client.id), client_id); + }) + .await; +} + +#[actix_rt::test] +async fn can_delete_oauth_client() { + with_test_environment(None, |env: TestEnvironment| async move { + let client_id = env.dummy.oauth_client_alpha.client_id.clone(); + let resp = env.api.delete_oauth_client(&client_id, USER_USER_PAT).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let clients = env + .api + .get_user_oauth_clients(USER_USER_ID, USER_USER_PAT) + .await; + assert_eq!(0, clients.len()); + }) + .await; +} + +#[actix_rt::test] +async fn delete_oauth_client_after_issuing_access_tokens_revokes_tokens() { + with_test_environment(None, |env: TestEnvironment| async move { + let DummyOAuthClientAlpha { + client_id, + client_secret, + .. + } = env.dummy.oauth_client_alpha.clone(); + let access_token = env + .api + .complete_full_authorize_flow( + &client_id, + &client_secret, + Some("NOTIFICATION_READ"), + None, + None, + USER_USER_PAT, + ) + .await; + + env.api.delete_oauth_client(&client_id, USER_USER_PAT).await; + + env.assert_read_notifications_status( + USER_USER_ID, + Some(&access_token), + StatusCode::UNAUTHORIZED, + ) + .await; + }) + .await; +} + +#[actix_rt::test] +async fn can_list_user_oauth_authorizations() { + with_test_environment(None, |env: TestEnvironment| async move { + let DummyOAuthClientAlpha { + client_id, + client_secret, + .. + } = env.dummy.oauth_client_alpha.clone(); + env.api + .complete_full_authorize_flow( + &client_id, + &client_secret, + None, + None, + None, + USER_USER_PAT, + ) + .await; + + let authorizations = + env.api.get_user_oauth_authorizations(USER_USER_PAT).await; + assert_eq!(1, authorizations.len()); + assert_eq!(USER_USER_ID_PARSED, authorizations[0].user_id.0 as i64); + }) + .await; +} diff --git a/apps/labrinth/tests/organizations.rs b/apps/labrinth/tests/organizations.rs new file mode 100644 index 000000000..1a570b358 --- /dev/null +++ b/apps/labrinth/tests/organizations.rs @@ -0,0 +1,1323 @@ +use crate::common::{ + api_common::{ApiProject, ApiTeams}, + database::{ + generate_random_name, ADMIN_USER_PAT, ENEMY_USER_ID_PARSED, + ENEMY_USER_PAT, FRIEND_USER_ID_PARSED, MOD_USER_ID, MOD_USER_PAT, + USER_USER_ID, USER_USER_ID_PARSED, + }, + dummy_data::{ + DummyImage, DummyOrganizationZeta, DummyProjectAlpha, DummyProjectBeta, + }, +}; +use actix_http::StatusCode; +use common::{ + api_v3::ApiV3, + database::{FRIEND_USER_ID, FRIEND_USER_PAT, USER_USER_PAT}, + environment::{ + with_test_environment, with_test_environment_all, TestEnvironment, + }, + permissions::{PermissionsTest, PermissionsTestContext}, +}; +use labrinth::models::{ + teams::{OrganizationPermissions, ProjectPermissions}, + users::UserId, +}; +use serde_json::json; + +mod common; + +#[actix_rt::test] +async fn create_organization() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + let zeta_organization_slug = + &test_env.dummy.organization_zeta.organization_id; + + // Failed creations title: + // - too short title + // - too long title + for title in ["a", &"a".repeat(100)] { + let resp = api + .create_organization( + title, + "theta", + "theta_description", + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + } + + // Failed creations slug: + // - slug collision with zeta + // - too short slug + // - too long slug + // - not url safe slug + for slug in [ + zeta_organization_slug, + "a", + &"a".repeat(100), + "not url safe%&^!#$##!@#$%^&*()", + ] { + let resp = api + .create_organization( + "Theta Org", + slug, + "theta_description", + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + } + + // Failed creations description: + // - too short desc + // - too long desc + for description in ["a", &"a".repeat(300)] { + let resp = api + .create_organization( + "Theta Org", + "theta", + description, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + } + + // Create 'theta' organization + let resp = api + .create_organization( + "Theta Org", + "theta", + "not url safe%&^!#$##!@#$%^&", + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::OK); + + // Get organization using slug + let theta = api + .get_organization_deserialized("theta", USER_USER_PAT) + .await; + assert_eq!(theta.name, "Theta Org"); + assert_eq!(theta.slug, "theta"); + assert_eq!(theta.description, "not url safe%&^!#$##!@#$%^&"); + assert_status!(&resp, StatusCode::OK); + + // Get created team + let members = api + .get_organization_members_deserialized("theta", USER_USER_PAT) + .await; + + // Should only be one member, which is USER_USER_ID, and is the owner with full permissions + assert_eq!(members[0].user.id.to_string(), USER_USER_ID); + assert_eq!( + members[0].organization_permissions, + Some(OrganizationPermissions::all()) + ); + assert_eq!(members[0].role, "Member"); + assert!(members[0].is_owner); + }, + ) + .await; +} + +#[actix_rt::test] +async fn get_project_organization() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + let zeta_organization_id = + &test_env.dummy.organization_zeta.organization_id; + let alpha_project_id = &test_env.dummy.project_alpha.project_id; + + // ADd alpha project to zeta organization + let resp = api + .organization_add_project( + zeta_organization_id, + alpha_project_id, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::OK); + + // Get project organization + let zeta = api + .get_project_organization_deserialized( + alpha_project_id, + USER_USER_PAT, + ) + .await; + assert_eq!(zeta.id.to_string(), zeta_organization_id.to_string()); + }, + ) + .await; +} + +#[actix_rt::test] +async fn patch_organization() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + + let zeta_organization_id = + &test_env.dummy.organization_zeta.organization_id; + + // Create 'theta' organization + let resp = api + .create_organization( + "Theta Org", + "theta", + "theta_description", + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::OK); + + // Failed patch to theta title: + // - too short title + // - too long title + for title in ["a", &"a".repeat(100)] { + let resp = api + .edit_organization( + "theta", + json!({ + "name": title, + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + } + + // Failed patch to zeta slug: + // - slug collision with theta + // - too short slug + // - too long slug + // - not url safe slug + for title in [ + "theta", + "a", + &"a".repeat(100), + "not url safe%&^!#$##!@#$%^&*()", + ] { + let resp = api + .edit_organization( + zeta_organization_id, + json!({ + "slug": title, + "description": "theta_description" + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + } + + // Failed patch to zeta description: + // - too short description + // - too long description + for description in ["a", &"a".repeat(300)] { + let resp = api + .edit_organization( + zeta_organization_id, + json!({ + "description": description + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + } + + // Successful patch to many fields + let resp = api + .edit_organization( + zeta_organization_id, + json!({ + "name": "new_title", + "slug": "new_slug", + "description": "not url safe%&^!#$##!@#$%^&" // not-URL-safe description should still work + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Get project using new slug + let new_title = api + .get_organization_deserialized("new_slug", USER_USER_PAT) + .await; + assert_eq!(new_title.name, "new_title"); + assert_eq!(new_title.slug, "new_slug"); + assert_eq!(new_title.description, "not url safe%&^!#$##!@#$%^&"); + }, + ) + .await; +} + +// add/remove icon +#[actix_rt::test] +async fn add_remove_icon() { + with_test_environment( + Some(10), + |test_env: TestEnvironment| async move { + let api = &test_env.api; + let zeta_organization_id = + &test_env.dummy.organization_zeta.organization_id; + + // Get project + let resp = test_env + .api + .get_organization_deserialized( + zeta_organization_id, + USER_USER_PAT, + ) + .await; + assert_eq!(resp.icon_url, None); + + // Icon edit + // Uses alpha organization to delete this icon + let resp = api + .edit_organization_icon( + zeta_organization_id, + Some(DummyImage::SmallIcon.get_icon_data()), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Get project + let zeta_org = api + .get_organization_deserialized( + zeta_organization_id, + USER_USER_PAT, + ) + .await; + assert!(zeta_org.icon_url.is_some()); + + // Icon delete + // Uses alpha organization to delete added icon + let resp = api + .edit_organization_icon( + zeta_organization_id, + None, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Get project + let zeta_org = api + .get_organization_deserialized( + zeta_organization_id, + USER_USER_PAT, + ) + .await; + assert!(zeta_org.icon_url.is_none()); + }, + ) + .await; +} + +// delete org +#[actix_rt::test] +async fn delete_org() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + let zeta_organization_id = + &test_env.dummy.organization_zeta.organization_id; + + let resp = api + .delete_organization(zeta_organization_id, USER_USER_PAT) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Get organization, which should no longer exist + let resp = api + .get_organization(zeta_organization_id, USER_USER_PAT) + .await; + assert_status!(&resp, StatusCode::NOT_FOUND); + }, + ) + .await; +} + +// add/remove organization projects +#[actix_rt::test] +async fn add_remove_organization_projects() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let alpha_project_id: &str = + &test_env.dummy.project_alpha.project_id; + let alpha_project_slug: &str = + &test_env.dummy.project_alpha.project_slug; + let zeta_organization_id: &str = + &test_env.dummy.organization_zeta.organization_id; + + // user's page should show alpha project + // It may contain more than one project, depending on dummy data, but should contain the alpha project + let projects = test_env + .api + .get_user_projects_deserialized_common( + USER_USER_ID, + USER_USER_PAT, + ) + .await; + assert!(projects + .iter() + .any(|p| p.id.to_string() == alpha_project_id)); + + // Add/remove project to organization, first by ID, then by slug + for alpha in [alpha_project_id, alpha_project_slug] { + let resp = test_env + .api + .organization_add_project( + zeta_organization_id, + alpha, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::OK); + + // Get organization projects + let projects = test_env + .api + .get_organization_projects_deserialized( + zeta_organization_id, + USER_USER_PAT, + ) + .await; + assert_eq!(projects[0].id.to_string(), alpha_project_id); + assert_eq!( + projects[0].slug, + Some(alpha_project_slug.to_string()) + ); + + // Currently, intended behaviour is that user's page should NOT show organization projects. + // It may contain other projects, depending on dummy data, but should not contain the alpha project + let projects = test_env + .api + .get_user_projects_deserialized_common( + USER_USER_ID, + USER_USER_PAT, + ) + .await; + assert!(!projects + .iter() + .any(|p| p.id.to_string() == alpha_project_id)); + + // Remove project from organization + let resp = test_env + .api + .organization_remove_project( + zeta_organization_id, + alpha, + UserId(USER_USER_ID_PARSED as u64), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::OK); + + // Get user's projects as user - should be 1, the alpha project, + // as we gave back ownership to the user when we removed it from the organization + // So user's page should show the alpha project (and possibly others) + let projects = test_env + .api + .get_user_projects_deserialized_common( + USER_USER_ID, + USER_USER_PAT, + ) + .await; + assert!(projects + .iter() + .any(|p| p.id.to_string() == alpha_project_id)); + + // Get organization projects + let projects = test_env + .api + .get_organization_projects_deserialized( + zeta_organization_id, + USER_USER_PAT, + ) + .await; + assert!(projects.is_empty()); + } + }, + ) + .await; +} + +// Like above, but specifically regarding ownership transferring +#[actix_rt::test] +async fn add_remove_organization_project_ownership_to_user() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let DummyProjectAlpha { + project_id: alpha_project_id, + team_id: alpha_team_id, + .. + } = &test_env.dummy.project_alpha; + let DummyProjectBeta { + project_id: beta_project_id, + team_id: beta_team_id, + .. + } = &test_env.dummy.project_beta; + let DummyOrganizationZeta { + organization_id: zeta_organization_id, + team_id: zeta_team_id, + .. + } = &test_env.dummy.organization_zeta; + + // Add friend to alpha, beta, and zeta + for (team, organization) in [ + (alpha_team_id, false), + (beta_team_id, false), + (zeta_team_id, true), + ] { + let org_permissions = if organization { + Some(OrganizationPermissions::all()) + } else { + None + }; + let resp = test_env + .api + .add_user_to_team( + team, + FRIEND_USER_ID, + Some(ProjectPermissions::all()), + org_permissions, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Accept invites + let resp = test_env.api.join_team(team, FRIEND_USER_PAT).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + } + + // For each team, confirm there are two members, but only one owner of the project, and it is USER_USER_ID + for team in [alpha_team_id, beta_team_id, zeta_team_id] { + let members = test_env + .api + .get_team_members_deserialized(team, USER_USER_PAT) + .await; + assert_eq!(members.len(), 2); + let user_member = + members.iter().filter(|m| m.is_owner).collect::>(); + assert_eq!(user_member.len(), 1); + assert_eq!(user_member[0].user.id.to_string(), USER_USER_ID); + } + + // Transfer ownership of beta project to FRIEND + let resp = test_env + .api + .transfer_team_ownership( + beta_team_id, + FRIEND_USER_ID, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Confirm there are still two users, but now FRIEND_USER_ID is the owner + let members = test_env + .api + .get_team_members_deserialized(beta_team_id, USER_USER_PAT) + .await; + assert_eq!(members.len(), 2); + let user_member = + members.iter().filter(|m| m.is_owner).collect::>(); + assert_eq!(user_member.len(), 1); + assert_eq!(user_member[0].user.id.to_string(), FRIEND_USER_ID); + + // Add alpha, beta to zeta organization + for (project_id, pat) in [ + (alpha_project_id, USER_USER_PAT), + (beta_project_id, FRIEND_USER_PAT), + ] { + let resp = test_env + .api + .organization_add_project( + zeta_organization_id, + project_id, + pat, + ) + .await; + assert_status!(&resp, StatusCode::OK); + + // Get and confirm it has been added + let project = test_env + .api + .get_project_deserialized(project_id, pat) + .await; + assert_eq!( + &project.organization.unwrap().to_string(), + zeta_organization_id + ); + } + + // Alpha project should have: + // - 1 member, FRIEND_USER_ID + // -> User was removed entirely as a team_member as it is now the owner of the organization + // - No owner. + // -> For alpha, user was removed as owner when it was added to the organization + // -> Friend was never an owner of the alpha project + let members = test_env + .api + .get_team_members_deserialized(alpha_team_id, USER_USER_PAT) + .await; + assert_eq!(members.len(), 1); + assert_eq!(members[0].user.id.to_string(), FRIEND_USER_ID); + let user_member = + members.iter().filter(|m| m.is_owner).collect::>(); + assert_eq!(user_member.len(), 0); + + // Beta project should have: + // - No members + // -> User was removed entirely as a team_member as it is now the owner of the organization + // -> Friend was made owner of the beta project, but was removed as a member when it was added to the organization + // If you are owner of a projeect, you are removed from the team when it is added to an organization, + // so that your former permissions are not overriding the organization permissions by default. + let members = test_env + .api + .get_team_members_deserialized(beta_team_id, USER_USER_PAT) + .await; + assert!(members.is_empty()); + + // Transfer ownership of zeta organization to FRIEND + let resp = test_env + .api + .transfer_team_ownership( + zeta_team_id, + FRIEND_USER_ID, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Confirm there are no members of the alpha project OR the beta project + // - Friend was removed as a member of these projects when ownership was transferred to them + for team_id in [alpha_team_id, beta_team_id] { + let members = test_env + .api + .get_team_members_deserialized(team_id, USER_USER_PAT) + .await; + assert!(members.is_empty()); + } + + // As user, cannot add friend to alpha project, as they are the org owner + let resp = test_env + .api + .add_user_to_team( + alpha_team_id, + FRIEND_USER_ID, + None, + None, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // As friend, can add user to alpha project, as they are not the org owner + let resp = test_env + .api + .add_user_to_team( + alpha_team_id, + USER_USER_ID, + None, + None, + FRIEND_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // At this point, friend owns the org + // Alpha member has user as a member, but not as an owner + // Neither project has an owner, as they are owned by the org + + // Remove project from organization with a user that is not an organization member + // This should fail as we cannot give a project to a user that is not a member of the organization + let resp = test_env + .api + .organization_remove_project( + zeta_organization_id, + alpha_project_id, + UserId(ENEMY_USER_ID_PARSED as u64), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // Set user's permissions within the project that it is a member of to none (for a later test) + let resp = test_env + .api + .edit_team_member( + alpha_team_id, + USER_USER_ID, + json!({ + "project_permissions": 0, + }), + FRIEND_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Remove project from organization with a user that is an organization member, and a project member + // This should succeed + let resp = test_env + .api + .organization_remove_project( + zeta_organization_id, + alpha_project_id, + UserId(USER_USER_ID_PARSED as u64), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::OK); + + // Remove project from organization with a user that is an organization member, but not a project member + // This should succeed + let resp = test_env + .api + .organization_remove_project( + zeta_organization_id, + beta_project_id, + UserId(USER_USER_ID_PARSED as u64), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::OK); + + // For each of alpha and beta, confirm: + // - There is one member of each project, the owner, USER_USER_ID + // - In addition to being the owner, they have full permissions (even though they were set to none earlier) + // - They no longer have an attached organization + for team_id in [alpha_team_id, beta_team_id] { + let members = test_env + .api + .get_team_members_deserialized(team_id, USER_USER_PAT) + .await; + assert_eq!(members.len(), 1); + let user_member = + members.iter().filter(|m| m.is_owner).collect::>(); + assert_eq!(user_member.len(), 1); + assert_eq!(user_member[0].user.id.to_string(), USER_USER_ID); + assert_eq!( + user_member[0].permissions.unwrap(), + ProjectPermissions::all() + ); + } + + for project_id in [alpha_project_id, beta_project_id] { + let project = test_env + .api + .get_project_deserialized(project_id, USER_USER_PAT) + .await; + assert!(project.organization.is_none()); + } + }, + ) + .await; +} + +#[actix_rt::test] +async fn delete_organization_means_all_projects_to_org_owner() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let DummyProjectAlpha { + project_id: alpha_project_id, + team_id: alpha_team_id, + .. + } = &test_env.dummy.project_alpha; + let DummyProjectBeta { + project_id: beta_project_id, + team_id: beta_team_id, + .. + } = &test_env.dummy.project_beta; + let DummyOrganizationZeta { + organization_id: zeta_organization_id, + team_id: zeta_team_id, + .. + } = &test_env.dummy.organization_zeta; + + // Create random project from enemy, ensure it wont get affected + let (enemy_project, _) = test_env + .api + .add_public_project("enemy_project", None, None, ENEMY_USER_PAT) + .await; + + // Add FRIEND + let resp = test_env + .api + .add_user_to_team( + zeta_team_id, + FRIEND_USER_ID, + None, + None, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Accept invite + let resp = + test_env.api.join_team(zeta_team_id, FRIEND_USER_PAT).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Confirm there is only one owner of the project, and it is USER_USER_ID + let members = test_env + .api + .get_team_members_deserialized(alpha_team_id, USER_USER_PAT) + .await; + let user_member = + members.iter().filter(|m| m.is_owner).collect::>(); + assert_eq!(user_member.len(), 1); + assert_eq!(user_member[0].user.id.to_string(), USER_USER_ID); + + // Add alpha to zeta organization + let resp = test_env + .api + .organization_add_project( + zeta_organization_id, + alpha_project_id, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::OK); + + // Add beta to zeta organization + test_env + .api + .organization_add_project( + zeta_organization_id, + beta_project_id, + USER_USER_PAT, + ) + .await; + + // Add friend as a member of the beta project + let resp = test_env + .api + .add_user_to_team( + beta_team_id, + FRIEND_USER_ID, + None, + None, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Try to accept invite + // This returns a failure, because since beta and FRIEND are in the organizations, + // they can be added to the project without an invite + let resp = + test_env.api.join_team(beta_team_id, FRIEND_USER_PAT).await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // Confirm there is NO owner of the project, as it is owned by the organization + let members = test_env + .api + .get_team_members_deserialized(alpha_team_id, USER_USER_PAT) + .await; + let user_member = + members.iter().filter(|m| m.is_owner).collect::>(); + assert_eq!(user_member.len(), 0); + + // Transfer ownership of zeta organization to FRIEND + let resp = test_env + .api + .transfer_team_ownership( + zeta_team_id, + FRIEND_USER_ID, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Confirm there is NO owner of the project, as it is owned by the organization + let members = test_env + .api + .get_team_members_deserialized(alpha_team_id, USER_USER_PAT) + .await; + let user_member = + members.iter().filter(|m| m.is_owner).collect::>(); + assert_eq!(user_member.len(), 0); + + // Delete organization + let resp = test_env + .api + .delete_organization(zeta_organization_id, FRIEND_USER_PAT) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Confirm there is only one owner of the alpha project, and it is now FRIEND_USER_ID + let members = test_env + .api + .get_team_members_deserialized(alpha_team_id, USER_USER_PAT) + .await; + let user_member = + members.iter().filter(|m| m.is_owner).collect::>(); + assert_eq!(user_member.len(), 1); + assert_eq!(user_member[0].user.id.to_string(), FRIEND_USER_ID); + + // Confirm there is only one owner of the beta project, and it is now FRIEND_USER_ID + let members = test_env + .api + .get_team_members_deserialized(beta_team_id, USER_USER_PAT) + .await; + let user_member = + members.iter().filter(|m| m.is_owner).collect::>(); + assert_eq!(user_member.len(), 1); + assert_eq!(user_member[0].user.id.to_string(), FRIEND_USER_ID); + + // Confirm there is only one member of the enemy project, and it is STILL ENEMY_USER_ID + let enemy_project = test_env + .api + .get_project_deserialized( + &enemy_project.id.to_string(), + ENEMY_USER_PAT, + ) + .await; + let members = test_env + .api + .get_team_members_deserialized( + &enemy_project.team_id.to_string(), + ENEMY_USER_PAT, + ) + .await; + let user_member = + members.iter().filter(|m| m.is_owner).collect::>(); + assert_eq!(user_member.len(), 1); + assert_eq!( + user_member[0].user.id.to_string(), + ENEMY_USER_ID_PARSED.to_string() + ); + }, + ) + .await; +} + +#[actix_rt::test] +async fn permissions_patch_organization() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + // For each permission covered by EDIT_DETAILS, ensure the permission is required + let api = &test_env.api; + let edit_details = OrganizationPermissions::EDIT_DETAILS; + let test_pairs = [ + ("name", json!("")), // generated in the test to not collide slugs + ("description", json!("New description")), + ]; + + for (key, value) in test_pairs { + let req_gen = |ctx: PermissionsTestContext| { + let value = value.clone(); + async move { + api.edit_organization( + &ctx.organization_id.unwrap(), + json!({ + key: if key == "name" { + json!(generate_random_name("randomslug")) + } else { + value.clone() + }, + }), + ctx.test_pat.as_deref(), + ) + .await + } + }; + PermissionsTest::new(&test_env) + .simple_organization_permissions_test(edit_details, req_gen) + .await + .unwrap(); + } + }, + ) + .await; +} + +// Not covered by PATCH /organization +#[actix_rt::test] +async fn permissions_edit_details() { + with_test_environment( + Some(12), + |test_env: TestEnvironment| async move { + let zeta_organization_id = + &test_env.dummy.organization_zeta.organization_id; + let zeta_team_id = &test_env.dummy.organization_zeta.team_id; + + let api = &test_env.api; + let edit_details = OrganizationPermissions::EDIT_DETAILS; + + // Icon edit + // Uses alpha organization to delete this icon + let req_gen = |ctx: PermissionsTestContext| async move { + api.edit_organization_icon( + &ctx.organization_id.unwrap(), + Some(DummyImage::SmallIcon.get_icon_data()), + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .with_existing_organization(zeta_organization_id, zeta_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_organization_permissions_test(edit_details, req_gen) + .await + .unwrap(); + + // Icon delete + // Uses alpha project to delete added icon + let req_gen = |ctx: PermissionsTestContext| async move { + api.edit_organization_icon( + &ctx.organization_id.unwrap(), + None, + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .with_existing_organization(zeta_organization_id, zeta_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_organization_permissions_test(edit_details, req_gen) + .await + .unwrap(); + }, + ) + .await; +} + +#[actix_rt::test] +async fn permissions_manage_invites() { + // Add member, remove member, edit member + with_test_environment_all(None, |test_env| async move { + let api = &test_env.api; + + let zeta_organization_id = + &test_env.dummy.organization_zeta.organization_id; + let zeta_team_id = &test_env.dummy.organization_zeta.team_id; + + let manage_invites = OrganizationPermissions::MANAGE_INVITES; + + // Add member + let req_gen = |ctx: PermissionsTestContext| async move { + api.add_user_to_team( + &ctx.team_id.unwrap(), + MOD_USER_ID, + Some(ProjectPermissions::empty()), + Some(OrganizationPermissions::empty()), + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .with_existing_organization(zeta_organization_id, zeta_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_organization_permissions_test(manage_invites, req_gen) + .await + .unwrap(); + + // Edit member + let edit_member = OrganizationPermissions::EDIT_MEMBER; + let req_gen = |ctx: PermissionsTestContext| async move { + api.edit_team_member( + &ctx.team_id.unwrap(), + MOD_USER_ID, + json!({ + "organization_permissions": 0, + }), + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .with_existing_organization(zeta_organization_id, zeta_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_organization_permissions_test(edit_member, req_gen) + .await + .unwrap(); + + // remove member + // requires manage_invites if they have not yet accepted the invite + let req_gen = |ctx: PermissionsTestContext| async move { + api.remove_from_team( + &ctx.team_id.unwrap(), + MOD_USER_ID, + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .with_existing_organization(zeta_organization_id, zeta_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_organization_permissions_test(manage_invites, req_gen) + .await + .unwrap(); + + // re-add member for testing + let resp = api + .add_user_to_team( + zeta_team_id, + MOD_USER_ID, + None, + None, + ADMIN_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + let resp = api.join_team(zeta_team_id, MOD_USER_PAT).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // remove existing member (requires remove_member) + let remove_member = OrganizationPermissions::REMOVE_MEMBER; + let req_gen = |ctx: PermissionsTestContext| async move { + api.remove_from_team( + &ctx.team_id.unwrap(), + MOD_USER_ID, + ctx.test_pat.as_deref(), + ) + .await + }; + + PermissionsTest::new(&test_env) + .with_existing_organization(zeta_organization_id, zeta_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_organization_permissions_test(remove_member, req_gen) + .await + .unwrap(); + }) + .await; +} + +#[actix_rt::test] +async fn permissions_add_remove_project() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + + let alpha_project_id = &test_env.dummy.project_alpha.project_id; + let alpha_team_id = &test_env.dummy.project_alpha.team_id; + let zeta_organization_id = + &test_env.dummy.organization_zeta.organization_id; + let zeta_team_id = &test_env.dummy.organization_zeta.team_id; + + let add_project = OrganizationPermissions::ADD_PROJECT; + + // First, we add FRIEND_USER_ID to the alpha project and transfer ownership to them + // This is because the ownership of a project is needed to add it to an organization + let resp = api + .add_user_to_team( + alpha_team_id, + FRIEND_USER_ID, + None, + None, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + let resp = api.join_team(alpha_team_id, FRIEND_USER_PAT).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + let resp = api + .transfer_team_ownership( + alpha_team_id, + FRIEND_USER_ID, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Now, FRIEND_USER_ID owns the alpha project + // Add alpha project to zeta organization + let req_gen = |ctx: PermissionsTestContext| async move { + api.organization_add_project( + &ctx.organization_id.unwrap(), + alpha_project_id, + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .with_existing_organization(zeta_organization_id, zeta_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_organization_permissions_test(add_project, req_gen) + .await + .unwrap(); + + // Remove alpha project from zeta organization + let remove_project = OrganizationPermissions::REMOVE_PROJECT; + let req_gen = |ctx: PermissionsTestContext| async move { + api.organization_remove_project( + &ctx.organization_id.unwrap(), + alpha_project_id, + UserId(FRIEND_USER_ID_PARSED as u64), + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .with_existing_organization(zeta_organization_id, zeta_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_organization_permissions_test(remove_project, req_gen) + .await + .unwrap(); + }, + ) + .await; +} + +#[actix_rt::test] +async fn permissions_delete_organization() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + let delete_organization = + OrganizationPermissions::DELETE_ORGANIZATION; + + // Now, FRIEND_USER_ID owns the alpha project + // Add alpha project to zeta organization + let req_gen = |ctx: PermissionsTestContext| async move { + api.delete_organization( + &ctx.organization_id.unwrap(), + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .simple_organization_permissions_test( + delete_organization, + req_gen, + ) + .await + .unwrap(); + }, + ) + .await; +} + +#[actix_rt::test] +async fn permissions_add_default_project_permissions() { + with_test_environment_all(None, |test_env| async move { + let zeta_organization_id = + &test_env.dummy.organization_zeta.organization_id; + let zeta_team_id = &test_env.dummy.organization_zeta.team_id; + + let api = &test_env.api; + + // Add member + let add_member_default_permissions = + OrganizationPermissions::MANAGE_INVITES + | OrganizationPermissions::EDIT_MEMBER_DEFAULT_PERMISSIONS; + + // Failure test should include MANAGE_INVITES, as it is required to add + // default permissions on an invited user, but should still fail without EDIT_MEMBER_DEFAULT_PERMISSIONS + let failure_with_add_member = (OrganizationPermissions::all() + ^ add_member_default_permissions) + | OrganizationPermissions::MANAGE_INVITES; + + let req_gen = |ctx: PermissionsTestContext| async move { + api.add_user_to_team( + &ctx.team_id.unwrap(), + MOD_USER_ID, + Some( + ProjectPermissions::UPLOAD_VERSION + | ProjectPermissions::DELETE_VERSION, + ), + Some(OrganizationPermissions::empty()), + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .with_existing_organization(zeta_organization_id, zeta_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .with_failure_permissions(None, Some(failure_with_add_member)) + .simple_organization_permissions_test( + add_member_default_permissions, + req_gen, + ) + .await + .unwrap(); + + // Now that member is added, modify default permissions + let modify_member_default_permission = + OrganizationPermissions::EDIT_MEMBER + | OrganizationPermissions::EDIT_MEMBER_DEFAULT_PERMISSIONS; + + // Failure test should include MANAGE_INVITES, as it is required to add + // default permissions on an invited user, but should still fail without EDIT_MEMBER_DEFAULT_PERMISSIONS + let failure_with_modify_member = (OrganizationPermissions::all() + ^ add_member_default_permissions) + | OrganizationPermissions::EDIT_MEMBER; + + let req_gen = |ctx: PermissionsTestContext| async move { + api.edit_team_member( + &ctx.team_id.unwrap(), + MOD_USER_ID, + json!({ + "permissions": ProjectPermissions::EDIT_DETAILS.bits(), + }), + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .with_existing_organization(zeta_organization_id, zeta_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .with_failure_permissions(None, Some(failure_with_modify_member)) + .simple_organization_permissions_test( + modify_member_default_permission, + req_gen, + ) + .await + .unwrap(); + }) + .await; +} + +#[actix_rt::test] +async fn permissions_organization_permissions_consistency_test() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + // Ensuring that permission are as we expect them to be + // Full organization permissions test + let success_permissions = OrganizationPermissions::EDIT_DETAILS; + let req_gen = |ctx: PermissionsTestContext| async move { + api.edit_organization( + &ctx.organization_id.unwrap(), + json!({ + "description": "Example description - changed.", + }), + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .full_organization_permissions_tests( + success_permissions, + req_gen, + ) + .await + .unwrap(); + }, + ) + .await; +} diff --git a/apps/labrinth/tests/pats.rs b/apps/labrinth/tests/pats.rs new file mode 100644 index 000000000..07b130f9e --- /dev/null +++ b/apps/labrinth/tests/pats.rs @@ -0,0 +1,294 @@ +use actix_http::StatusCode; +use actix_web::test; +use chrono::{Duration, Utc}; +use common::{database::*, environment::with_test_environment_all}; + +use labrinth::models::pats::Scopes; +use serde_json::json; + +use crate::common::api_common::AppendsOptionalPat; + +mod common; + +// Full pat test: +// - create a PAT and ensure it can be used for the scope +// - ensure access token is not returned for any PAT in GET +// - ensure PAT can be patched to change scopes +// - ensure PAT can be patched to change expiry +// - ensure expired PATs cannot be used +// - ensure PATs can be deleted +#[actix_rt::test] +pub async fn pat_full_test() { + with_test_environment_all(None, |test_env| async move { + // Create a PAT for a full test + let req = test::TestRequest::post() + .uri("/_internal/pat") + .append_pat(USER_USER_PAT) + .set_json(json!({ + "scopes": Scopes::COLLECTION_CREATE, // Collection create as an easily tested example + "name": "test_pat_scopes Test", + "expires": Utc::now() + Duration::days(1), + })) + .to_request(); + let resp = test_env.call(req).await; + assert_status!(&resp, StatusCode::OK); + let success: serde_json::Value = test::read_body_json(resp).await; + let id = success["id"].as_str().unwrap(); + + // Has access token and correct scopes + assert!(success["access_token"].as_str().is_some()); + assert_eq!( + success["scopes"].as_u64().unwrap(), + Scopes::COLLECTION_CREATE.bits() + ); + let access_token = success["access_token"].as_str().unwrap(); + + // Get PAT again + let req = test::TestRequest::get() + .append_pat(USER_USER_PAT) + .uri("/_internal/pat") + .to_request(); + let resp = test_env.call(req).await; + assert_status!(&resp, StatusCode::OK); + let success: serde_json::Value = test::read_body_json(resp).await; + + // Ensure access token is NOT returned for any PATs + for pat in success.as_array().unwrap() { + assert!(pat["access_token"].as_str().is_none()); + } + + // Create mock test for using PAT + let mock_pat_test = |token: &str| { + let token = token.to_string(); + async { + // This uses a route directly instead of an api call because it doesn't relaly matter and we + // want it to succeed no matter what. + // This is an arbitrary request. + let req = test::TestRequest::post() + .uri("/v3/collection") + .append_header(("Authorization", token)) + .set_json(json!({ + "name": "Test Collection 1", + "description": "Test Collection Description" + })) + .to_request(); + let resp = test_env.call(req).await; + resp.status().as_u16() + } + }; + + assert_eq!(mock_pat_test(access_token).await, 200); + + // Change scopes and test again + let req = test::TestRequest::patch() + .uri(&format!("/_internal/pat/{}", id)) + .append_pat(USER_USER_PAT) + .set_json(json!({ + "scopes": 0, + })) + .to_request(); + let resp = test_env.call(req).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + assert_eq!(mock_pat_test(access_token).await, 401); // No longer works + + // Change scopes back, and set expiry to the past, and test again + let req = test::TestRequest::patch() + .uri(&format!("/_internal/pat/{}", id)) + .append_pat(USER_USER_PAT) + .set_json(json!({ + "scopes": Scopes::COLLECTION_CREATE, + "expires": Utc::now() + Duration::seconds(1), // expires in 1 second + })) + .to_request(); + let resp = test_env.call(req).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Wait 1 second before testing again for expiry + tokio::time::sleep(Duration::seconds(1).to_std().unwrap()).await; + assert_eq!(mock_pat_test(access_token).await, 401); // No longer works + + // Change everything back to normal and test again + let req = test::TestRequest::patch() + .uri(&format!("/_internal/pat/{}", id)) + .append_pat(USER_USER_PAT) + .set_json(json!({ + "expires": Utc::now() + Duration::days(1), // no longer expired! + })) + .to_request(); + + println!("PAT ID FOR TEST: {}", id); + let resp = test_env.call(req).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + assert_eq!(mock_pat_test(access_token).await, 200); // Works again + + // Patching to a bad expiry should fail + let req = test::TestRequest::patch() + .uri(&format!("/_internal/pat/{}", id)) + .append_pat(USER_USER_PAT) + .set_json(json!({ + "expires": Utc::now() - Duration::days(1), // Past + })) + .to_request(); + let resp = test_env.call(req).await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // Similar to above with PAT creation, patching to a bad scope should fail + for i in 0..64 { + let scope = Scopes::from_bits_truncate(1 << i); + if !Scopes::all().contains(scope) { + continue; + } + + let req = test::TestRequest::patch() + .uri(&format!("/_internal/pat/{}", id)) + .append_pat(USER_USER_PAT) + .set_json(json!({ + "scopes": scope.bits(), + })) + .to_request(); + let resp = test_env.call(req).await; + assert_eq!( + resp.status().as_u16(), + if scope.is_restricted() { 400 } else { 204 } + ); + } + + // Delete PAT + let req = test::TestRequest::delete() + .append_pat(USER_USER_PAT) + .uri(&format!("/_internal/pat/{}", id)) + .to_request(); + let resp = test_env.call(req).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + }) + .await; +} + +// Test illegal PAT setting, both in POST and PATCH +#[actix_rt::test] +pub async fn bad_pats() { + with_test_environment_all(None, |test_env| async move { + // Creating a PAT with no name should fail + let req = test::TestRequest::post() + .uri("/_internal/pat") + .append_pat(USER_USER_PAT) + .set_json(json!({ + "scopes": Scopes::COLLECTION_CREATE, // Collection create as an easily tested example + "expires": Utc::now() + Duration::days(1), + })) + .to_request(); + let resp = test_env.call(req).await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // Name too short or too long should fail + for name in ["n", "this_name_is_too_long".repeat(16).as_str()] { + let req = test::TestRequest::post() + .uri("/_internal/pat") + .append_pat(USER_USER_PAT) + .set_json(json!({ + "name": name, + "scopes": Scopes::COLLECTION_CREATE, // Collection create as an easily tested example + "expires": Utc::now() + Duration::days(1), + })) + .to_request(); + let resp = test_env.call(req).await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + } + + // Creating a PAT with an expiry in the past should fail + let req = test::TestRequest::post() + .uri("/_internal/pat") + .append_pat(USER_USER_PAT) + .set_json(json!({ + "scopes": Scopes::COLLECTION_CREATE, // Collection create as an easily tested example + "name": "test_pat_scopes Test", + "expires": Utc::now() - Duration::days(1), + })) + .to_request(); + let resp = test_env.call(req).await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // Make a PAT with each scope, with the result varying by whether that scope is restricted + for i in 0..64 { + let scope = Scopes::from_bits_truncate(1 << i); + if !Scopes::all().contains(scope) { + continue; + } + let req = test::TestRequest::post() + .uri("/_internal/pat") + .append_pat(USER_USER_PAT) + .set_json(json!({ + "scopes": scope.bits(), + "name": format!("test_pat_scopes Name {}", i), + "expires": Utc::now() + Duration::days(1), + })) + .to_request(); + let resp = test_env.call(req).await; + assert_eq!( + resp.status().as_u16(), + if scope.is_restricted() { 400 } else { 200 } + ); + } + + // Create a 'good' PAT for patching + let req = test::TestRequest::post() + .uri("/_internal/pat") + .append_pat(USER_USER_PAT) + .set_json(json!({ + "scopes": Scopes::COLLECTION_CREATE, + "name": "test_pat_scopes Test", + "expires": Utc::now() + Duration::days(1), + })) + .to_request(); + let resp = test_env.call(req).await; + assert_status!(&resp, StatusCode::OK); + let success: serde_json::Value = test::read_body_json(resp).await; + let id = success["id"].as_str().unwrap(); + + // Patching to a bad name should fail + for name in ["n", "this_name_is_too_long".repeat(16).as_str()] { + let req = test::TestRequest::post() + .uri("/_internal/pat") + .append_pat(USER_USER_PAT) + .set_json(json!({ + "name": name, + })) + .to_request(); + let resp = test_env.call(req).await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + } + + // Patching to a bad expiry should fail + let req = test::TestRequest::patch() + .uri(&format!("/_internal/pat/{}", id)) + .append_pat(USER_USER_PAT) + .set_json(json!({ + "expires": Utc::now() - Duration::days(1), // Past + })) + .to_request(); + let resp = test_env.call(req).await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // Similar to above with PAT creation, patching to a bad scope should fail + for i in 0..64 { + let scope = Scopes::from_bits_truncate(1 << i); + if !Scopes::all().contains(scope) { + continue; + } + + let req = test::TestRequest::patch() + .uri(&format!("/_internal/pat/{}", id)) + .append_pat(USER_USER_PAT) + .set_json(json!({ + "scopes": scope.bits(), + })) + .to_request(); + let resp = test_env.call(req).await; + assert_eq!( + resp.status().as_u16(), + if scope.is_restricted() { 400 } else { 204 } + ); + } + }) + .await; +} diff --git a/apps/labrinth/tests/project.rs b/apps/labrinth/tests/project.rs new file mode 100644 index 000000000..11c63abbc --- /dev/null +++ b/apps/labrinth/tests/project.rs @@ -0,0 +1,1363 @@ +use actix_http::StatusCode; +use actix_web::test; +use common::api_v3::ApiV3; +use common::database::*; +use common::dummy_data::DUMMY_CATEGORIES; + +use common::environment::{ + with_test_environment, with_test_environment_all, TestEnvironment, +}; +use common::permissions::{PermissionsTest, PermissionsTestContext}; +use futures::StreamExt; +use labrinth::database::models::project_item::{ + PROJECTS_NAMESPACE, PROJECTS_SLUGS_NAMESPACE, +}; +use labrinth::models::ids::base62_impl::parse_base62; +use labrinth::models::projects::ProjectId; +use labrinth::models::teams::ProjectPermissions; +use labrinth::util::actix::{MultipartSegment, MultipartSegmentData}; +use serde_json::json; + +use crate::common::api_common::models::CommonProject; +use crate::common::api_common::request_data::ProjectCreationRequestData; +use crate::common::api_common::{ApiProject, ApiTeams, ApiVersion}; +use crate::common::dummy_data::{ + DummyImage, DummyOrganizationZeta, DummyProjectAlpha, DummyProjectBeta, + TestFile, +}; +mod common; + +#[actix_rt::test] +async fn test_get_project() { + // Test setup and dummy data + with_test_environment_all(None, |test_env| async move { + let DummyProjectAlpha { + project_id: alpha_project_id, + project_slug: alpha_project_slug, + version_id: alpha_version_id, + .. + } = &test_env.dummy.project_alpha; + let DummyProjectBeta { + project_id: beta_project_id, + .. + } = &test_env.dummy.project_beta; + + let api = &test_env.api; + + // Perform request on dummy data + let resp = api.get_project(alpha_project_id, USER_USER_PAT).await; + assert_status!(&resp, StatusCode::OK); + let body: serde_json::Value = test::read_body_json(resp).await; + + assert_eq!(body["id"], json!(alpha_project_id)); + assert_eq!(body["slug"], json!(alpha_project_slug)); + let versions = body["versions"].as_array().unwrap(); + assert_eq!(versions[0], json!(alpha_version_id)); + + // Confirm that the request was cached + let mut redis_pool = test_env.db.redis_pool.connect().await.unwrap(); + assert_eq!( + redis_pool + .get(PROJECTS_SLUGS_NAMESPACE, alpha_project_slug) + .await + .unwrap() + .and_then(|x| x.parse::().ok()), + Some(parse_base62(alpha_project_id).unwrap() as i64) + ); + + let cached_project = redis_pool + .get( + PROJECTS_NAMESPACE, + &parse_base62(alpha_project_id).unwrap().to_string(), + ) + .await + .unwrap() + .unwrap(); + let cached_project: serde_json::Value = + serde_json::from_str(&cached_project).unwrap(); + assert_eq!( + cached_project["val"]["inner"]["slug"], + json!(alpha_project_slug) + ); + + // Make the request again, this time it should be cached + let resp = api.get_project(alpha_project_id, USER_USER_PAT).await; + assert_status!(&resp, StatusCode::OK); + + let body: serde_json::Value = test::read_body_json(resp).await; + assert_eq!(body["id"], json!(alpha_project_id)); + assert_eq!(body["slug"], json!(alpha_project_slug)); + + // Request should fail on non-existent project + let resp = api.get_project("nonexistent", USER_USER_PAT).await; + assert_status!(&resp, StatusCode::NOT_FOUND); + + // Similarly, request should fail on non-authorized user, on a yet-to-be-approved or hidden project, with a 404 (hiding the existence of the project) + let resp = api.get_project(beta_project_id, ENEMY_USER_PAT).await; + assert_status!(&resp, StatusCode::NOT_FOUND); + }) + .await; +} + +#[actix_rt::test] +async fn test_add_remove_project() { + // Test setup and dummy data + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + + // Generate test project data. + let mut json_data = api + .get_public_project_creation_data_json( + "demo", + Some(&TestFile::BasicMod), + ) + .await; + + // Basic json + let json_segment = MultipartSegment { + name: "data".to_string(), + filename: None, + content_type: Some("application/json".to_string()), + data: MultipartSegmentData::Text( + serde_json::to_string(&json_data).unwrap(), + ), + }; + + // Basic json, with a different file + json_data["initial_versions"][0]["file_parts"][0] = + json!("basic-mod-different.jar"); + let json_diff_file_segment = MultipartSegment { + data: MultipartSegmentData::Text( + serde_json::to_string(&json_data).unwrap(), + ), + ..json_segment.clone() + }; + + // Basic json, with a different file, and a different slug + json_data["slug"] = json!("new_demo"); + json_data["initial_versions"][0]["file_parts"][0] = + json!("basic-mod-different.jar"); + let json_diff_slug_file_segment = MultipartSegment { + data: MultipartSegmentData::Text( + serde_json::to_string(&json_data).unwrap(), + ), + ..json_segment.clone() + }; + + let basic_mod_file = TestFile::BasicMod; + let basic_mod_different_file = TestFile::BasicModDifferent; + + // Basic file + let file_segment = MultipartSegment { + // 'Basic' + name: basic_mod_file.filename(), + filename: Some(basic_mod_file.filename()), + content_type: basic_mod_file.content_type(), + data: MultipartSegmentData::Binary(basic_mod_file.bytes()), + }; + + // Differently named file, with the SAME content (for hash testing) + let file_diff_name_segment = MultipartSegment { + // 'Different' + name: basic_mod_different_file.filename(), + filename: Some(basic_mod_different_file.filename()), + content_type: basic_mod_different_file.content_type(), + // 'Basic' + data: MultipartSegmentData::Binary(basic_mod_file.bytes()), + }; + + // Differently named file, with different content + let file_diff_name_content_segment = MultipartSegment { + // 'Different' + name: basic_mod_different_file.filename(), + filename: Some(basic_mod_different_file.filename()), + content_type: basic_mod_different_file.content_type(), + data: MultipartSegmentData::Binary( + basic_mod_different_file.bytes(), + ), + }; + + // Add a project- simple, should work. + let resp = api + .create_project( + ProjectCreationRequestData { + slug: "demo".to_string(), + segment_data: vec![ + json_segment.clone(), + file_segment.clone(), + ], + jar: None, // File not needed at this point + }, + USER_USER_PAT, + ) + .await; + + assert_status!(&resp, StatusCode::OK); + + // Get the project we just made, and confirm that it's correct + let project = api + .get_project_deserialized_common("demo", USER_USER_PAT) + .await; + assert!(project.versions.len() == 1); + let uploaded_version_id = project.versions[0]; + + // Checks files to ensure they were uploaded and correctly identify the file + let hash = sha1::Sha1::from(basic_mod_file.bytes()) + .digest() + .to_string(); + let version = api + .get_version_from_hash_deserialized_common( + &hash, + "sha1", + USER_USER_PAT, + ) + .await; + assert_eq!(version.id, uploaded_version_id); + + // Reusing with a different slug and the same file should fail + // Even if that file is named differently + let resp = api + .create_project( + ProjectCreationRequestData { + slug: "demo".to_string(), + segment_data: vec![ + json_diff_slug_file_segment.clone(), + file_diff_name_segment.clone(), + ], + jar: None, // File not needed at this point + }, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // Reusing with the same slug and a different file should fail + let resp = api + .create_project( + ProjectCreationRequestData { + slug: "demo".to_string(), + segment_data: vec![ + json_diff_file_segment.clone(), + file_diff_name_content_segment.clone(), + ], + jar: None, // File not needed at this point + }, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // Different slug, different file should succeed + let resp = api + .create_project( + ProjectCreationRequestData { + slug: "demo".to_string(), + segment_data: vec![ + json_diff_slug_file_segment.clone(), + file_diff_name_content_segment.clone(), + ], + jar: None, // File not needed at this point + }, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::OK); + + // Get + let project = api + .get_project_deserialized_common("demo", USER_USER_PAT) + .await; + let id = project.id.to_string(); + + // Remove the project + let resp = test_env.api.remove_project("demo", USER_USER_PAT).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Confirm that the project is gone from the cache + let mut redis_pool = + test_env.db.redis_pool.connect().await.unwrap(); + assert_eq!( + redis_pool + .get(PROJECTS_SLUGS_NAMESPACE, "demo") + .await + .unwrap() + .and_then(|x| x.parse::().ok()), + None + ); + assert_eq!( + redis_pool + .get(PROJECTS_SLUGS_NAMESPACE, &id) + .await + .unwrap() + .and_then(|x| x.parse::().ok()), + None + ); + + // Old slug no longer works + let resp = api.get_project("demo", USER_USER_PAT).await; + assert_status!(&resp, StatusCode::NOT_FOUND); + }, + ) + .await; +} + +#[actix_rt::test] +pub async fn test_patch_project() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + + let alpha_project_slug = &test_env.dummy.project_alpha.project_slug; + let beta_project_slug = &test_env.dummy.project_beta.project_slug; + + // First, we do some patch requests that should fail. + // Failure because the user is not authorized. + let resp = api + .edit_project( + alpha_project_slug, + json!({ + "name": "Test_Add_Project project - test 1", + }), + ENEMY_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::UNAUTHORIZED); + + // Failure because we are setting URL fields to invalid urls. + for url_type in ["issues", "source", "wiki", "discord"] { + let resp = api + .edit_project( + alpha_project_slug, + json!({ + "link_urls": { + url_type: "not a url", + }, + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + } + + // Failure because these are illegal requested statuses for a normal user. + for req in ["unknown", "processing", "withheld", "scheduled"] { + let resp = api + .edit_project( + alpha_project_slug, + json!({ + "requested_status": req, + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + } + + // Failure because these should not be able to be set by a non-mod + for key in ["moderation_message", "moderation_message_body"] { + let resp = api + .edit_project( + alpha_project_slug, + json!({ + key: "test", + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::UNAUTHORIZED); + + // (should work for a mod, though) + let resp = api + .edit_project( + alpha_project_slug, + json!({ + key: "test", + }), + MOD_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + } + + // Failed patch to alpha slug: + // - slug collision with beta + // - too short slug + // - too long slug + // - not url safe slug + // - not url safe slug + for slug in [ + beta_project_slug, + "a", + &"a".repeat(100), + "not url safe%&^!#$##!@#$%^&*()", + ] { + let resp = api + .edit_project( + alpha_project_slug, + json!({ + "slug": slug, // the other dummy project has this slug + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + } + + // Not allowed to directly set status, as 'beta_project_slug' (the other project) is "processing" and cannot have its status changed like this. + let resp = api + .edit_project( + beta_project_slug, + json!({ + "status": "private" + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::UNAUTHORIZED); + + // Sucessful request to patch many fields. + let resp = api + .edit_project( + alpha_project_slug, + json!({ + "slug": "newslug", + "categories": [DUMMY_CATEGORIES[0]], + "license_id": "MIT", + "link_urls": + { + "patreon": "https://patreon.com", + "issues": "https://github.com", + "discord": "https://discord.gg", + "wiki": "https://wiki.com" + } + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Old slug no longer works + let resp = api.get_project(alpha_project_slug, USER_USER_PAT).await; + assert_status!(&resp, StatusCode::NOT_FOUND); + + // New slug does work + let project = + api.get_project_deserialized("newslug", USER_USER_PAT).await; + + assert_eq!(project.slug.unwrap(), "newslug"); + assert_eq!(project.categories, vec![DUMMY_CATEGORIES[0]]); + assert_eq!(project.license.id, "MIT"); + + let link_urls = project.link_urls; + assert_eq!(link_urls.len(), 4); + assert_eq!(link_urls["patreon"].platform, "patreon"); + assert_eq!(link_urls["patreon"].url, "https://patreon.com"); + assert!(link_urls["patreon"].donation); + assert_eq!(link_urls["issues"].platform, "issues"); + assert_eq!(link_urls["issues"].url, "https://github.com"); + assert!(!link_urls["issues"].donation); + assert_eq!(link_urls["discord"].platform, "discord"); + assert_eq!(link_urls["discord"].url, "https://discord.gg"); + assert!(!link_urls["discord"].donation); + assert_eq!(link_urls["wiki"].platform, "wiki"); + assert_eq!(link_urls["wiki"].url, "https://wiki.com"); + assert!(!link_urls["wiki"].donation); + + // Unset the set link_urls + let resp = api + .edit_project( + "newslug", + json!({ + "link_urls": + { + "issues": null, + } + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + let project = + api.get_project_deserialized("newslug", USER_USER_PAT).await; + assert_eq!(project.link_urls.len(), 3); + assert!(!project.link_urls.contains_key("issues")); + }, + ) + .await; +} + +#[actix_rt::test] +pub async fn test_patch_v3() { + // Hits V3-specific patchable fields + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + + let alpha_project_slug = &test_env.dummy.project_alpha.project_slug; + + // Sucessful request to patch many fields. + let resp = api + .edit_project( + alpha_project_slug, + json!({ + "name": "New successful title", + "summary": "New successful summary", + "description": "New successful description", + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let project = api + .get_project_deserialized(alpha_project_slug, USER_USER_PAT) + .await; + + assert_eq!(project.name, "New successful title"); + assert_eq!(project.summary, "New successful summary"); + assert_eq!(project.description, "New successful description"); + }, + ) + .await; +} + +#[actix_rt::test] +pub async fn test_bulk_edit_categories() { + with_test_environment_all(None, |test_env| async move { + let api = &test_env.api; + let alpha_project_id: &str = &test_env.dummy.project_alpha.project_id; + let beta_project_id: &str = &test_env.dummy.project_beta.project_id; + + let resp = api + .edit_project_bulk( + &[alpha_project_id, beta_project_id], + json!({ + "categories": [DUMMY_CATEGORIES[0], DUMMY_CATEGORIES[3]], + "add_categories": [DUMMY_CATEGORIES[1], DUMMY_CATEGORIES[2]], + "remove_categories": [DUMMY_CATEGORIES[3]], + "additional_categories": [DUMMY_CATEGORIES[4], DUMMY_CATEGORIES[6]], + "add_additional_categories": [DUMMY_CATEGORIES[5]], + "remove_additional_categories": [DUMMY_CATEGORIES[6]], + }), + ADMIN_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let alpha_body = api + .get_project_deserialized_common(alpha_project_id, ADMIN_USER_PAT) + .await; + assert_eq!(alpha_body.categories, DUMMY_CATEGORIES[0..=2]); + assert_eq!(alpha_body.additional_categories, DUMMY_CATEGORIES[4..=5]); + + let beta_body = api + .get_project_deserialized_common(beta_project_id, ADMIN_USER_PAT) + .await; + assert_eq!(beta_body.categories, alpha_body.categories); + assert_eq!( + beta_body.additional_categories, + alpha_body.additional_categories, + ); + }) + .await; +} + +#[actix_rt::test] +pub async fn test_bulk_edit_links() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + let alpha_project_id: &str = + &test_env.dummy.project_alpha.project_id; + let beta_project_id: &str = &test_env.dummy.project_beta.project_id; + + // Sets links for issue, source, wiki, and patreon for all projects + // The first loop, sets issue, the second, clears it for all projects. + for issues in [Some("https://www.issues.com"), None] { + let resp = api + .edit_project_bulk( + &[alpha_project_id, beta_project_id], + json!({ + "link_urls": { + "issues": issues, + "wiki": "https://wiki.com", + "patreon": "https://patreon.com", + }, + }), + ADMIN_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let alpha_body = api + .get_project_deserialized(alpha_project_id, ADMIN_USER_PAT) + .await; + if let Some(issues) = issues { + assert_eq!(alpha_body.link_urls.len(), 3); + assert_eq!(alpha_body.link_urls["issues"].url, issues); + } else { + assert_eq!(alpha_body.link_urls.len(), 2); + assert!(!alpha_body.link_urls.contains_key("issues")); + } + assert_eq!( + alpha_body.link_urls["wiki"].url, + "https://wiki.com" + ); + assert_eq!( + alpha_body.link_urls["patreon"].url, + "https://patreon.com" + ); + + let beta_body = api + .get_project_deserialized(beta_project_id, ADMIN_USER_PAT) + .await; + assert_eq!(beta_body.categories, alpha_body.categories); + assert_eq!( + beta_body.additional_categories, + alpha_body.additional_categories, + ); + } + }, + ) + .await; +} + +#[actix_rt::test] +async fn permissions_patch_project_v3() { + with_test_environment(Some(8), |test_env: TestEnvironment| async move { + let alpha_project_id = &test_env.dummy.project_alpha.project_id; + let alpha_team_id = &test_env.dummy.project_alpha.team_id; + + let api = &test_env.api; + // TODO: This should be a separate test from v3 + // - only a couple of these fields are v3-specific + // once we have permissions/scope tests setup to not just take closures, we can split this up + + // For each permission covered by EDIT_DETAILS, ensure the permission is required + let edit_details = ProjectPermissions::EDIT_DETAILS; + let test_pairs = [ + // Body, status, requested_status tested separately + ("slug", json!("")), // generated in the test to not collide slugs + ("name", json!("randomname")), + ("description", json!("randomdescription")), + ("categories", json!(["combat", "economy"])), + ("additional_categories", json!(["decoration"])), + ( + "links", + json!({ + "issues": "https://issues.com", + "source": "https://source.com", + }), + ), + ("license_id", json!("MIT")), + ]; + + futures::stream::iter(test_pairs) + .map(|(key, value)| { + let test_env = test_env.clone(); + async move { + let req_gen = |ctx: PermissionsTestContext| { + let value = value.clone(); + async move { + api.edit_project( + &ctx.project_id.unwrap(), + json!({ + key: if key == "slug" { + json!(generate_random_name("randomslug")) + } else { + value.clone() + }, + }), + ctx.test_pat.as_deref(), + ) + .await + } + }; + PermissionsTest::new(&test_env) + .simple_project_permissions_test(edit_details, req_gen) + .await + .into_iter(); + } + }) + .buffer_unordered(4) + .collect::>() + .await; + + // Test with status and requested_status + // This requires a project with a version, so we use alpha_project_id + let req_gen = |ctx: PermissionsTestContext| async move { + api.edit_project( + &ctx.project_id.unwrap(), + json!({ + "status": "private", + "requested_status": "private", + }), + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .with_existing_project(alpha_project_id, alpha_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_project_permissions_test(edit_details, req_gen) + .await + .unwrap(); + + // Bulk patch projects + let req_gen = |ctx: PermissionsTestContext| async move { + api.edit_project_bulk( + &[&ctx.project_id.unwrap()], + json!({ + "name": "randomname", + }), + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .simple_project_permissions_test(edit_details, req_gen) + .await + .unwrap(); + + // Edit body + // Cannot bulk edit body + let edit_body = ProjectPermissions::EDIT_BODY; + let req_gen = |ctx: PermissionsTestContext| async move { + api.edit_project( + &ctx.project_id.unwrap(), + json!({ + "description": "new description!", + }), + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .simple_project_permissions_test(edit_body, req_gen) + .await + .unwrap(); + }) + .await; +} + +// TODO: Project scheduling has been temporarily disabled, so this test is disabled as well +// #[actix_rt::test] +// async fn permissions_schedule() { +// with_test_environment(None, |test_env : TestEnvironment| async move { +// let DummyProjectAlpha { +// project_id: alpha_project_id, +// team_id: alpha_team_id, +// .. +// } = &test_env.dummy.project_alpha; +// let DummyProjectBeta { +// project_id: beta_project_id, +// version_id: beta_version_id, +// team_id: beta_team_id, +// .. +// } = &test_env.dummy.project_beta; + +// let edit_details = ProjectPermissions::EDIT_DETAILS; +// let api = &test_env.api; + +// // Approve beta version as private so we can schedule it +// let resp = api +// .edit_version( +// beta_version_id, +// json!({ +// "status": "unlisted" +// }), +// MOD_USER_PAT, +// ) +// .await; +// assert_status!(&resp, StatusCode::NO_CONTENT); + +// // Schedule version +// let req_gen = |ctx: PermissionsTestContext| async move { +// api.schedule_version( +// beta_version_id, +// "archived", +// Utc::now() + Duration::days(1), +// ctx.test_pat.as_deref(), +// ) +// .await +// }; +// PermissionsTest::new(&test_env) +// .with_existing_project(beta_project_id, beta_team_id) +// .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) +// .simple_project_permissions_test(edit_details, req_gen) +// .await +// .unwrap(); +// }).await +// } + +// Not covered by PATCH /project +#[actix_rt::test] +async fn permissions_edit_details() { + with_test_environment_all(Some(10), |test_env| async move { + let DummyProjectAlpha { + project_id: alpha_project_id, + team_id: alpha_team_id, + .. + } = &test_env.dummy.project_alpha; + + let edit_details = ProjectPermissions::EDIT_DETAILS; + let api = &test_env.api; + + // Icon edit + // Uses alpha project to delete this icon + let req_gen = |ctx: PermissionsTestContext| async move { + api.edit_project_icon( + &ctx.project_id.unwrap(), + Some(DummyImage::SmallIcon.get_icon_data()), + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .with_existing_project(alpha_project_id, alpha_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_project_permissions_test(edit_details, req_gen) + .await + .unwrap(); + + // Icon delete + // Uses alpha project to delete added icon + let req_gen = |ctx: PermissionsTestContext| async move { + api.edit_project_icon( + &ctx.project_id.unwrap(), + None, + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .with_existing_project(alpha_project_id, alpha_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_project_permissions_test(edit_details, req_gen) + .await + .unwrap(); + + // Add gallery item + // Uses alpha project to add gallery item so we can get its url + let req_gen = |ctx: PermissionsTestContext| async move { + api.add_gallery_item( + &ctx.project_id.unwrap(), + DummyImage::SmallIcon.get_icon_data(), + true, + None, + None, + None, + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .with_existing_project(alpha_project_id, alpha_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_project_permissions_test(edit_details, req_gen) + .await + .unwrap(); + // Get project, as we need the gallery image url + let resp = api.get_project(alpha_project_id, USER_USER_PAT).await; + let project: serde_json::Value = test::read_body_json(resp).await; + let gallery_url = project["gallery"][0]["url"].as_str().unwrap(); + + // Edit gallery item + // Uses alpha project to edit gallery item + let req_gen = |ctx: PermissionsTestContext| async move { + api.edit_gallery_item( + &ctx.project_id.unwrap(), + gallery_url, + vec![("description".to_string(), "new caption!".to_string())] + .into_iter() + .collect(), + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .with_existing_project(alpha_project_id, alpha_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_project_permissions_test(edit_details, req_gen) + .await + .unwrap(); + + // Remove gallery item + // Uses alpha project to remove gallery item + let req_gen = |ctx: PermissionsTestContext| async move { + api.remove_gallery_item( + &ctx.project_id.unwrap(), + gallery_url, + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .with_existing_project(alpha_project_id, alpha_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_project_permissions_test(edit_details, req_gen) + .await + .unwrap(); + }) + .await; +} + +#[actix_rt::test] +async fn permissions_upload_version() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let alpha_project_id = &test_env.dummy.project_alpha.project_id; + let alpha_version_id = &test_env.dummy.project_alpha.version_id; + let alpha_team_id = &test_env.dummy.project_alpha.team_id; + let alpha_file_hash = &test_env.dummy.project_alpha.file_hash; + + let api = &test_env.api; + + let upload_version = ProjectPermissions::UPLOAD_VERSION; + // Upload version with basic-mod.jar + let req_gen = |ctx: PermissionsTestContext| async move { + let project_id = ctx.project_id.unwrap(); + let project_id = ProjectId(parse_base62(&project_id).unwrap()); + api.add_public_version( + project_id, + "1.0.0", + TestFile::BasicMod, + None, + None, + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .simple_project_permissions_test(upload_version, req_gen) + .await + .unwrap(); + + // Upload file to existing version + // Uses alpha project, as it has an existing version + let req_gen = |ctx: PermissionsTestContext| async move { + api.upload_file_to_version( + alpha_version_id, + &TestFile::BasicModDifferent, + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .with_existing_project(alpha_project_id, alpha_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_project_permissions_test(upload_version, req_gen) + .await + .unwrap(); + + // Patch version + // Uses alpha project, as it has an existing version + let req_gen = |ctx: PermissionsTestContext| async move { + api.edit_version( + alpha_version_id, + json!({ + "name": "Basic Mod", + }), + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .with_existing_project(alpha_project_id, alpha_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_project_permissions_test(upload_version, req_gen) + .await + .unwrap(); + + // Delete version file + // Uses alpha project, as it has an existing version + let delete_version = ProjectPermissions::DELETE_VERSION; + let req_gen = |ctx: PermissionsTestContext| async move { + api.remove_version_file( + alpha_file_hash, + ctx.test_pat.as_deref(), + ) + .await + }; + + PermissionsTest::new(&test_env) + .with_existing_project(alpha_project_id, alpha_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_project_permissions_test(delete_version, req_gen) + .await + .unwrap(); + + // Delete version + // Uses alpha project, as it has an existing version + let req_gen = |ctx: PermissionsTestContext| async move { + api.remove_version(alpha_version_id, ctx.test_pat.as_deref()) + .await + }; + PermissionsTest::new(&test_env) + .with_existing_project(alpha_project_id, alpha_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_project_permissions_test(delete_version, req_gen) + .await + .unwrap(); + }, + ) + .await; +} + +#[actix_rt::test] +async fn permissions_manage_invites() { + // Add member, remove member, edit member + with_test_environment_all(None, |test_env| async move { + let alpha_project_id = &test_env.dummy.project_alpha.project_id; + let alpha_team_id = &test_env.dummy.project_alpha.team_id; + + let api = &test_env.api; + let manage_invites = ProjectPermissions::MANAGE_INVITES; + + // Add member + let req_gen = |ctx: PermissionsTestContext| async move { + api.add_user_to_team( + &ctx.team_id.unwrap(), + MOD_USER_ID, + Some(ProjectPermissions::empty()), + None, + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .with_existing_project(alpha_project_id, alpha_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_project_permissions_test(manage_invites, req_gen) + .await + .unwrap(); + + // Edit member + let edit_member = ProjectPermissions::EDIT_MEMBER; + let req_gen = |ctx: PermissionsTestContext| async move { + api.edit_team_member( + &ctx.team_id.unwrap(), + MOD_USER_ID, + json!({ + "permissions": 0, + }), + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .with_existing_project(alpha_project_id, alpha_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_project_permissions_test(edit_member, req_gen) + .await + .unwrap(); + + // remove member + // requires manage_invites if they have not yet accepted the invite + let req_gen = |ctx: PermissionsTestContext| async move { + api.remove_from_team( + &ctx.team_id.unwrap(), + MOD_USER_ID, + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .with_existing_project(alpha_project_id, alpha_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_project_permissions_test(manage_invites, req_gen) + .await + .unwrap(); + + // re-add member for testing + let resp = api + .add_user_to_team( + alpha_team_id, + MOD_USER_ID, + Some(ProjectPermissions::empty()), + None, + ADMIN_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Accept invite + let resp = api.join_team(alpha_team_id, MOD_USER_PAT).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // remove existing member (requires remove_member) + let remove_member = ProjectPermissions::REMOVE_MEMBER; + let req_gen = |ctx: PermissionsTestContext| async move { + api.remove_from_team( + &ctx.team_id.unwrap(), + MOD_USER_ID, + ctx.test_pat.as_deref(), + ) + .await + }; + + PermissionsTest::new(&test_env) + .with_existing_project(alpha_project_id, alpha_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_project_permissions_test(remove_member, req_gen) + .await + .unwrap(); + }) + .await; +} + +#[actix_rt::test] +async fn permissions_delete_project() { + // Add member, remove member, edit member + with_test_environment_all(None, |test_env| async move { + let delete_project = ProjectPermissions::DELETE_PROJECT; + let api = &test_env.api; + // Delete project + let req_gen = |ctx: PermissionsTestContext| async move { + api.remove_project( + &ctx.project_id.unwrap(), + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .simple_project_permissions_test(delete_project, req_gen) + .await + .unwrap(); + + test_env.cleanup().await; + }) + .await; +} + +#[actix_rt::test] +async fn project_permissions_consistency_test() { + with_test_environment_all(Some(10), |test_env| async move { + // Test that the permissions are consistent with each other + // For example, if we get the projectpermissions directly, from an organization's defaults, overriden, etc, they should all be correct & consistent + let api = &test_env.api; + // Full project permissions test with EDIT_DETAILS + let success_permissions = ProjectPermissions::EDIT_DETAILS; + let req_gen = |ctx: PermissionsTestContext| async move { + api.edit_project( + &ctx.project_id.unwrap(), + json!({ + "categories": [], + }), + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .full_project_permissions_test(success_permissions, req_gen) + .await + .unwrap(); + + // We do a test with more specific permissions, to ensure that *exactly* the permissions at each step are as expected + let success_permissions = ProjectPermissions::EDIT_DETAILS + | ProjectPermissions::REMOVE_MEMBER + | ProjectPermissions::DELETE_VERSION + | ProjectPermissions::VIEW_PAYOUTS; + let req_gen = |ctx: PermissionsTestContext| async move { + api.edit_project( + &ctx.project_id.unwrap(), + json!({ + "categories": [], + }), + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .full_project_permissions_test(success_permissions, req_gen) + .await + .unwrap(); + }) + .await; +} + +// TODO: Re-add this if we want to match v3 Projects structure to v3 Search Result structure, otherwise, delete +// #[actix_rt::test] +// async fn align_search_projects() { +// // Test setup and dummy data +// with_test_environment(Some(10), |test_env: TestEnvironment| async move { +// setup_search_projects(&test_env).await; + +// let api = &test_env.api; +// let test_name = test_env.db.database_name.clone(); + +// let projects = api +// .search_deserialized( +// Some(&format!("\"&{test_name}\"")), +// Some(json!([["categories:fabric"]])), +// USER_USER_PAT, +// ) +// .await; + +// for project in projects.hits { +// let project_model = api +// .get_project(&project.id.to_string(), USER_USER_PAT) +// .await; +// assert_status!(&project_model, StatusCode::OK); +// let mut project_model: Project = test::read_body_json(project_model).await; + +// // Body/description is huge- don't store it in search, so it's StatusCode::OK if they differ here +// // (Search should return "") +// project_model.description = "".into(); + +// let project_model = serde_json::to_value(project_model).unwrap(); +// let searched_project_serialized = serde_json::to_value(project).unwrap(); +// assert_eq!(project_model, searched_project_serialized); +// } +// }) +// .await +// } + +#[actix_rt::test] +async fn projects_various_visibility() { + // For testing the filter_visible_projects and is_visible_project + with_test_environment( + None, + |env: common::environment::TestEnvironment| async move { + let DummyProjectAlpha { + project_id: alpha_project_id, + project_id_parsed: alpha_project_id_parsed, + .. + } = &env.dummy.project_alpha; + let DummyProjectBeta { + project_id: beta_project_id, + project_id_parsed: beta_project_id_parsed, + .. + } = &env.dummy.project_beta; + let DummyOrganizationZeta { + organization_id: zeta_organization_id, + team_id: zeta_team_id, + .. + } = &env.dummy.organization_zeta; + + // Invite friend to org zeta and accept it + let resp = env + .api + .add_user_to_team( + zeta_team_id, + FRIEND_USER_ID, + Some(ProjectPermissions::empty()), + None, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + let resp = env.api.join_team(zeta_team_id, FRIEND_USER_PAT).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let visible_pat_pairs = vec![ + (&alpha_project_id_parsed, USER_USER_PAT, StatusCode::OK), + (&alpha_project_id_parsed, FRIEND_USER_PAT, StatusCode::OK), + (&alpha_project_id_parsed, ENEMY_USER_PAT, StatusCode::OK), + (&beta_project_id_parsed, USER_USER_PAT, StatusCode::OK), + ( + &beta_project_id_parsed, + FRIEND_USER_PAT, + StatusCode::NOT_FOUND, + ), + ( + &beta_project_id_parsed, + ENEMY_USER_PAT, + StatusCode::NOT_FOUND, + ), + ]; + + // Tests get_project, a route that uses is_visible_project + for (project_id, pat, expected_status) in visible_pat_pairs { + let resp = + env.api.get_project(&project_id.to_string(), pat).await; + assert_status!(&resp, expected_status); + } + + // Test get_user_projects, a route that uses filter_visible_projects + let visible_pat_pairs = vec![ + (USER_USER_PAT, 2), + (FRIEND_USER_PAT, 1), + (ENEMY_USER_PAT, 1), + ]; + for (pat, expected_count) in visible_pat_pairs { + let projects = env + .api + .get_user_projects_deserialized_common(USER_USER_ID, pat) + .await; + assert_eq!(projects.len(), expected_count); + } + + // Add projects to org zeta + let resp = env + .api + .organization_add_project( + zeta_organization_id, + alpha_project_id, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::OK); + let resp = env + .api + .organization_add_project( + zeta_organization_id, + beta_project_id, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::OK); + + // Test get_project, a route that uses is_visible_project + let visible_pat_pairs = vec![ + (&alpha_project_id_parsed, USER_USER_PAT, StatusCode::OK), + (&alpha_project_id_parsed, FRIEND_USER_PAT, StatusCode::OK), + (&alpha_project_id_parsed, ENEMY_USER_PAT, StatusCode::OK), + (&beta_project_id_parsed, USER_USER_PAT, StatusCode::OK), + (&beta_project_id_parsed, FRIEND_USER_PAT, StatusCode::OK), + ( + &beta_project_id_parsed, + ENEMY_USER_PAT, + StatusCode::NOT_FOUND, + ), + ]; + + for (project_id, pat, expected_status) in visible_pat_pairs { + let resp = + env.api.get_project(&project_id.to_string(), pat).await; + assert_status!(&resp, expected_status); + } + + // Test get_user_projects, a route that uses filter_visible_projects + let visible_pat_pairs = vec![ + (USER_USER_PAT, 2), + (FRIEND_USER_PAT, 2), + (ENEMY_USER_PAT, 1), + ]; + for (pat, expected_count) in visible_pat_pairs { + let projects = env + .api + .get_projects(&[alpha_project_id, beta_project_id], pat) + .await; + let projects: Vec = + test::read_body_json(projects).await; + assert_eq!(projects.len(), expected_count); + } + }, + ) + .await; +} + +// Route tests: +// TODO: Missing routes on projects +// TODO: using permissions/scopes, can we SEE projects existence that we are not allowed to? (ie 401 instead of 404) + +// Permissions: +// TODO: permissions VIEW_PAYOUTS currently is unused. Add tests when it is used. +// TODO: permissions VIEW_ANALYTICS currently is unused. Add tests when it is used. diff --git a/apps/labrinth/tests/scopes.rs b/apps/labrinth/tests/scopes.rs new file mode 100644 index 000000000..1d19d2b4f --- /dev/null +++ b/apps/labrinth/tests/scopes.rs @@ -0,0 +1,1232 @@ +use std::collections::HashMap; + +use crate::common::api_common::{ + ApiProject, ApiTeams, ApiUser, ApiVersion, AppendsOptionalPat, +}; +use crate::common::dummy_data::{ + DummyImage, DummyProjectAlpha, DummyProjectBeta, +}; +use actix_http::StatusCode; +use actix_web::test; +use chrono::{Duration, Utc}; +use common::api_common::models::CommonItemType; +use common::api_common::Api; +use common::api_v3::request_data::get_public_project_creation_data; +use common::api_v3::ApiV3; +use common::dummy_data::TestFile; +use common::environment::{ + with_test_environment, with_test_environment_all, TestEnvironment, +}; +use common::{database::*, scopes::ScopeTest}; +use labrinth::models::ids::base62_impl::parse_base62; +use labrinth::models::pats::Scopes; +use labrinth::models::projects::ProjectId; +use labrinth::models::users::UserId; +use serde_json::json; + +// For each scope, we (using test_scope): +// - create a PAT with a given set of scopes for a function +// - create a PAT with all other scopes for a function +// - test the function with the PAT with the given scopes +// - test the function with the PAT with all other scopes + +mod common; + +// Test for users, emails, and payout scopes (not user auth scope or notifs) +#[actix_rt::test] +async fn user_scopes() { + // Test setup and dummy data + with_test_environment_all(None, |test_env| async move { + let api = &test_env.api; + // User reading + let read_user = Scopes::USER_READ; + let req_gen = |pat: Option| async move { + api.get_current_user(pat.as_deref()).await + }; + let (_, success) = ScopeTest::new(&test_env) + .test(req_gen, read_user) + .await + .unwrap(); + assert!(success["email"].as_str().is_none()); // email should not be present + assert!(success["payout_data"].as_object().is_none()); // payout should not be present + + // Email reading + let read_email = Scopes::USER_READ | Scopes::USER_READ_EMAIL; + let req_gen = |pat: Option| async move { + api.get_current_user(pat.as_deref()).await + }; + let (_, success) = ScopeTest::new(&test_env) + .test(req_gen, read_email) + .await + .unwrap(); + assert_eq!(success["email"], json!("user@modrinth.com")); // email should be present + + // Payout reading + let read_payout = Scopes::USER_READ | Scopes::PAYOUTS_READ; + let req_gen = |pat: Option| async move { + api.get_current_user(pat.as_deref()).await + }; + let (_, success) = ScopeTest::new(&test_env) + .test(req_gen, read_payout) + .await + .unwrap(); + assert!(success["payout_data"].as_object().is_some()); // payout should be present + + // User writing + // We use the Admin PAT for this test, on the 'user' user + let write_user = Scopes::USER_WRITE; + let req_gen = |pat: Option| async move { + api.edit_user( + "user", + json!( { + // Do not include 'username', as to not change the rest of the tests + "name": "NewName", + "bio": "New bio", + "location": "New location", + "role": "admin", + "badges": 5, + // Do not include payout info, different scope + }), + pat.as_deref(), + ) + .await + }; + ScopeTest::new(&test_env) + .with_user_id(ADMIN_USER_ID_PARSED) + .test(req_gen, write_user) + .await + .unwrap(); + + // User deletion + // (The failure is first, and this is the last test for this test function, we can delete it and use the same PAT for both tests) + let delete_user = Scopes::USER_DELETE; + let req_gen = |pat: Option| async move { + api.delete_user("enemy", pat.as_deref()).await + }; + ScopeTest::new(&test_env) + .with_user_id(ENEMY_USER_ID_PARSED) + .test(req_gen, delete_user) + .await + .unwrap(); + }) + .await; +} + +// Notifications +#[actix_rt::test] +pub async fn notifications_scopes() { + with_test_environment_all(None, |test_env| async move { + let api = &test_env.api; + let alpha_team_id = &test_env.dummy.project_alpha.team_id; + + // We will invite user 'friend' to project team, and use that as a notification + // Get notifications + let resp = test_env + .api + .add_user_to_team( + alpha_team_id, + FRIEND_USER_ID, + None, + None, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Notification get + let read_notifications = Scopes::NOTIFICATION_READ; + let req_gen = |pat: Option| async move { + api.get_user_notifications(FRIEND_USER_ID, pat.as_deref()) + .await + }; + let (_, success) = ScopeTest::new(&test_env) + .with_user_id(FRIEND_USER_ID_PARSED) + .test(req_gen, read_notifications) + .await + .unwrap(); + let notification_id = success[0]["id"].as_str().unwrap(); + + let req_gen = |pat: Option| async move { + api.get_notifications(&[notification_id], pat.as_deref()) + .await + }; + ScopeTest::new(&test_env) + .with_user_id(FRIEND_USER_ID_PARSED) + .test(req_gen, read_notifications) + .await + .unwrap(); + + let req_gen = |pat: Option| async move { + api.get_notification(notification_id, pat.as_deref()).await + }; + ScopeTest::new(&test_env) + .with_user_id(FRIEND_USER_ID_PARSED) + .test(req_gen, read_notifications) + .await + .unwrap(); + + // Notification mark as read + let write_notifications = Scopes::NOTIFICATION_WRITE; + let req_gen = |pat: Option| async move { + api.mark_notifications_read(&[notification_id], pat.as_deref()) + .await + }; + ScopeTest::new(&test_env) + .with_user_id(FRIEND_USER_ID_PARSED) + .test(req_gen, write_notifications) + .await + .unwrap(); + + let req_gen = |pat: Option| async move { + api.mark_notification_read(notification_id, pat.as_deref()) + .await + }; + ScopeTest::new(&test_env) + .with_user_id(FRIEND_USER_ID_PARSED) + .test(req_gen, write_notifications) + .await + .unwrap(); + + // Notification delete + let req_gen = |pat: Option| async move { + api.delete_notification(notification_id, pat.as_deref()) + .await + }; + ScopeTest::new(&test_env) + .with_user_id(FRIEND_USER_ID_PARSED) + .test(req_gen, write_notifications) + .await + .unwrap(); + + // Mass notification delete + // We invite mod, get the notification ID, and do mass delete using that + let resp = test_env + .api + .add_user_to_team( + alpha_team_id, + MOD_USER_ID, + None, + None, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + let read_notifications = Scopes::NOTIFICATION_READ; + let req_gen = |pat: Option| async move { + api.get_user_notifications(MOD_USER_ID, pat.as_deref()) + .await + }; + let (_, success) = ScopeTest::new(&test_env) + .with_user_id(MOD_USER_ID_PARSED) + .test(req_gen, read_notifications) + .await + .unwrap(); + let notification_id = success[0]["id"].as_str().unwrap(); + + let req_gen = |pat: Option| async move { + api.delete_notifications(&[notification_id], pat.as_deref()) + .await + }; + ScopeTest::new(&test_env) + .with_user_id(MOD_USER_ID_PARSED) + .test(req_gen, write_notifications) + .await + .unwrap(); + }) + .await; +} + +// Project version creation scopes +#[actix_rt::test] +pub async fn project_version_create_scopes_v3() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + + // Create project + let create_project = Scopes::PROJECT_CREATE; + let req_gen = |pat: Option| async move { + let creation_data = get_public_project_creation_data( + "demo", + Some(TestFile::BasicMod), + None, + ); + api.create_project(creation_data, pat.as_deref()).await + }; + let (_, success) = ScopeTest::new(&test_env) + .test(req_gen, create_project) + .await + .unwrap(); + let project_id = success["id"].as_str().unwrap(); + let project_id = ProjectId(parse_base62(project_id).unwrap()); + + // Add version to project + let create_version = Scopes::VERSION_CREATE; + let req_gen = |pat: Option| async move { + api.add_public_version( + project_id, + "1.2.3.4", + TestFile::BasicModDifferent, + None, + None, + pat.as_deref(), + ) + .await + }; + ScopeTest::new(&test_env) + .test(req_gen, create_version) + .await + .unwrap(); + }, + ) + .await; +} + +// Project management scopes +#[actix_rt::test] +pub async fn project_version_reads_scopes() { + with_test_environment_all(None, |test_env| async move { + let api = &test_env.api; + let DummyProjectAlpha { + team_id: alpha_team_id, + .. + } = &test_env.dummy.project_alpha; + let DummyProjectBeta { + project_id: beta_project_id, + version_id: beta_version_id, + file_hash: beta_file_hash, + .. + } = &test_env.dummy.project_beta; + + // Project reading + // Uses 404 as the expected failure code (or 200 and an empty list for mass reads) + let read_project = Scopes::PROJECT_READ; + let req_gen = |pat: Option| async move { + api.get_project(beta_project_id, pat.as_deref()).await + }; + ScopeTest::new(&test_env) + .with_failure_code(404) + .test(req_gen, read_project) + .await + .unwrap(); + + let req_gen = |pat: Option| async move { + api.get_project_dependencies(beta_project_id, pat.as_deref()) + .await + }; + ScopeTest::new(&test_env) + .with_failure_code(404) + .test(req_gen, read_project) + .await + .unwrap(); + + let req_gen = |pat: Option| async move { + api.get_projects(&[beta_project_id.as_str()], pat.as_deref()) + .await + }; + let (failure, success) = ScopeTest::new(&test_env) + .with_failure_code(200) + .test(req_gen, read_project) + .await + .unwrap(); + assert!(failure.as_array().unwrap().is_empty()); + assert!(!success.as_array().unwrap().is_empty()); + + // Team project reading + let req_gen = |pat: Option| async move { + api.get_project_members(beta_project_id, pat.as_deref()) + .await + }; + ScopeTest::new(&test_env) + .with_failure_code(404) + .test(req_gen, read_project) + .await + .unwrap(); + + // Get team members + // In this case, as these are public endpoints, logging in only is relevant to showing permissions + // So for our test project (with 1 user, 'user') we will check the permissions before and after having the scope. + let req_gen = |pat: Option| async move { + api.get_team_members(alpha_team_id, pat.as_deref()).await + }; + let (failure, success) = ScopeTest::new(&test_env) + .with_failure_code(200) + .test(req_gen, read_project) + .await + .unwrap(); + assert!(!failure[0]["permissions"].is_number()); + assert!(success[0]["permissions"].is_number()); + + let req_gen = |pat: Option| async move { + api.get_teams_members(&[alpha_team_id.as_str()], pat.as_deref()) + .await + }; + let (failure, success) = ScopeTest::new(&test_env) + .with_failure_code(200) + .test(req_gen, read_project) + .await + .unwrap(); + assert!(!failure[0][0]["permissions"].is_number()); + assert!(success[0][0]["permissions"].is_number()); + + // User project reading + // Test user has two projects, one public and one private + let req_gen = |pat: Option| async move { + api.get_user_projects(USER_USER_ID, pat.as_deref()).await + }; + let (failure, success) = ScopeTest::new(&test_env) + .with_failure_code(200) + .test(req_gen, read_project) + .await + .unwrap(); + assert!(!failure + .as_array() + .unwrap() + .iter() + .any(|x| x["status"] == "processing")); + assert!(success + .as_array() + .unwrap() + .iter() + .any(|x| x["status"] == "processing")); + + // Project metadata reading + let req_gen = |pat: Option| async move { + let req = test::TestRequest::get() + .uri(&format!( + "/maven/maven/modrinth/{beta_project_id}/maven-metadata.xml" + )) + .append_pat(pat.as_deref()) + .to_request(); + api.call(req).await + }; + ScopeTest::new(&test_env) + .with_failure_code(404) + .test(req_gen, read_project) + .await + .unwrap(); + + // Version reading + // First, set version to hidden (which is when the scope is required to read it) + let read_version = Scopes::VERSION_READ; + let resp = test_env + .api + .edit_version( + beta_version_id, + json!({ "status": "draft" }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let req_gen = |pat: Option| async move { + api.get_version_from_hash(beta_file_hash, "sha1", pat.as_deref()) + .await + }; + ScopeTest::new(&test_env) + .with_failure_code(404) + .test(req_gen, read_version) + .await + .unwrap(); + + let req_gen = |pat: Option| async move { + api.download_version_redirect( + beta_file_hash, + "sha1", + pat.as_deref(), + ) + .await + }; + ScopeTest::new(&test_env) + .with_failure_code(404) + .test(req_gen, read_version) + .await + .unwrap(); + + // TODO: This scope currently fails still as the 'version' field of QueryProject only allows public versions. + // TODO: This will be fixed when the 'extracts_versions' PR is merged. + // let req_gen = |pat: Option| async move { + // api.get_update_from_hash(beta_file_hash, "sha1", None, None, None, pat.as_deref()) + // .await + // }; + // ScopeTest::new(&test_env).with_failure_code(404).test(req_gen, read_version).await.unwrap(); + + let req_gen = |pat: Option| async move { + api.get_versions_from_hashes( + &[beta_file_hash], + "sha1", + pat.as_deref(), + ) + .await + }; + let (failure, success) = ScopeTest::new(&test_env) + .with_failure_code(200) + .test(req_gen, read_version) + .await + .unwrap(); + assert!(!failure.as_object().unwrap().contains_key(beta_file_hash)); + assert!(success.as_object().unwrap().contains_key(beta_file_hash)); + + // Update version file + // TODO: This scope currently fails still as the 'version' field of QueryProject only allows public versions. + // TODO: This will be fixed when the 'extracts_versions' PR is merged. + // let req_gen = |pat : Option| async move { + // api.update_files("sha1", vec![beta_file_hash.clone()], None, None, None, pat.as_deref()).await + // }; + // let (failure, success) = ScopeTest::new(&test_env).with_failure_code(200).test(req_gen, read_version).await.unwrap(); + // assert!(!failure.as_object().unwrap().contains_key(beta_file_hash)); + // assert!(success.as_object().unwrap().contains_key(beta_file_hash)); + + // Both project and version reading + let read_project_and_version = + Scopes::PROJECT_READ | Scopes::VERSION_READ; + let req_gen = |pat: Option| async move { + api.get_project_versions( + beta_project_id, + None, + None, + None, + None, + None, + None, + pat.as_deref(), + ) + .await + }; + ScopeTest::new(&test_env) + .with_failure_code(404) + .test(req_gen, read_project_and_version) + .await + .unwrap(); + + // TODO: fails for the same reason as above + // let req_gen = || { + // test::TestRequest::get() + // .uri(&format!("/v3/project/{beta_project_id}/version/{beta_version_id}")) + // }; + // ScopeTest::new(&test_env).with_failure_code(404).test(req_gen, read_project_and_version).await.unwrap(); + }) + .await; +} + +// Project writing +#[actix_rt::test] +pub async fn project_write_scopes() { + // Test setup and dummy data + with_test_environment_all(None, |test_env| async move { + let api = &test_env.api; + let beta_project_id = &test_env.dummy.project_beta.project_id; + let alpha_team_id = &test_env.dummy.project_alpha.team_id; + + // Projects writing + let write_project = Scopes::PROJECT_WRITE; + let req_gen = |pat: Option| async move { + api.edit_project( + beta_project_id, + json!( + { + "name": "test_project_version_write_scopes Title", + } + ), + pat.as_deref(), + ) + .await + }; + ScopeTest::new(&test_env) + .test(req_gen, write_project) + .await + .unwrap(); + + let req_gen = |pat: Option| async move { + api.edit_project_bulk( + &[beta_project_id.as_str()], + json!( + { + "description": "test_project_version_write_scopes Description" + }), + pat.as_deref(), + ) + .await + }; + ScopeTest::new(&test_env) + .test(req_gen, write_project) + .await + .unwrap(); + + // Icons and gallery images + let req_gen = |pat: Option| async move { + api.edit_project_icon( + beta_project_id, + Some(DummyImage::SmallIcon.get_icon_data()), + pat.as_deref(), + ) + .await + }; + ScopeTest::new(&test_env) + .test(req_gen, write_project) + .await + .unwrap(); + + let req_gen = |pat: Option| async move { + api.edit_project_icon(beta_project_id, None, pat.as_deref()) + .await + }; + ScopeTest::new(&test_env) + .test(req_gen, write_project) + .await + .unwrap(); + + let req_gen = |pat: Option| async move { + api.add_gallery_item( + beta_project_id, + DummyImage::SmallIcon.get_icon_data(), + true, + None, + None, + None, + pat.as_deref(), + ) + .await + }; + ScopeTest::new(&test_env) + .test(req_gen, write_project) + .await + .unwrap(); + + // Get project, as we need the gallery image url + let resp = api.get_project(beta_project_id, USER_USER_PAT).await; + let project: serde_json::Value = test::read_body_json(resp).await; + let gallery_url = project["gallery"][0]["url"].as_str().unwrap(); + + let req_gen = |pat: Option| async move { + api.edit_gallery_item(beta_project_id, gallery_url, HashMap::new(), pat.as_deref()) + .await + }; + ScopeTest::new(&test_env) + .test(req_gen, write_project) + .await + .unwrap(); + + let req_gen = |pat: Option| async move { + api.remove_gallery_item(beta_project_id, gallery_url, pat.as_deref()) + .await + }; + ScopeTest::new(&test_env) + .test(req_gen, write_project) + .await + .unwrap(); + + // Team scopes - add user 'friend' + let req_gen = |pat: Option| async move { + api.add_user_to_team(alpha_team_id, FRIEND_USER_ID, None, None, pat.as_deref()) + .await + }; + ScopeTest::new(&test_env) + .test(req_gen, write_project) + .await + .unwrap(); + + // Accept team invite as 'friend' + let req_gen = + |pat: Option| async move { api.join_team(alpha_team_id, pat.as_deref()).await }; + ScopeTest::new(&test_env) + .with_user_id(FRIEND_USER_ID_PARSED) + .test(req_gen, write_project) + .await + .unwrap(); + + // Patch 'friend' user + let req_gen = |pat: Option| async move { + api.edit_team_member( + alpha_team_id, + FRIEND_USER_ID, + json!({ + "permissions": 1 + }), + pat.as_deref(), + ) + .await + }; + ScopeTest::new(&test_env) + .test(req_gen, write_project) + .await + .unwrap(); + + // Transfer ownership to 'friend' + let req_gen = |pat: Option| async move { + api.transfer_team_ownership(alpha_team_id, FRIEND_USER_ID, pat.as_deref()) + .await + }; + ScopeTest::new(&test_env) + .test(req_gen, write_project) + .await + .unwrap(); + + // Now as 'friend', delete 'user' + let req_gen = |pat: Option| async move { + api.remove_from_team(alpha_team_id, USER_USER_ID, pat.as_deref()) + .await + }; + ScopeTest::new(&test_env) + .with_user_id(FRIEND_USER_ID_PARSED) + .test(req_gen, write_project) + .await + .unwrap(); + + // Delete project + // TODO: this route is currently broken, + // because the Project::get_id contained within Project::remove doesnt include hidden versions, meaning that if there + // is a hidden version, it will fail to delete the project (with a 500 error, as the versions of a project are not all deleted) + // let delete_version = Scopes::PROJECT_DELETE; + // let req_gen = || { + // test::TestRequest::delete() + // .uri(&format!("/v3/project/{beta_project_id}")) + // }; + // ScopeTest::new(&test_env).test(req_gen, delete_version).await.unwrap(); + }) + .await; +} + +// Version write +#[actix_rt::test] +pub async fn version_write_scopes() { + // Test setup and dummy data + with_test_environment_all(None, |test_env| async move { + let api = &test_env.api; + let DummyProjectAlpha { + version_id: alpha_version_id, + file_hash: alpha_file_hash, + .. + } = &test_env.dummy.project_alpha; + + let write_version = Scopes::VERSION_WRITE; + + // Patch version + let req_gen = |pat: Option| async move { + api.edit_version( + alpha_version_id, + json!( + { + "name": "test_version_write_scopes Title", + } + ), + pat.as_deref(), + ) + .await + }; + ScopeTest::new(&test_env) + .test(req_gen, write_version) + .await + .unwrap(); + + // Upload version file + let req_gen = |pat: Option| async move { + api.upload_file_to_version( + alpha_version_id, + &TestFile::BasicZip, + pat.as_deref(), + ) + .await + }; + ScopeTest::new(&test_env) + .test(req_gen, write_version) + .await + .unwrap(); + + // Delete version file. Notably, this uses 'VERSION_WRITE' instead of 'VERSION_DELETE' as it is writing to the version + let req_gen = |pat: Option| async move { + api.remove_version_file(alpha_file_hash, pat.as_deref()) + .await + // Delete from alpha_version_id, as we uploaded to alpha_version_id and it needs another file + }; + ScopeTest::new(&test_env) + .test(req_gen, write_version) + .await + .unwrap(); + + // Delete version + let delete_version = Scopes::VERSION_DELETE; + let req_gen = |pat: Option| async move { + api.remove_version(alpha_version_id, pat.as_deref()).await + }; + ScopeTest::new(&test_env) + .test(req_gen, delete_version) + .await + .unwrap(); + }) + .await; +} + +// Report scopes +#[actix_rt::test] +pub async fn report_scopes() { + // Test setup and dummy data + with_test_environment_all(None, |test_env| async move { + let api = &test_env.api; + let beta_project_id = &test_env.dummy.project_beta.project_id; + + // Create report + let report_create = Scopes::REPORT_CREATE; + let req_gen = |pat: Option| async move { + api.create_report( + "copyright", + beta_project_id, + CommonItemType::Project, + "This is a reupload of my mod", + pat.as_deref(), + ) + .await + }; + ScopeTest::new(&test_env) + .test(req_gen, report_create) + .await + .unwrap(); + + // Get reports + let report_read = Scopes::REPORT_READ; + let req_gen = |pat: Option| async move { + api.get_user_reports(pat.as_deref()).await + }; + let (_, success) = ScopeTest::new(&test_env) + .test(req_gen, report_read) + .await + .unwrap(); + let report_id = success[0]["id"].as_str().unwrap(); + + let req_gen = |pat: Option| async move { + api.get_report(report_id, pat.as_deref()).await + }; + ScopeTest::new(&test_env) + .test(req_gen, report_read) + .await + .unwrap(); + + let req_gen = |pat: Option| async move { + api.get_reports(&[report_id], pat.as_deref()).await + }; + ScopeTest::new(&test_env) + .test(req_gen, report_read) + .await + .unwrap(); + + // Edit report + let report_edit = Scopes::REPORT_WRITE; + let req_gen = |pat: Option| async move { + api.edit_report( + report_id, + json!({ "body": "This is a reupload of my mod, G8!" }), + pat.as_deref(), + ) + .await + }; + ScopeTest::new(&test_env) + .test(req_gen, report_edit) + .await + .unwrap(); + + // Delete report + // We use a moderator PAT here, as only moderators can delete reports + let report_delete = Scopes::REPORT_DELETE; + let req_gen = |pat: Option| async move { + api.delete_report(report_id, pat.as_deref()).await + }; + ScopeTest::new(&test_env) + .with_user_id(MOD_USER_ID_PARSED) + .test(req_gen, report_delete) + .await + .unwrap(); + }) + .await; +} + +// Thread scopes +#[actix_rt::test] +pub async fn thread_scopes() { + // Test setup and dummy data + with_test_environment_all(None, |test_env| async move { + let api = &test_env.api; + let alpha_thread_id = &test_env.dummy.project_alpha.thread_id; + let beta_thread_id = &test_env.dummy.project_beta.thread_id; + + // Thread read + let thread_read = Scopes::THREAD_READ; + let req_gen = |pat: Option| async move { + api.get_thread(alpha_thread_id, pat.as_deref()).await + }; + ScopeTest::new(&test_env) + .test(req_gen, thread_read) + .await + .unwrap(); + + let req_gen = |pat: Option| async move { + api.get_threads(&[alpha_thread_id.as_str()], pat.as_deref()) + .await + }; + ScopeTest::new(&test_env) + .test(req_gen, thread_read) + .await + .unwrap(); + + // Thread write (to also push to moderator inbox) + let thread_write = Scopes::THREAD_WRITE; + let req_gen = |pat: Option| async move { + api.write_to_thread( + beta_thread_id, + "text", + "test_thread_scopes Body", + pat.as_deref(), + ) + .await + }; + ScopeTest::new(&test_env) + .with_user_id(USER_USER_ID_PARSED) + .test(req_gen, thread_write) + .await + .unwrap(); + }) + .await; +} + +// Pat scopes +#[actix_rt::test] +pub async fn pat_scopes() { + with_test_environment_all(None, |test_env| async move { + let api = &test_env.api; + // Pat create + let pat_create = Scopes::PAT_CREATE; + let req_gen = |pat: Option| async move { + let req = test::TestRequest::post() + .uri("/_internal/pat") + .set_json(json!({ + "scopes": 1, + "name": "test_pat_scopes Name", + "expires": Utc::now() + Duration::days(1), + })) + .append_pat(pat.as_deref()) + .to_request(); + api.call(req).await + }; + let (_, success) = ScopeTest::new(&test_env) + .test(req_gen, pat_create) + .await + .unwrap(); + let pat_id = success["id"].as_str().unwrap(); + + // Pat write + let pat_write = Scopes::PAT_WRITE; + let req_gen = |pat: Option| async move { + let req = test::TestRequest::patch() + .uri(&format!("/_internal/pat/{pat_id}")) + .set_json(json!({})) + .append_pat(pat.as_deref()) + .to_request(); + api.call(req).await + }; + ScopeTest::new(&test_env) + .test(req_gen, pat_write) + .await + .unwrap(); + + // Pat read + let pat_read = Scopes::PAT_READ; + let req_gen = |pat: Option| async move { + let req = test::TestRequest::get() + .uri("/_internal/pat") + .append_pat(pat.as_deref()) + .to_request(); + api.call(req).await + }; + ScopeTest::new(&test_env) + .test(req_gen, pat_read) + .await + .unwrap(); + + // Pat delete + let pat_delete = Scopes::PAT_DELETE; + let req_gen = |pat: Option| async move { + let req = test::TestRequest::delete() + .uri(&format!("/_internal/pat/{pat_id}")) + .append_pat(pat.as_deref()) + .to_request(); + api.call(req).await + }; + ScopeTest::new(&test_env) + .test(req_gen, pat_delete) + .await + .unwrap(); + }) + .await; +} + +// Collection scopes +#[actix_rt::test] +pub async fn collections_scopes() { + // Test setup and dummy data + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + let alpha_project_id = &test_env.dummy.project_alpha.project_id; + + // Create collection + let collection_create = Scopes::COLLECTION_CREATE; + let req_gen = |pat: Option| async move { + api.create_collection( + "Test Collection", + "Test Collection Description", + &[alpha_project_id.as_str()], + pat.as_deref(), + ) + .await + }; + let (_, success) = ScopeTest::new(&test_env) + .test(req_gen, collection_create) + .await + .unwrap(); + let collection_id = success["id"].as_str().unwrap(); + + // Patch collection + // Collections always initialize to public, so we do patch before Get testing + let collection_write = Scopes::COLLECTION_WRITE; + let req_gen = |pat: Option| async move { + api.edit_collection( + collection_id, + json!({ + "name": "Test Collection patch", + "status": "private", + }), + pat.as_deref(), + ) + .await + }; + ScopeTest::new(&test_env) + .test(req_gen, collection_write) + .await + .unwrap(); + + // Read collection + let collection_read = Scopes::COLLECTION_READ; + let req_gen = |pat: Option| async move { + api.get_collection(collection_id, pat.as_deref()).await + }; + ScopeTest::new(&test_env) + .with_failure_code(404) + .test(req_gen, collection_read) + .await + .unwrap(); + + let req_gen = |pat: Option| async move { + api.get_collections(&[collection_id], pat.as_deref()).await + }; + let (failure, success) = ScopeTest::new(&test_env) + .with_failure_code(200) + .test(req_gen, collection_read) + .await + .unwrap(); + assert_eq!(failure.as_array().unwrap().len(), 0); + assert_eq!(success.as_array().unwrap().len(), 1); + + let req_gen = |pat: Option| async move { + api.get_user_collections(USER_USER_ID, pat.as_deref()).await + }; + let (failure, success) = ScopeTest::new(&test_env) + .with_failure_code(200) + .test(req_gen, collection_read) + .await + .unwrap(); + assert_eq!(failure.as_array().unwrap().len(), 0); + assert_eq!(success.as_array().unwrap().len(), 1); + + let req_gen = |pat: Option| async move { + api.edit_collection_icon( + collection_id, + Some(DummyImage::SmallIcon.get_icon_data()), + pat.as_deref(), + ) + .await + }; + ScopeTest::new(&test_env) + .test(req_gen, collection_write) + .await + .unwrap(); + + let req_gen = |pat: Option| async move { + api.edit_collection_icon(collection_id, None, pat.as_deref()) + .await + }; + ScopeTest::new(&test_env) + .test(req_gen, collection_write) + .await + .unwrap(); + }, + ) + .await; +} + +// Organization scopes (and a couple PROJECT_WRITE scopes that are only allowed for orgs) +#[actix_rt::test] +pub async fn organization_scopes() { + // Test setup and dummy data + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + let beta_project_id = &test_env.dummy.project_beta.project_id; + + // Create organization + let organization_create = Scopes::ORGANIZATION_CREATE; + let req_gen = |pat: Option| async move { + api.create_organization( + "Test Org", + "TestOrg", + "TestOrg Description", + pat.as_deref(), + ) + .await + }; + let (_, success) = ScopeTest::new(&test_env) + .test(req_gen, organization_create) + .await + .unwrap(); + let organization_id = success["id"].as_str().unwrap(); + + // Patch organization + let organization_edit = Scopes::ORGANIZATION_WRITE; + let req_gen = |pat: Option| async move { + api.edit_organization( + organization_id, + json!({ + "description": "TestOrg Patch Description", + }), + pat.as_deref(), + ) + .await + }; + ScopeTest::new(&test_env) + .test(req_gen, organization_edit) + .await + .unwrap(); + + let req_gen = |pat: Option| async move { + api.edit_organization_icon( + organization_id, + Some(DummyImage::SmallIcon.get_icon_data()), + pat.as_deref(), + ) + .await + }; + ScopeTest::new(&test_env) + .test(req_gen, organization_edit) + .await + .unwrap(); + + let req_gen = |pat: Option| async move { + api.edit_organization_icon( + organization_id, + None, + pat.as_deref(), + ) + .await + }; + ScopeTest::new(&test_env) + .test(req_gen, organization_edit) + .await + .unwrap(); + + // add project + let organization_project_edit = + Scopes::PROJECT_WRITE | Scopes::ORGANIZATION_WRITE; + let req_gen = |pat: Option| async move { + api.organization_add_project( + organization_id, + beta_project_id, + pat.as_deref(), + ) + .await + }; + ScopeTest::new(&test_env) + .with_failure_scopes(Scopes::all() ^ Scopes::ORGANIZATION_WRITE) + .test(req_gen, organization_project_edit) + .await + .unwrap(); + + // Organization reads + let organization_read = Scopes::ORGANIZATION_READ; + let req_gen = |pat: Option| async move { + api.get_organization(organization_id, pat.as_deref()).await + }; + let (failure, success) = ScopeTest::new(&test_env) + .with_failure_code(200) + .test(req_gen, organization_read) + .await + .unwrap(); + assert!(failure["members"][0]["permissions"].is_null()); + assert!(!success["members"][0]["permissions"].is_null()); + + let req_gen = |pat: Option| async move { + api.get_organizations(&[organization_id], pat.as_deref()) + .await + }; + + let (failure, success) = ScopeTest::new(&test_env) + .with_failure_code(200) + .test(req_gen, organization_read) + .await + .unwrap(); + assert!(failure[0]["members"][0]["permissions"].is_null()); + assert!(!success[0]["members"][0]["permissions"].is_null()); + + let organization_project_read = + Scopes::PROJECT_READ | Scopes::ORGANIZATION_READ; + let req_gen = |pat: Option| async move { + api.get_organization_projects(organization_id, pat.as_deref()) + .await + }; + let (failure, success) = ScopeTest::new(&test_env) + .with_failure_code(200) + .with_failure_scopes(Scopes::all() ^ Scopes::ORGANIZATION_READ) + .test(req_gen, organization_project_read) + .await + .unwrap(); + assert!(failure.as_array().unwrap().is_empty()); + assert!(!success.as_array().unwrap().is_empty()); + + // remove project (now that we've checked) + let req_gen = |pat: Option| async move { + api.organization_remove_project( + organization_id, + beta_project_id, + UserId(USER_USER_ID_PARSED as u64), + pat.as_deref(), + ) + .await + }; + ScopeTest::new(&test_env) + .with_failure_scopes(Scopes::all() ^ Scopes::ORGANIZATION_WRITE) + .test(req_gen, organization_project_edit) + .await + .unwrap(); + + // Delete organization + let organization_delete = Scopes::ORGANIZATION_DELETE; + let req_gen = |pat: Option| async move { + api.delete_organization(organization_id, pat.as_deref()) + .await + }; + ScopeTest::new(&test_env) + .test(req_gen, organization_delete) + .await + .unwrap(); + }, + ) + .await; +} + +// TODO: Analytics scopes + +// TODO: User authentication, and Session scopes + +// TODO: Some hash/version files functions + +// TODO: Image scopes diff --git a/apps/labrinth/tests/search.rs b/apps/labrinth/tests/search.rs new file mode 100644 index 000000000..d0c5fb14c --- /dev/null +++ b/apps/labrinth/tests/search.rs @@ -0,0 +1,195 @@ +use actix_http::StatusCode; +use common::api_v3::ApiV3; +use common::database::*; + +use common::dummy_data::DUMMY_CATEGORIES; + +use common::environment::with_test_environment; +use common::environment::TestEnvironment; +use common::search::setup_search_projects; +use futures::stream::StreamExt; +use labrinth::models::ids::base62_impl::parse_base62; +use serde_json::json; + +use crate::common::api_common::Api; +use crate::common::api_common::ApiProject; + +mod common; + +// TODO: Revisit this wit h the new modify_json in the version maker +// That change here should be able to simplify it vastly + +#[actix_rt::test] +async fn search_projects() { + // Test setup and dummy data + with_test_environment( + Some(10), + |test_env: TestEnvironment| async move { + let id_conversion = setup_search_projects(&test_env).await; + + let api = &test_env.api; + let test_name = test_env.db.database_name.clone(); + + // Pairs of: + // 1. vec of search facets + // 2. expected project ids to be returned by this search + let pairs = vec![ + ( + json!([["categories:fabric"]]), + vec![0, 1, 2, 3, 4, 5, 6, 7, 9], + ), + (json!([["categories:forge"]]), vec![7]), + ( + json!([["categories:fabric", "categories:forge"]]), + vec![0, 1, 2, 3, 4, 5, 6, 7, 9], + ), + (json!([["categories:fabric"], ["categories:forge"]]), vec![]), + ( + json!([ + ["categories:fabric"], + [&format!("categories:{}", DUMMY_CATEGORIES[0])], + ]), + vec![1, 2, 3, 4], + ), + (json!([["project_types:modpack"]]), vec![4]), + (json!([["client_only:true"]]), vec![0, 2, 3, 7, 9]), + (json!([["server_only:true"]]), vec![0, 2, 3, 6, 7]), + (json!([["open_source:true"]]), vec![0, 1, 2, 4, 5, 6, 7, 9]), + (json!([["license:MIT"]]), vec![1, 2, 4, 9]), + (json!([[r#"name:'Mysterious Project'"#]]), vec![2, 3]), + (json!([["author:user"]]), vec![0, 1, 2, 4, 5, 7, 9]), // Organization test '9' is included here as user is owner of org + (json!([["game_versions:1.20.5"]]), vec![4, 5]), + // bug fix + ( + json!([ + // Only the forge one has 1.20.2, so its true that this project 'has' + // 1.20.2 and a fabric version, but not true that it has a 1.20.2 fabric version. + ["categories:fabric"], + ["game_versions:1.20.2"] + ]), + vec![], + ), + // Project type change + // Modpack should still be able to search based on former loader, even though technically the loader is 'mrpack' + // (json!([["categories:mrpack"]]), vec![4]), + // ( + // json!([["categories:fabric"]]), + // vec![4], + // ), + ( + json!([["categories:fabric"], ["project_types:modpack"]]), + vec![4], + ), + ]; + // TODO: versions, game versions + // Untested: + // - downloads (not varied) + // - color (not varied) + // - created_timestamp (not varied) + // - modified_timestamp (not varied) + // TODO: multiple different project types test + + // Test searches + let stream = futures::stream::iter(pairs); + stream + .for_each_concurrent(1, |(facets, mut expected_project_ids)| { + let id_conversion = id_conversion.clone(); + let test_name = test_name.clone(); + async move { + let projects = api + .search_deserialized( + Some(&format!("\"&{test_name}\"")), + Some(facets.clone()), + USER_USER_PAT, + ) + .await; + let mut found_project_ids: Vec = projects + .hits + .into_iter() + .map(|p| { + id_conversion + [&parse_base62(&p.project_id).unwrap()] + }) + .collect(); + let num_hits = projects.total_hits; + expected_project_ids.sort(); + found_project_ids.sort(); + println!("Facets: {:?}", facets); + assert_eq!(found_project_ids, expected_project_ids); + assert_eq!(num_hits, { expected_project_ids.len() }); + } + }) + .await; + }, + ) + .await; +} + +#[actix_rt::test] +async fn index_swaps() { + with_test_environment( + Some(10), + |test_env: TestEnvironment| async move { + // Reindex + let resp = test_env.api.reset_search_index().await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Now we should get results + let projects = test_env + .api + .search_deserialized( + None, + Some(json!([["categories:fabric"]])), + USER_USER_PAT, + ) + .await; + assert_eq!(projects.total_hits, 1); + assert!(projects.hits[0].slug.as_ref().unwrap().contains("alpha")); + + // Delete the project + let resp = + test_env.api.remove_project("alpha", USER_USER_PAT).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // We should not get any results, because the project has been deleted + let projects = test_env + .api + .search_deserialized( + None, + Some(json!([["categories:fabric"]])), + USER_USER_PAT, + ) + .await; + assert_eq!(projects.total_hits, 0); + + // But when we reindex, it should be gone + let resp = test_env.api.reset_search_index().await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let projects = test_env + .api + .search_deserialized( + None, + Some(json!([["categories:fabric"]])), + USER_USER_PAT, + ) + .await; + assert_eq!(projects.total_hits, 0); + + // Reindex again, should still be gone + let resp = test_env.api.reset_search_index().await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let projects = test_env + .api + .search_deserialized( + None, + Some(json!([["categories:fabric"]])), + USER_USER_PAT, + ) + .await; + assert_eq!(projects.total_hits, 0); + }, + ) + .await; +} diff --git a/apps/labrinth/tests/tags.rs b/apps/labrinth/tests/tags.rs new file mode 100644 index 000000000..264c7e29e --- /dev/null +++ b/apps/labrinth/tests/tags.rs @@ -0,0 +1,75 @@ +use std::collections::{HashMap, HashSet}; + +use common::{ + api_v3::ApiV3, + environment::{ + with_test_environment, with_test_environment_all, TestEnvironment, + }, +}; + +use crate::common::api_common::ApiTags; + +mod common; + +#[actix_rt::test] +async fn get_tags() { + with_test_environment_all(None, |test_env| async move { + let api = &test_env.api; + let categories = api.get_categories_deserialized_common().await; + + let category_names = categories + .into_iter() + .map(|x| x.name) + .collect::>(); + assert_eq!( + category_names, + [ + "combat", + "economy", + "food", + "optimization", + "decoration", + "mobs", + "magic" + ] + .iter() + .map(|s| s.to_string()) + .collect() + ); + }) + .await; +} + +#[actix_rt::test] +async fn get_tags_v3() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + let loaders = api.get_loaders_deserialized().await; + + let loader_metadata = loaders + .into_iter() + .map(|x| { + ( + x.name, + x.metadata.get("platform").and_then(|x| x.as_bool()), + ) + }) + .collect::>(); + let loader_names = + loader_metadata.keys().cloned().collect::>(); + assert_eq!( + loader_names, + ["fabric", "forge", "mrpack", "bukkit", "waterfall"] + .iter() + .map(|s| s.to_string()) + .collect() + ); + assert_eq!(loader_metadata["fabric"], None); + assert_eq!(loader_metadata["bukkit"], Some(false)); + assert_eq!(loader_metadata["waterfall"], Some(true)); + }, + ) + .await; +} diff --git a/apps/labrinth/tests/teams.rs b/apps/labrinth/tests/teams.rs new file mode 100644 index 000000000..4743ecf4c --- /dev/null +++ b/apps/labrinth/tests/teams.rs @@ -0,0 +1,737 @@ +use crate::common::{api_common::ApiTeams, database::*}; +use actix_http::StatusCode; +use common::{ + api_v3::ApiV3, + environment::{ + with_test_environment, with_test_environment_all, TestEnvironment, + }, +}; +use labrinth::models::teams::{OrganizationPermissions, ProjectPermissions}; +use rust_decimal::Decimal; +use serde_json::json; + +mod common; + +#[actix_rt::test] +async fn test_get_team() { + // Test setup and dummy data + // Perform get_team related tests for a project team + //TODO: This needs to consider organizations now as well + with_test_environment_all(None, |test_env| async move { + let api = &test_env.api; + let alpha_project_id = &test_env.dummy.project_alpha.project_id; + let alpha_team_id = &test_env.dummy.project_alpha.team_id; + + // A non-member of the team should get basic info but not be able to see private data + let members = api + .get_team_members_deserialized_common( + alpha_team_id, + FRIEND_USER_PAT, + ) + .await; + assert_eq!(members.len(), 1); + assert_eq!(members[0].user.id.0, USER_USER_ID_PARSED as u64); + assert!(members[0].permissions.is_none()); + + let members = api + .get_project_members_deserialized_common( + alpha_project_id, + FRIEND_USER_PAT, + ) + .await; + assert_eq!(members.len(), 1); + assert_eq!(members[0].user.id.0, USER_USER_ID_PARSED as u64); + + // A non-accepted member of the team should: + // - not be able to see private data about the team, but see all members including themselves + // - should not appear in the team members list to enemy users + let resp = api + .add_user_to_team( + alpha_team_id, + FRIEND_USER_ID, + None, + None, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Team check directly + let members = api + .get_team_members_deserialized_common( + alpha_team_id, + FRIEND_USER_PAT, + ) + .await; + assert!(members.len() == 2); // USER_USER_ID and FRIEND_USER_ID should be in the team + let user_user = members + .iter() + .find(|x| x.user.id.0 == USER_USER_ID_PARSED as u64) + .unwrap(); + let friend_user = members + .iter() + .find(|x| x.user.id.0 == FRIEND_USER_ID_PARSED as u64) + .unwrap(); + assert_eq!(user_user.user.id.0, USER_USER_ID_PARSED as u64); + assert!(user_user.permissions.is_none()); // Should not see private data of the team + assert_eq!(friend_user.user.id.0, FRIEND_USER_ID_PARSED as u64); + assert!(friend_user.permissions.is_none()); + + // team check via association + let members = api + .get_project_members_deserialized_common( + alpha_project_id, + FRIEND_USER_PAT, + ) + .await; + assert!(members.len() == 2); // USER_USER_ID and FRIEND_USER_ID should be in the team + let user_user = members + .iter() + .find(|x| x.user.id.0 == USER_USER_ID_PARSED as u64) + .unwrap(); + let friend_user = members + .iter() + .find(|x| x.user.id.0 == FRIEND_USER_ID_PARSED as u64) + .unwrap(); + assert_eq!(user_user.user.id.0, USER_USER_ID_PARSED as u64); + assert!(user_user.permissions.is_none()); // Should not see private data of the team + assert_eq!(friend_user.user.id.0, FRIEND_USER_ID_PARSED as u64); + assert!(friend_user.permissions.is_none()); + + // enemy team check directly + let members = api + .get_team_members_deserialized_common(alpha_team_id, ENEMY_USER_PAT) + .await; + assert_eq!(members.len(), 1); // Only USER_USER_ID should be in the team + + // enemy team check via association + let members = api + .get_project_members_deserialized_common( + alpha_project_id, + ENEMY_USER_PAT, + ) + .await; + assert_eq!(members.len(), 1); // Only USER_USER_ID should be in the team + + // An accepted member of the team should appear in the team members list + // and should be able to see private data about the team + let resp = api.join_team(alpha_team_id, FRIEND_USER_PAT).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Team check directly + let members = api + .get_team_members_deserialized_common( + alpha_team_id, + FRIEND_USER_PAT, + ) + .await; + assert!(members.len() == 2); // USER_USER_ID and FRIEND_USER_ID should be in the team + let user_user = members + .iter() + .find(|x| x.user.id.0 == USER_USER_ID_PARSED as u64) + .unwrap(); + let friend_user = members + .iter() + .find(|x| x.user.id.0 == FRIEND_USER_ID_PARSED as u64) + .unwrap(); + assert_eq!(user_user.user.id.0, USER_USER_ID_PARSED as u64); + assert!(user_user.permissions.is_some()); // SHOULD see private data of the team + assert_eq!(friend_user.user.id.0, FRIEND_USER_ID_PARSED as u64); + assert!(friend_user.permissions.is_some()); + + // team check via association + let members = api + .get_project_members_deserialized_common( + alpha_project_id, + FRIEND_USER_PAT, + ) + .await; + assert!(members.len() == 2); // USER_USER_ID and FRIEND_USER_ID should be in the team + let user_user = members + .iter() + .find(|x| x.user.id.0 == USER_USER_ID_PARSED as u64) + .unwrap(); + let friend_user = members + .iter() + .find(|x| x.user.id.0 == FRIEND_USER_ID_PARSED as u64) + .unwrap(); + assert_eq!(user_user.user.id.0, USER_USER_ID_PARSED as u64); + assert!(user_user.permissions.is_some()); // SHOULD see private data of the team + assert_eq!(friend_user.user.id.0, FRIEND_USER_ID_PARSED as u64); + assert!(friend_user.permissions.is_some()); + }) + .await; +} + +#[actix_rt::test] +async fn test_get_team_organization() { + // Test setup and dummy data + // Perform get_team related tests for an organization team + //TODO: This needs to consider users in organizations now and how they perceive as well + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + let zeta_organization_id = + &test_env.dummy.organization_zeta.organization_id; + let zeta_team_id = &test_env.dummy.organization_zeta.team_id; + + // A non-member of the team should get basic info but not be able to see private data + let members = api + .get_team_members_deserialized_common( + zeta_team_id, + FRIEND_USER_PAT, + ) + .await; + assert_eq!(members.len(), 1); + assert_eq!(members[0].user.id.0, USER_USER_ID_PARSED as u64); + assert!(members[0].permissions.is_none()); + + let members = api + .get_organization_members_deserialized_common( + zeta_organization_id, + FRIEND_USER_PAT, + ) + .await; + assert_eq!(members.len(), 1); + assert_eq!(members[0].user.id.0, USER_USER_ID_PARSED as u64); + + // A non-accepted member of the team should: + // - not be able to see private data about the team, but see all members including themselves + // - should not appear in the team members list to enemy users + let resp = api + .add_user_to_team( + zeta_team_id, + FRIEND_USER_ID, + None, + None, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Team check directly + let members = api + .get_team_members_deserialized_common( + zeta_team_id, + FRIEND_USER_PAT, + ) + .await; + assert!(members.len() == 2); // USER_USER_ID and FRIEND_USER_ID should be in the team + let user_user = members + .iter() + .find(|x| x.user.id.0 == USER_USER_ID_PARSED as u64) + .unwrap(); + let friend_user = members + .iter() + .find(|x| x.user.id.0 == FRIEND_USER_ID_PARSED as u64) + .unwrap(); + assert_eq!(user_user.user.id.0, USER_USER_ID_PARSED as u64); + assert!(user_user.permissions.is_none()); // Should not see private data of the team + assert_eq!(friend_user.user.id.0, FRIEND_USER_ID_PARSED as u64); + assert!(friend_user.permissions.is_none()); + + // team check via association + let members = api + .get_organization_members_deserialized_common( + zeta_organization_id, + FRIEND_USER_PAT, + ) + .await; + + assert!(members.len() == 2); // USER_USER_ID and FRIEND_USER_ID should be in the team + let user_user = members + .iter() + .find(|x| x.user.id.0 == USER_USER_ID_PARSED as u64) + .unwrap(); + let friend_user = members + .iter() + .find(|x| x.user.id.0 == FRIEND_USER_ID_PARSED as u64) + .unwrap(); + assert_eq!(user_user.user.id.0, USER_USER_ID_PARSED as u64); + assert!(user_user.permissions.is_none()); // Should not see private data of the team + assert_eq!(friend_user.user.id.0, FRIEND_USER_ID_PARSED as u64); + assert!(friend_user.permissions.is_none()); + + // enemy team check directly + let members = api + .get_team_members_deserialized_common( + zeta_team_id, + ENEMY_USER_PAT, + ) + .await; + assert_eq!(members.len(), 1); // Only USER_USER_ID should be in the team + + // enemy team check via association + let members = api + .get_organization_members_deserialized_common( + zeta_organization_id, + ENEMY_USER_PAT, + ) + .await; + assert_eq!(members.len(), 1); // Only USER_USER_ID should be in the team + + // An accepted member of the team should appear in the team members list + // and should be able to see private data about the team + let resp = api.join_team(zeta_team_id, FRIEND_USER_PAT).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Team check directly + let members = api + .get_team_members_deserialized_common( + zeta_team_id, + FRIEND_USER_PAT, + ) + .await; + assert!(members.len() == 2); // USER_USER_ID and FRIEND_USER_ID should be in the team + let user_user = members + .iter() + .find(|x| x.user.id.0 == USER_USER_ID_PARSED as u64) + .unwrap(); + let friend_user = members + .iter() + .find(|x| x.user.id.0 == FRIEND_USER_ID_PARSED as u64) + .unwrap(); + assert_eq!(user_user.user.id.0, USER_USER_ID_PARSED as u64); + assert!(user_user.permissions.is_some()); // SHOULD see private data of the team + assert_eq!(friend_user.user.id.0, FRIEND_USER_ID_PARSED as u64); + assert!(friend_user.permissions.is_some()); + + // team check via association + let members = api + .get_organization_members_deserialized_common( + zeta_organization_id, + FRIEND_USER_PAT, + ) + .await; + assert!(members.len() == 2); // USER_USER_ID and FRIEND_USER_ID should be in the team + let user_user = members + .iter() + .find(|x| x.user.id.0 == USER_USER_ID_PARSED as u64) + .unwrap(); + let friend_user = members + .iter() + .find(|x| x.user.id.0 == FRIEND_USER_ID_PARSED as u64) + .unwrap(); + assert_eq!(user_user.user.id.0, USER_USER_ID_PARSED as u64); + assert!(user_user.permissions.is_some()); // SHOULD see private data of the team + assert_eq!(friend_user.user.id.0, FRIEND_USER_ID_PARSED as u64); + assert!(friend_user.permissions.is_some()); + }, + ) + .await; +} + +#[actix_rt::test] +async fn test_get_team_project_orgs() { + // Test setup and dummy data + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let alpha_project_id = &test_env.dummy.project_alpha.project_id; + let alpha_team_id = &test_env.dummy.project_alpha.team_id; + let zeta_organization_id = + &test_env.dummy.organization_zeta.organization_id; + let zeta_team_id = &test_env.dummy.organization_zeta.team_id; + + // Attach alpha to zeta + let resp = test_env + .api + .organization_add_project( + zeta_organization_id, + alpha_project_id, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::OK); + + // Invite and add friend to zeta + let resp = test_env + .api + .add_user_to_team( + zeta_team_id, + FRIEND_USER_ID, + None, + None, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let resp = + test_env.api.join_team(zeta_team_id, FRIEND_USER_PAT).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // The team members route from teams (on a project's team): + // - the members of the project team specifically + // - not the ones from the organization + // - Remember: the owner of an org will not be included in the org's team members list + let members = test_env + .api + .get_team_members_deserialized_common( + alpha_team_id, + FRIEND_USER_PAT, + ) + .await; + assert_eq!(members.len(), 0); + + // The team members route from project should show the same! + let members = test_env + .api + .get_project_members_deserialized_common( + alpha_project_id, + FRIEND_USER_PAT, + ) + .await; + assert_eq!(members.len(), 0); + }, + ) + .await; +} + +// edit team member (Varying permissions, varying roles) +#[actix_rt::test] +async fn test_patch_project_team_member() { + // Test setup and dummy data + with_test_environment_all(None, |test_env| async move { + let api = &test_env.api; + + let alpha_team_id = &test_env.dummy.project_alpha.team_id; + + // Edit team as admin/mod but not a part of the team should be StatusCode::OK + let resp = api.edit_team_member(alpha_team_id, USER_USER_ID, json!({}), ADMIN_USER_PAT).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // As a non-owner with full permissions, attempt to edit the owner's permissions + let resp = api.edit_team_member(alpha_team_id, USER_USER_ID, json!({ + "permissions": 0 + }), ADMIN_USER_PAT).await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // Should not be able to edit organization permissions of a project team + let resp = api.edit_team_member(alpha_team_id, USER_USER_ID, json!({ + "organization_permissions": 0 + }), USER_USER_PAT).await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // Should not be able to add permissions to a user that the adding-user does not have + // (true for both project and org) + + // first, invite friend + let resp = api.add_user_to_team(alpha_team_id, FRIEND_USER_ID, + Some(ProjectPermissions::EDIT_MEMBER | ProjectPermissions::EDIT_BODY), + None, USER_USER_PAT).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // accept + let resp = api.join_team(alpha_team_id, FRIEND_USER_PAT).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // try to add permissions + let resp = api.edit_team_member(alpha_team_id, FRIEND_USER_ID, json!({ + "permissions": (ProjectPermissions::EDIT_MEMBER | ProjectPermissions::EDIT_DETAILS).bits() + }), FRIEND_USER_PAT).await; // should this be friend_user_pat + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // Cannot set payouts outside of 0 and 5000 + for payout in [-1, 5001] { + let resp = api.edit_team_member(alpha_team_id, FRIEND_USER_ID, json!({ + "payouts_split": payout + }), USER_USER_PAT).await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + } + + // Successful patch + let resp = api.edit_team_member(alpha_team_id, FRIEND_USER_ID, json!({ + "payouts_split": 51, + "permissions": ProjectPermissions::EDIT_MEMBER.bits(), // reduces permissions + "role": "membe2r", + "ordering": 5 + }), FRIEND_USER_PAT).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Check results + let members = api.get_team_members_deserialized_common(alpha_team_id, FRIEND_USER_PAT).await; + let member = members.iter().find(|x| x.user.id.0 == FRIEND_USER_ID_PARSED as u64).unwrap(); + assert_eq!(member.payouts_split, Decimal::from_f64_retain(51.0)); + assert_eq!(member.permissions.unwrap(), ProjectPermissions::EDIT_MEMBER); + assert_eq!(member.role, "membe2r"); + assert_eq!(member.ordering, 5); + }).await; +} + +// edit team member (Varying permissions, varying roles) +#[actix_rt::test] +async fn test_patch_organization_team_member() { + // Test setup and dummy data + with_test_environment(None, |test_env: TestEnvironment| async move { + let zeta_team_id = &test_env.dummy.organization_zeta.team_id; + + // Edit team as admin/mod but not a part of the team should be StatusCode::OK + let resp = test_env + .api + .edit_team_member(zeta_team_id, USER_USER_ID, json!({}), ADMIN_USER_PAT) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // As a non-owner with full permissions, attempt to edit the owner's permissions + let resp = test_env + .api + .edit_team_member(zeta_team_id, USER_USER_ID, json!({ "permissions": 0 }), ADMIN_USER_PAT) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // Should not be able to add permissions to a user that the adding-user does not have + // (true for both project and org) + + // first, invite friend + let resp = test_env + .api + .add_user_to_team(zeta_team_id, FRIEND_USER_ID, None, Some(OrganizationPermissions::EDIT_MEMBER | OrganizationPermissions::EDIT_MEMBER_DEFAULT_PERMISSIONS), USER_USER_PAT) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // accept + let resp = test_env.api.join_team(zeta_team_id, FRIEND_USER_PAT).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // try to add permissions- fails, as we do not have EDIT_DETAILS + let resp = test_env + .api + .edit_team_member(zeta_team_id, FRIEND_USER_ID, json!({ "organization_permissions": (OrganizationPermissions::EDIT_MEMBER | OrganizationPermissions::EDIT_DETAILS).bits() }), FRIEND_USER_PAT) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // Cannot set payouts outside of 0 and 5000 + for payout in [-1, 5001] { + let resp = test_env + .api + .edit_team_member(zeta_team_id, FRIEND_USER_ID, json!({ "payouts_split": payout }), USER_USER_PAT) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + } + + // Successful patch + let resp = test_env + .api + .edit_team_member( + zeta_team_id, + FRIEND_USER_ID, + json!({ + "payouts_split": 51, + "organization_permissions": OrganizationPermissions::EDIT_MEMBER.bits(), // reduces permissions + "permissions": (ProjectPermissions::EDIT_MEMBER).bits(), + "role": "very-cool-member", + "ordering": 5 + }), + FRIEND_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Check results + let members = test_env + .api + .get_team_members_deserialized(zeta_team_id, FRIEND_USER_PAT) + .await; + let member = members + .iter() + .find(|x| x.user.id.0 == FRIEND_USER_ID_PARSED as u64) + .unwrap(); + assert_eq!(member.payouts_split.unwrap(), Decimal::from_f64_retain(51.0_f64).unwrap()); + assert_eq!( + member.organization_permissions, + Some(OrganizationPermissions::EDIT_MEMBER) + ); + assert_eq!( + member.permissions, + Some(ProjectPermissions::EDIT_MEMBER) + ); + assert_eq!(member.role, "very-cool-member"); + assert_eq!(member.ordering, 5); + + }).await; +} + +// trasnfer ownership (requires being owner, etc) +#[actix_rt::test] +async fn transfer_ownership_v3() { + // Test setup and dummy data + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + + let alpha_team_id = &test_env.dummy.project_alpha.team_id; + + // Cannot set friend as owner (not a member) + let resp = api + .transfer_team_ownership( + alpha_team_id, + FRIEND_USER_ID, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + let resp = api + .transfer_team_ownership( + alpha_team_id, + FRIEND_USER_ID, + FRIEND_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::UNAUTHORIZED); + + // first, invite friend + let resp = api + .add_user_to_team( + alpha_team_id, + FRIEND_USER_ID, + None, + None, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // still cannot set friend as owner (not accepted) + let resp = api + .transfer_team_ownership( + alpha_team_id, + FRIEND_USER_ID, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // accept + let resp = api.join_team(alpha_team_id, FRIEND_USER_PAT).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Cannot set ourselves as owner if we are not owner + let resp = api + .transfer_team_ownership( + alpha_team_id, + FRIEND_USER_ID, + FRIEND_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::UNAUTHORIZED); + + // Can set friend as owner + let resp = api + .transfer_team_ownership( + alpha_team_id, + FRIEND_USER_ID, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Check + let members = api + .get_team_members_deserialized(alpha_team_id, USER_USER_PAT) + .await; + let friend_member = members + .iter() + .find(|x| x.user.id.0 == FRIEND_USER_ID_PARSED as u64) + .unwrap(); + assert_eq!(friend_member.role, "Member"); // her role does not actually change, but is_owner is set to true + assert!(friend_member.is_owner); + assert_eq!( + friend_member.permissions.unwrap(), + ProjectPermissions::all() + ); + + let user_member = members + .iter() + .find(|x| x.user.id.0 == USER_USER_ID_PARSED as u64) + .unwrap(); + assert_eq!(user_member.role, "Member"); // We are the 'owner', but we are not actually the owner! + assert!(!user_member.is_owner); + assert_eq!( + user_member.permissions.unwrap(), + ProjectPermissions::all() + ); + + // Confirm that user, a user who still has full permissions, cannot then remove the owner + let resp = api + .remove_from_team(alpha_team_id, FRIEND_USER_ID, USER_USER_PAT) + .await; + assert_status!(&resp, StatusCode::UNAUTHORIZED); + + // V3 only- confirm the owner can change their role without losing ownership + let resp = api + .edit_team_member( + alpha_team_id, + FRIEND_USER_ID, + json!({ + "role": "Member" + }), + FRIEND_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let members = api + .get_team_members_deserialized(alpha_team_id, USER_USER_PAT) + .await; + let friend_member = members + .iter() + .find(|x| x.user.id.0 == FRIEND_USER_ID_PARSED as u64) + .unwrap(); + assert_eq!(friend_member.role, "Member"); + assert!(friend_member.is_owner); + }, + ) + .await; +} + +// This test is currently not working. +// #[actix_rt::test] +// pub async fn no_acceptance_permissions() { +// // Adding a user to a project team in an organization, when that user is in the organization but not the team, +// // should have those permissions apply regardless of whether the user has accepted the invite or not. + +// // This is because project-team permission overrriding must be possible, and this overriding can decrease the number of permissions a user has. + +// let test_env = TestEnvironment::build(None).await; +// let api = &test_env.api; + +// let alpha_team_id = &test_env.dummy.project_alpha.team_id; +// let alpha_project_id = &test_env.dummy.project_alpha.project_id; +// let zeta_organization_id = &test_env.dummy.zeta_organization_id; +// let zeta_team_id = &test_env.dummy.zeta_team_id; + +// // Link alpha team to zeta org +// let resp = api.organization_add_project(zeta_organization_id, alpha_project_id, USER_USER_PAT).await; +// assert_status!(&resp, StatusCode::OK); + +// // Invite friend to zeta team with all project default permissions +// let resp = api.add_user_to_team(&zeta_team_id, FRIEND_USER_ID, Some(ProjectPermissions::all()), Some(OrganizationPermissions::all()), USER_USER_PAT).await; +// assert_status!(&resp, StatusCode::NO_CONTENT); + +// // Accept invite to zeta team +// let resp = api.join_team(&zeta_team_id, FRIEND_USER_PAT).await; +// assert_status!(&resp, StatusCode::NO_CONTENT); + +// // Attempt, as friend, to edit details of alpha project (should succeed, org invite accepted) +// let resp = api.edit_project(alpha_project_id, json!({ +// "title": "new name" +// }), FRIEND_USER_PAT).await; +// assert_status!(&resp, StatusCode::NO_CONTENT); + +// // Invite friend to alpha team with *no* project permissions +// let resp = api.add_user_to_team(&alpha_team_id, FRIEND_USER_ID, Some(ProjectPermissions::empty()), None, USER_USER_PAT).await; +// assert_status!(&resp, StatusCode::NO_CONTENT); + +// // Do not accept invite to alpha team + +// // Attempt, as friend, to edit details of alpha project (should fail now, even though user has not accepted invite) +// let resp = api.edit_project(alpha_project_id, json!({ +// "title": "new name" +// }), FRIEND_USER_PAT).await; +// assert_status!(&resp, StatusCode::UNAUTHORIZED); + +// test_env.cleanup().await; +// } diff --git a/apps/labrinth/tests/user.rs b/apps/labrinth/tests/user.rs new file mode 100644 index 000000000..b1b7bfd01 --- /dev/null +++ b/apps/labrinth/tests/user.rs @@ -0,0 +1,139 @@ +use crate::common::api_common::{ApiProject, ApiTeams}; +use common::dummy_data::TestFile; +use common::{ + database::{FRIEND_USER_ID, FRIEND_USER_PAT, USER_USER_ID, USER_USER_PAT}, + environment::with_test_environment_all, +}; + +mod common; + +// user GET (permissions, different users) +// users GET +// user auth +// user projects get +// user collections get +// patch user +// patch user icon +// user follows + +#[actix_rt::test] +pub async fn get_user_projects_after_creating_project_returns_new_project() { + with_test_environment_all(None, |test_env| async move { + let api = test_env.api; + api.get_user_projects_deserialized_common(USER_USER_ID, USER_USER_PAT) + .await; + + let (project, _) = api + .add_public_project( + "slug", + Some(TestFile::BasicMod), + None, + USER_USER_PAT, + ) + .await; + + let resp_projects = api + .get_user_projects_deserialized_common(USER_USER_ID, USER_USER_PAT) + .await; + assert!(resp_projects.iter().any(|p| p.id == project.id)); + }) + .await; +} + +#[actix_rt::test] +pub async fn get_user_projects_after_deleting_project_shows_removal() { + with_test_environment_all(None, |test_env| async move { + let api = test_env.api; + let (project, _) = api + .add_public_project( + "iota", + Some(TestFile::BasicMod), + None, + USER_USER_PAT, + ) + .await; + api.get_user_projects_deserialized_common(USER_USER_ID, USER_USER_PAT) + .await; + + api.remove_project(project.slug.as_ref().unwrap(), USER_USER_PAT) + .await; + + let resp_projects = api + .get_user_projects_deserialized_common(USER_USER_ID, USER_USER_PAT) + .await; + assert!(!resp_projects.iter().any(|p| p.id == project.id)); + }) + .await; +} + +#[actix_rt::test] +pub async fn get_user_projects_after_joining_team_shows_team_projects() { + with_test_environment_all(None, |test_env| async move { + let alpha_team_id = &test_env.dummy.project_alpha.team_id; + let alpha_project_id = &test_env.dummy.project_alpha.project_id; + let api = test_env.api; + api.get_user_projects_deserialized_common( + FRIEND_USER_ID, + FRIEND_USER_PAT, + ) + .await; + + api.add_user_to_team( + alpha_team_id, + FRIEND_USER_ID, + None, + None, + USER_USER_PAT, + ) + .await; + api.join_team(alpha_team_id, FRIEND_USER_PAT).await; + + let projects = api + .get_user_projects_deserialized_common( + FRIEND_USER_ID, + FRIEND_USER_PAT, + ) + .await; + assert!(projects + .iter() + .any(|p| p.id.to_string() == *alpha_project_id)); + }) + .await; +} + +#[actix_rt::test] +pub async fn get_user_projects_after_leaving_team_shows_no_team_projects() { + with_test_environment_all(None, |test_env| async move { + let alpha_team_id = &test_env.dummy.project_alpha.team_id; + let alpha_project_id = &test_env.dummy.project_alpha.project_id; + let api = test_env.api; + api.add_user_to_team( + alpha_team_id, + FRIEND_USER_ID, + None, + None, + USER_USER_PAT, + ) + .await; + api.join_team(alpha_team_id, FRIEND_USER_PAT).await; + api.get_user_projects_deserialized_common( + FRIEND_USER_ID, + FRIEND_USER_PAT, + ) + .await; + + api.remove_from_team(alpha_team_id, FRIEND_USER_ID, USER_USER_PAT) + .await; + + let projects = api + .get_user_projects_deserialized_common( + FRIEND_USER_ID, + FRIEND_USER_PAT, + ) + .await; + assert!(!projects + .iter() + .any(|p| p.id.to_string() == *alpha_project_id)); + }) + .await; +} diff --git a/apps/labrinth/tests/v2/error.rs b/apps/labrinth/tests/v2/error.rs new file mode 100644 index 000000000..1ae56a719 --- /dev/null +++ b/apps/labrinth/tests/v2/error.rs @@ -0,0 +1,28 @@ +use crate::assert_status; +use crate::common::api_common::ApiProject; + +use actix_http::StatusCode; +use actix_web::test; +use bytes::Bytes; + +use crate::common::database::USER_USER_PAT; +use crate::common::{ + api_v2::ApiV2, + environment::{with_test_environment, TestEnvironment}, +}; +#[actix_rt::test] +pub async fn error_404_empty() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + // V2 errors should have 404 as blank body, for missing resources + let api = &test_env.api; + let resp = api.get_project("does-not-exist", USER_USER_PAT).await; + assert_status!(&resp, StatusCode::NOT_FOUND); + let body = test::read_body(resp).await; + let empty_bytes = Bytes::from_static(b""); + assert_eq!(body, empty_bytes); + }, + ) + .await; +} diff --git a/apps/labrinth/tests/v2/notifications.rs b/apps/labrinth/tests/v2/notifications.rs new file mode 100644 index 000000000..692ae1388 --- /dev/null +++ b/apps/labrinth/tests/v2/notifications.rs @@ -0,0 +1,38 @@ +use crate::common::{ + api_common::ApiTeams, + api_v2::ApiV2, + database::{FRIEND_USER_ID, FRIEND_USER_PAT, USER_USER_PAT}, + environment::{with_test_environment, TestEnvironment}, +}; + +#[actix_rt::test] +pub async fn get_user_notifications_after_team_invitation_returns_notification() +{ + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let alpha_team_id = test_env.dummy.project_alpha.team_id.clone(); + let api = test_env.api; + api.add_user_to_team( + &alpha_team_id, + FRIEND_USER_ID, + None, + None, + USER_USER_PAT, + ) + .await; + + let notifications = api + .get_user_notifications_deserialized( + FRIEND_USER_ID, + FRIEND_USER_PAT, + ) + .await; + assert_eq!(1, notifications.len()); + + // Check to make sure type_ is correct + assert_eq!(notifications[0].type_.as_ref().unwrap(), "team_invite"); + }, + ) + .await; +} diff --git a/apps/labrinth/tests/v2/project.rs b/apps/labrinth/tests/v2/project.rs new file mode 100644 index 000000000..5e9006af7 --- /dev/null +++ b/apps/labrinth/tests/v2/project.rs @@ -0,0 +1,699 @@ +use std::sync::Arc; + +use crate::{ + assert_status, + common::{ + api_common::{ApiProject, ApiVersion, AppendsOptionalPat}, + api_v2::{request_data::get_public_project_creation_data_json, ApiV2}, + database::{ + generate_random_name, ADMIN_USER_PAT, FRIEND_USER_ID, + FRIEND_USER_PAT, USER_USER_PAT, + }, + dummy_data::TestFile, + environment::{with_test_environment, TestEnvironment}, + permissions::{PermissionsTest, PermissionsTestContext}, + }, +}; +use actix_http::StatusCode; +use actix_web::test; +use futures::StreamExt; +use itertools::Itertools; +use labrinth::{ + database::models::project_item::PROJECTS_SLUGS_NAMESPACE, + models::{ + ids::base62_impl::parse_base62, projects::ProjectId, + teams::ProjectPermissions, + }, + util::actix::{AppendsMultipart, MultipartSegment, MultipartSegmentData}, +}; +use serde_json::json; + +#[actix_rt::test] +async fn test_project_type_sanity() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + + // Perform all other patch tests on both 'mod' and 'modpack' + for (mod_or_modpack, slug, file) in [ + ("mod", "test-mod", TestFile::build_random_jar()), + ("modpack", "test-modpack", TestFile::build_random_mrpack()), + ] { + // Create a modpack or mod + // both are 'fabric' (but modpack is actually 'mrpack' behind the scenes, through v3,with fabric as a 'mrpack_loader') + let (test_project, test_version) = api + .add_public_project(slug, Some(file), None, USER_USER_PAT) + .await; + let test_project_slug = test_project.slug.as_ref().unwrap(); + + // Check that the loader displays correctly as fabric from the version creation + assert_eq!(test_project.loaders, vec!["fabric"]); + assert_eq!(test_version[0].loaders, vec!["fabric"]); + + // Check that the project type is correct when getting the project + let project = api + .get_project_deserialized(test_project_slug, USER_USER_PAT) + .await; + assert_eq!(test_project.loaders, vec!["fabric"]); + assert_eq!(project.project_type, mod_or_modpack); + + // Check that the project type is correct when getting the version + let version = api + .get_version_deserialized( + &test_version[0].id.to_string(), + USER_USER_PAT, + ) + .await; + assert_eq!( + version.loaders.iter().map(|x| &x.0).collect_vec(), + vec!["fabric"] + ); + + // Edit the version loader to change it to 'forge' + let resp = api + .edit_version( + &test_version[0].id.to_string(), + json!({ + "loaders": ["forge"], + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Check that the project type is still correct when getting the project + let project = api + .get_project_deserialized(test_project_slug, USER_USER_PAT) + .await; + assert_eq!(project.project_type, mod_or_modpack); + assert_eq!(project.loaders, vec!["forge"]); + + // Check that the project type is still correct when getting the version + let version = api + .get_version_deserialized( + &test_version[0].id.to_string(), + USER_USER_PAT, + ) + .await; + assert_eq!( + version.loaders.iter().map(|x| &x.0).collect_vec(), + vec!["forge"] + ); + } + + // As we get more complicated strucures with as v3 continues to expand, and alpha/beta get more complicated, we should add more tests here, + // to ensure that projects created with v3 routes are still valid and work with v3 routes. + }, + ) + .await; +} + +#[actix_rt::test] +async fn test_add_remove_project() { + // Test setup and dummy data + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + + // Generate test project data. + let mut json_data = get_public_project_creation_data_json( + "demo", + Some(&TestFile::BasicMod), + ); + + // Basic json + let json_segment = MultipartSegment { + name: "data".to_string(), + filename: None, + content_type: Some("application/json".to_string()), + data: MultipartSegmentData::Text( + serde_json::to_string(&json_data).unwrap(), + ), + }; + + // Basic json, with a different file + json_data["initial_versions"][0]["file_parts"][0] = + json!("basic-mod-different.jar"); + let json_diff_file_segment = MultipartSegment { + data: MultipartSegmentData::Text( + serde_json::to_string(&json_data).unwrap(), + ), + ..json_segment.clone() + }; + + // Basic json, with a different file, and a different slug + json_data["slug"] = json!("new_demo"); + json_data["initial_versions"][0]["file_parts"][0] = + json!("basic-mod-different.jar"); + let json_diff_slug_file_segment = MultipartSegment { + data: MultipartSegmentData::Text( + serde_json::to_string(&json_data).unwrap(), + ), + ..json_segment.clone() + }; + + let basic_mod_file = TestFile::BasicMod; + let basic_mod_different_file = TestFile::BasicModDifferent; + + // Basic file + let file_segment = MultipartSegment { + // 'Basic' + name: basic_mod_file.filename(), + filename: Some(basic_mod_file.filename()), + content_type: basic_mod_file.content_type(), + data: MultipartSegmentData::Binary(basic_mod_file.bytes()), + }; + + // Differently named file, with the SAME content (for hash testing) + let file_diff_name_segment = MultipartSegment { + // 'Different' + name: basic_mod_different_file.filename(), + filename: Some(basic_mod_different_file.filename()), + content_type: basic_mod_different_file.content_type(), + // 'Basic' + data: MultipartSegmentData::Binary(basic_mod_file.bytes()), + }; + + // Differently named file, with different content + let file_diff_name_content_segment = MultipartSegment { + // 'Different' + name: basic_mod_different_file.filename(), + filename: Some(basic_mod_different_file.filename()), + content_type: basic_mod_different_file.content_type(), + data: MultipartSegmentData::Binary( + basic_mod_different_file.bytes(), + ), + }; + + // Add a project- simple, should work. + let req = test::TestRequest::post() + .uri("/v2/project") + .append_pat(USER_USER_PAT) + .set_multipart(vec![json_segment.clone(), file_segment.clone()]) + .to_request(); + let resp: actix_web::dev::ServiceResponse = + test_env.call(req).await; + assert_status!(&resp, StatusCode::OK); + + // Get the project we just made, and confirm that it's correct + let project = + api.get_project_deserialized("demo", USER_USER_PAT).await; + assert!(project.versions.len() == 1); + let uploaded_version_id = project.versions[0]; + + // Checks files to ensure they were uploaded and correctly identify the file + let hash = sha1::Sha1::from(basic_mod_file.bytes()) + .digest() + .to_string(); + let version = api + .get_version_from_hash_deserialized( + &hash, + "sha1", + USER_USER_PAT, + ) + .await; + assert_eq!(version.id, uploaded_version_id); + + // Reusing with a different slug and the same file should fail + // Even if that file is named differently + let req = test::TestRequest::post() + .uri("/v2/project") + .append_pat(USER_USER_PAT) + .set_multipart(vec![ + json_diff_slug_file_segment.clone(), // Different slug, different file name + file_diff_name_segment.clone(), // Different file name, same content + ]) + .to_request(); + + let resp = test_env.call(req).await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // Reusing with the same slug and a different file should fail + let req = test::TestRequest::post() + .uri("/v2/project") + .append_pat(USER_USER_PAT) + .set_multipart(vec![ + json_diff_file_segment.clone(), // Same slug, different file name + file_diff_name_content_segment.clone(), // Different file name, different content + ]) + .to_request(); + + let resp = test_env.call(req).await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // Different slug, different file should succeed + let req = test::TestRequest::post() + .uri("/v2/project") + .append_pat(USER_USER_PAT) + .set_multipart(vec![ + json_diff_slug_file_segment.clone(), // Different slug, different file name + file_diff_name_content_segment.clone(), // Different file name, same content + ]) + .to_request(); + + let resp = test_env.call(req).await; + assert_status!(&resp, StatusCode::OK); + + // Get + let project = + api.get_project_deserialized("demo", USER_USER_PAT).await; + let id = project.id.to_string(); + + // Remove the project + let resp = test_env.api.remove_project("demo", USER_USER_PAT).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Confirm that the project is gone from the cache + let mut redis_conn = + test_env.db.redis_pool.connect().await.unwrap(); + assert_eq!( + redis_conn + .get(PROJECTS_SLUGS_NAMESPACE, "demo") + .await + .unwrap() + .map(|x| x.parse::().unwrap()), + None + ); + assert_eq!( + redis_conn + .get(PROJECTS_SLUGS_NAMESPACE, &id) + .await + .unwrap() + .map(|x| x.parse::().unwrap()), + None + ); + + // Old slug no longer works + let resp = api.get_project("demo", USER_USER_PAT).await; + assert_status!(&resp, StatusCode::NOT_FOUND); + }, + ) + .await; +} + +#[actix_rt::test] +async fn permissions_upload_version() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let alpha_project_id = &test_env.dummy.project_alpha.project_id; + let alpha_version_id = &test_env.dummy.project_alpha.version_id; + let alpha_team_id = &test_env.dummy.project_alpha.team_id; + let alpha_file_hash = &test_env.dummy.project_alpha.file_hash; + + let api = &test_env.api; + let basic_mod_different_file = TestFile::BasicModDifferent; + let upload_version = ProjectPermissions::UPLOAD_VERSION; + + let req_gen = |ctx: PermissionsTestContext| async move { + let project_id = ctx.project_id.unwrap(); + let project_id = ProjectId(parse_base62(&project_id).unwrap()); + api.add_public_version( + project_id, + "1.0.0", + TestFile::BasicMod, + None, + None, + ctx.test_pat.as_deref(), + ) + .await + }; + + PermissionsTest::new(&test_env) + .simple_project_permissions_test(upload_version, req_gen) + .await + .unwrap(); + + // Upload file to existing version + // Uses alpha project, as it has an existing version + let file_ref = Arc::new(basic_mod_different_file); + let req_gen = |ctx: PermissionsTestContext| { + let file_ref = file_ref.clone(); + async move { + api.upload_file_to_version( + alpha_version_id, + &file_ref, + ctx.test_pat.as_deref(), + ) + .await + } + }; + PermissionsTest::new(&test_env) + .with_existing_project(alpha_project_id, alpha_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_project_permissions_test(upload_version, req_gen) + .await + .unwrap(); + + // Patch version + // Uses alpha project, as it has an existing version + let req_gen = |ctx: PermissionsTestContext| async move { + api.edit_version( + alpha_version_id, + json!({ + "name": "Basic Mod", + }), + ctx.test_pat.as_deref(), + ) + .await + }; + + PermissionsTest::new(&test_env) + .with_existing_project(alpha_project_id, alpha_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_project_permissions_test(upload_version, req_gen) + .await + .unwrap(); + + // Delete version file + // Uses alpha project, as it has an existing version + let delete_version = ProjectPermissions::DELETE_VERSION; + let req_gen = |ctx: PermissionsTestContext| async move { + api.remove_version_file( + alpha_file_hash, + ctx.test_pat.as_deref(), + ) + .await + }; + + PermissionsTest::new(&test_env) + .with_existing_project(alpha_project_id, alpha_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_project_permissions_test(delete_version, req_gen) + .await + .unwrap(); + + // Delete version + // Uses alpha project, as it has an existing version + let req_gen = |ctx: PermissionsTestContext| async move { + api.remove_version(alpha_version_id, ctx.test_pat.as_deref()) + .await + }; + PermissionsTest::new(&test_env) + .with_existing_project(alpha_project_id, alpha_team_id) + .with_user(FRIEND_USER_ID, FRIEND_USER_PAT, true) + .simple_project_permissions_test(delete_version, req_gen) + .await + .unwrap(); + }, + ) + .await; +} + +#[actix_rt::test] +pub async fn test_patch_v2() { + // Hits V3-specific patchable fields + // Other fields are tested in test_patch_project (the v2 version of that test) + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + + let alpha_project_slug = &test_env.dummy.project_alpha.project_slug; + + // Sucessful request to patch many fields. + let resp = api + .edit_project( + alpha_project_slug, + json!({ + "client_side": "unsupported", + "server_side": "required", + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let project = api + .get_project_deserialized(alpha_project_slug, USER_USER_PAT) + .await; + + // Note: the original V2 value of this was "optional", + // but Required/Optional is no longer a carried combination in v3, as the changes made were lossy. + // Now, the test Required/Unsupported combination is tested instead. + // Setting Required/Optional in v2 will not work, this is known and accepteed. + assert_eq!(project.client_side.as_str(), "unsupported"); + assert_eq!(project.server_side.as_str(), "required"); + }, + ) + .await; +} + +#[actix_rt::test] +async fn permissions_patch_project_v2() { + with_test_environment( + Some(8), + |test_env: TestEnvironment| async move { + let api = &test_env.api; + + // For each permission covered by EDIT_DETAILS, ensure the permission is required + let edit_details = ProjectPermissions::EDIT_DETAILS; + let test_pairs = [ + ("description", json!("description")), + ("issues_url", json!("https://issues.com")), + ("source_url", json!("https://source.com")), + ("wiki_url", json!("https://wiki.com")), + ( + "donation_urls", + json!([{ + "id": "paypal", + "platform": "Paypal", + "url": "https://paypal.com" + }]), + ), + ("discord_url", json!("https://discord.com")), + ]; + + futures::stream::iter(test_pairs) + .map(|(key, value)| { + let test_env = test_env.clone(); + async move { + let req_gen = |ctx: PermissionsTestContext| async { + api.edit_project( + &ctx.project_id.unwrap(), + json!({ + key: if key == "slug" { + json!(generate_random_name("randomslug")) + } else { + value.clone() + }, + }), + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .simple_project_permissions_test( + edit_details, + req_gen, + ) + .await + .into_iter(); + } + }) + .buffer_unordered(4) + .collect::>() + .await; + + // Edit body + // Cannot bulk edit body + let edit_body = ProjectPermissions::EDIT_BODY; + let req_gen = |ctx: PermissionsTestContext| async move { + api.edit_project( + &ctx.project_id.unwrap(), + json!({ + "body": "new body!", // new body + }), + ctx.test_pat.as_deref(), + ) + .await + }; + PermissionsTest::new(&test_env) + .simple_project_permissions_test(edit_body, req_gen) + .await + .unwrap(); + }, + ) + .await; +} + +#[actix_rt::test] +pub async fn test_bulk_edit_links() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + let alpha_project_id: &str = + &test_env.dummy.project_alpha.project_id; + let beta_project_id: &str = &test_env.dummy.project_beta.project_id; + + let resp = api + .edit_project_bulk( + &[alpha_project_id, beta_project_id], + json!({ + "issues_url": "https://github.com", + "donation_urls": [ + { + "id": "patreon", + "platform": "Patreon", + "url": "https://www.patreon.com/my_user" + } + ], + }), + ADMIN_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let alpha_body = api + .get_project_deserialized(alpha_project_id, ADMIN_USER_PAT) + .await; + let donation_urls = alpha_body.donation_urls.unwrap(); + assert_eq!(donation_urls.len(), 1); + assert_eq!(donation_urls[0].url, "https://www.patreon.com/my_user"); + assert_eq!( + alpha_body.issues_url, + Some("https://github.com".to_string()) + ); + assert_eq!(alpha_body.discord_url, None); + + let beta_body = api + .get_project_deserialized(beta_project_id, ADMIN_USER_PAT) + .await; + let donation_urls = beta_body.donation_urls.unwrap(); + assert_eq!(donation_urls.len(), 1); + assert_eq!(donation_urls[0].url, "https://www.patreon.com/my_user"); + assert_eq!( + beta_body.issues_url, + Some("https://github.com".to_string()) + ); + assert_eq!(beta_body.discord_url, None); + + let resp = api + .edit_project_bulk( + &[alpha_project_id, beta_project_id], + json!({ + "discord_url": "https://discord.gg", + "issues_url": null, + "add_donation_urls": [ + { + "id": "bmac", + "platform": "Buy Me a Coffee", + "url": "https://www.buymeacoffee.com/my_user" + } + ], + }), + ADMIN_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let alpha_body = api + .get_project_deserialized(alpha_project_id, ADMIN_USER_PAT) + .await; + let donation_urls = alpha_body + .donation_urls + .unwrap() + .into_iter() + .sorted_by_key(|x| x.id.clone()) + .collect_vec(); + assert_eq!(donation_urls.len(), 2); + assert_eq!( + donation_urls[0].url, + "https://www.buymeacoffee.com/my_user" + ); + assert_eq!(donation_urls[1].url, "https://www.patreon.com/my_user"); + assert_eq!(alpha_body.issues_url, None); + assert_eq!( + alpha_body.discord_url, + Some("https://discord.gg".to_string()) + ); + + let beta_body = api + .get_project_deserialized(beta_project_id, ADMIN_USER_PAT) + .await; + let donation_urls = beta_body + .donation_urls + .unwrap() + .into_iter() + .sorted_by_key(|x| x.id.clone()) + .collect_vec(); + assert_eq!(donation_urls.len(), 2); + assert_eq!( + donation_urls[0].url, + "https://www.buymeacoffee.com/my_user" + ); + assert_eq!(donation_urls[1].url, "https://www.patreon.com/my_user"); + assert_eq!(alpha_body.issues_url, None); + assert_eq!( + alpha_body.discord_url, + Some("https://discord.gg".to_string()) + ); + + let resp = api + .edit_project_bulk( + &[alpha_project_id, beta_project_id], + json!({ + "donation_urls": [ + { + "id": "patreon", + "platform": "Patreon", + "url": "https://www.patreon.com/my_user" + }, + { + "id": "ko-fi", + "platform": "Ko-fi", + "url": "https://www.ko-fi.com/my_user" + } + ], + "add_donation_urls": [ + { + "id": "paypal", + "platform": "PayPal", + "url": "https://www.paypal.com/my_user" + } + ], + "remove_donation_urls": [ + { + "id": "ko-fi", + "platform": "Ko-fi", + "url": "https://www.ko-fi.com/my_user" + } + ], + }), + ADMIN_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let alpha_body = api + .get_project_deserialized(alpha_project_id, ADMIN_USER_PAT) + .await; + let donation_urls = alpha_body + .donation_urls + .unwrap() + .into_iter() + .sorted_by_key(|x| x.id.clone()) + .collect_vec(); + assert_eq!(donation_urls.len(), 2); + assert_eq!(donation_urls[0].url, "https://www.patreon.com/my_user"); + assert_eq!(donation_urls[1].url, "https://www.paypal.com/my_user"); + + let beta_body = api + .get_project_deserialized(beta_project_id, ADMIN_USER_PAT) + .await; + let donation_urls = beta_body + .donation_urls + .unwrap() + .into_iter() + .sorted_by_key(|x| x.id.clone()) + .collect_vec(); + assert_eq!(donation_urls.len(), 2); + assert_eq!(donation_urls[0].url, "https://www.patreon.com/my_user"); + assert_eq!(donation_urls[1].url, "https://www.paypal.com/my_user"); + }, + ) + .await; +} diff --git a/apps/labrinth/tests/v2/scopes.rs b/apps/labrinth/tests/v2/scopes.rs new file mode 100644 index 000000000..be53bc20e --- /dev/null +++ b/apps/labrinth/tests/v2/scopes.rs @@ -0,0 +1,90 @@ +use crate::common::api_common::ApiProject; +use crate::common::api_common::ApiVersion; +use crate::common::api_v2::request_data::get_public_project_creation_data; +use crate::common::api_v2::ApiV2; +use crate::common::dummy_data::TestFile; +use crate::common::environment::with_test_environment; +use crate::common::environment::TestEnvironment; +use crate::common::scopes::ScopeTest; +use labrinth::models::ids::base62_impl::parse_base62; +use labrinth::models::pats::Scopes; +use labrinth::models::projects::ProjectId; + +// Project version creation scopes +#[actix_rt::test] +pub async fn project_version_create_scopes() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + // Create project + let create_project = Scopes::PROJECT_CREATE; + + let req_gen = |pat: Option| async move { + let creation_data = get_public_project_creation_data( + "demo", + Some(TestFile::BasicMod), + None, + ); + api.create_project(creation_data, pat.as_deref()).await + }; + let (_, success) = ScopeTest::new(&test_env) + .test(req_gen, create_project) + .await + .unwrap(); + let project_id = success["id"].as_str().unwrap(); + let project_id = ProjectId(parse_base62(project_id).unwrap()); + + // Add version to project + let create_version = Scopes::VERSION_CREATE; + let req_gen = |pat: Option| async move { + api.add_public_version( + project_id, + "1.2.3.4", + TestFile::BasicModDifferent, + None, + None, + pat.as_deref(), + ) + .await + }; + ScopeTest::new(&test_env) + .test(req_gen, create_version) + .await + .unwrap(); + }, + ) + .await; +} + +#[actix_rt::test] +pub async fn project_version_reads_scopes() { + with_test_environment( + None, + |_test_env: TestEnvironment| async move { + // let api = &test_env.api; + // let beta_file_hash = &test_env.dummy.project_beta.file_hash; + + // let read_version = Scopes::VERSION_READ; + + // Update individual version file + // TODO: This scope currently fails still as the 'version' field of QueryProject only allows public versions. + // TODO: This will be fixed when the 'extracts_versions' PR is merged. + // let req_gen = |pat : Option| async move { + // api.update_individual_files("sha1", vec![ + // FileUpdateData { + // hash: beta_file_hash.clone(), + // loaders: None, + // game_versions: None, + // version_types: None + // } + // ], pat.as_deref()) + // .await + // }; + // let (failure, success) = ScopeTest::new(&test_env).with_failure_code(200).test(req_gen, read_version).await.unwrap(); + // assert!(!failure.as_object().unwrap().contains_key(beta_file_hash)); + // assert!(success.as_object().unwrap().contains_key(beta_file_hash)); + }, + ) + .await; +} diff --git a/apps/labrinth/tests/v2/search.rs b/apps/labrinth/tests/v2/search.rs new file mode 100644 index 000000000..622bbcab1 --- /dev/null +++ b/apps/labrinth/tests/v2/search.rs @@ -0,0 +1,408 @@ +use crate::assert_status; +use crate::common::api_common::Api; +use crate::common::api_common::ApiProject; +use crate::common::api_common::ApiVersion; +use crate::common::api_v2::ApiV2; + +use crate::common::database::*; +use crate::common::dummy_data::TestFile; +use crate::common::dummy_data::DUMMY_CATEGORIES; +use crate::common::environment::with_test_environment; +use crate::common::environment::TestEnvironment; +use actix_http::StatusCode; +use futures::stream::StreamExt; +use labrinth::models::ids::base62_impl::parse_base62; +use serde_json::json; +use std::collections::HashMap; +use std::sync::Arc; + +#[actix_rt::test] +async fn search_projects() { + // Test setup and dummy data + with_test_environment(Some(10), |test_env: TestEnvironment| async move { + let api = &test_env.api; + let test_name = test_env.db.database_name.clone(); + + // Add dummy projects of various categories for searchability + let mut project_creation_futures = vec![]; + + let create_async_future = + |id: u64, + pat: Option<&'static str>, + is_modpack: bool, + modify_json: Option| { + let slug = format!("{test_name}-searchable-project-{id}"); + + let jar = if is_modpack { + TestFile::build_random_mrpack() + } else { + TestFile::build_random_jar() + }; + async move { + // Add a project- simple, should work. + let req = api.add_public_project(&slug, Some(jar), modify_json, pat); + let (project, _) = req.await; + + // Approve, so that the project is searchable + let resp = api + .edit_project( + &project.id.to_string(), + json!({ + "status": "approved" + }), + MOD_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + (project.id.0, id) + } + }; + + // Test project 0 + let id = 0; + let modify_json = serde_json::from_value(json!([ + { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[4..6] }, + { "op": "add", "path": "/server_side", "value": "required" }, + { "op": "add", "path": "/license_id", "value": "LGPL-3.0-or-later" }, + ])) + .unwrap(); + project_creation_futures.push(create_async_future( + id, + USER_USER_PAT, + false, + Some(modify_json), + )); + + // Test project 1 + let id = 1; + let modify_json = serde_json::from_value(json!([ + { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[0..2] }, + { "op": "add", "path": "/client_side", "value": "optional" }, + ])) + .unwrap(); + project_creation_futures.push(create_async_future( + id, + USER_USER_PAT, + false, + Some(modify_json), + )); + + // Test project 2 + let id = 2; + let modify_json = serde_json::from_value(json!([ + { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[0..2] }, + { "op": "add", "path": "/server_side", "value": "required" }, + { "op": "add", "path": "/title", "value": "Mysterious Project" }, + ])) + .unwrap(); + project_creation_futures.push(create_async_future( + id, + USER_USER_PAT, + false, + Some(modify_json), + )); + + // Test project 3 + let id = 3; + let modify_json = serde_json::from_value(json!([ + { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[0..3] }, + { "op": "add", "path": "/server_side", "value": "required" }, + { "op": "add", "path": "/initial_versions/0/game_versions", "value": ["1.20.4"] }, + { "op": "add", "path": "/title", "value": "Mysterious Project" }, + { "op": "add", "path": "/license_id", "value": "LicenseRef-All-Rights-Reserved" }, + ])) + .unwrap(); + project_creation_futures.push(create_async_future( + id, + FRIEND_USER_PAT, + false, + Some(modify_json), + )); + + // Test project 4 + let id = 4; + let modify_json = serde_json::from_value(json!([ + { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[0..3] }, + { "op": "add", "path": "/client_side", "value": "optional" }, + { "op": "add", "path": "/initial_versions/0/game_versions", "value": ["1.20.5"] }, + ])) + .unwrap(); + project_creation_futures.push(create_async_future( + id, + USER_USER_PAT, + true, + Some(modify_json), + )); + + // Test project 5 + let id = 5; + let modify_json = serde_json::from_value(json!([ + { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[5..6] }, + { "op": "add", "path": "/client_side", "value": "optional" }, + { "op": "add", "path": "/initial_versions/0/game_versions", "value": ["1.20.5"] }, + { "op": "add", "path": "/license_id", "value": "LGPL-3.0-or-later" }, + ])) + .unwrap(); + project_creation_futures.push(create_async_future( + id, + USER_USER_PAT, + false, + Some(modify_json), + )); + + // Test project 6 + let id = 6; + let modify_json = serde_json::from_value(json!([ + { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[5..6] }, + { "op": "add", "path": "/client_side", "value": "optional" }, + { "op": "add", "path": "/server_side", "value": "required" }, + { "op": "add", "path": "/license_id", "value": "LGPL-3.0-or-later" }, + ])) + .unwrap(); + project_creation_futures.push(create_async_future( + id, + FRIEND_USER_PAT, + false, + Some(modify_json), + )); + + // Test project 7 (testing the search bug) + // This project has an initial private forge version that is 1.20.2, and a fabric 1.20.1 version. + // This means that a search for fabric + 1.20.1 or forge + 1.20.1 should not return this project, + // but a search for fabric + 1.20.1 should, and it should include both versions in the data. + let id = 7; + let modify_json = serde_json::from_value(json!([ + { "op": "add", "path": "/categories", "value": DUMMY_CATEGORIES[5..6] }, + { "op": "add", "path": "/client_side", "value": "optional" }, + { "op": "add", "path": "/server_side", "value": "required" }, + { "op": "add", "path": "/license_id", "value": "LGPL-3.0-or-later" }, + { "op": "add", "path": "/initial_versions/0/loaders", "value": ["forge"] }, + { "op": "add", "path": "/initial_versions/0/game_versions", "value": ["1.20.2"] }, + ])) + .unwrap(); + project_creation_futures.push(create_async_future( + id, + USER_USER_PAT, + false, + Some(modify_json), + )); + + // Test project 8 + // Server side unsupported + let id = 8; + let modify_json = serde_json::from_value(json!([ + { "op": "add", "path": "/server_side", "value": "unsupported" }, + ])) + .unwrap(); + project_creation_futures.push(create_async_future( + id, + USER_USER_PAT, + false, + Some(modify_json), + )); + + // Await all project creation + // Returns a mapping of: + // project id -> test id + let id_conversion: Arc> = Arc::new( + futures::future::join_all(project_creation_futures) + .await + .into_iter() + .collect(), + ); + + // Create a second version for project 7 + let project_7 = api + .get_project_deserialized(&format!("{test_name}-searchable-project-7"), USER_USER_PAT) + .await; + api.add_public_version( + project_7.id, + "1.0.0", + TestFile::build_random_jar(), + None, + None, + USER_USER_PAT, + ) + .await; + + // Pairs of: + // 1. vec of search facets + // 2. expected project ids to be returned by this search + let pairs = vec![ + ( + json!([["categories:fabric"]]), + vec![0, 1, 2, 3, 4, 5, 6, 7, 8], + ), + (json!([["categories:forge"]]), vec![7]), + ( + json!([["categories:fabric", "categories:forge"]]), + vec![0, 1, 2, 3, 4, 5, 6, 7, 8], + ), + (json!([["categories:fabric"], ["categories:forge"]]), vec![]), + ( + json!([ + ["categories:fabric"], + [&format!("categories:{}", DUMMY_CATEGORIES[0])], + ]), + vec![1, 2, 3, 4], + ), + (json!([["project_types:modpack"]]), vec![4]), + // Formerly included 7, but with v2 changes, this is no longer the case. + // This is because we assume client_side/server_side with subsequent versions. + (json!([["client_side:required"]]), vec![0, 2, 3, 8]), + (json!([["server_side:required"]]), vec![0, 2, 3, 6, 7]), + (json!([["open_source:true"]]), vec![0, 1, 2, 4, 5, 6, 7, 8]), + (json!([["license:MIT"]]), vec![1, 2, 4, 8]), + (json!([[r#"title:'Mysterious Project'"#]]), vec![2, 3]), + (json!([["author:user"]]), vec![0, 1, 2, 4, 5, 7, 8]), + (json!([["versions:1.20.5"]]), vec![4, 5]), + // bug fix + ( + json!([ + // Only the forge one has 1.20.2, so its true that this project 'has' + // 1.20.2 and a fabric version, but not true that it has a 1.20.2 fabric version. + ["categories:fabric"], + ["versions:1.20.2"] + ]), + vec![], + ), + ( + json!([ + // But it does have a 1.20.2 forge version, so this should return it. + ["categories:forge"], + ["versions:1.20.2"] + ]), + vec![7], + ), + // Project type change + // Modpack should still be able to search based on former loader, even though technically the loader is 'mrpack' + // (json!([["categories:mrpack"]]), vec![4]), + // ( + // json!([["categories:mrpack"], ["categories:fabric"]]), + // vec![4], + // ), + ( + json!([ + // ["categories:mrpack"], + ["categories:fabric"], + ["project_type:modpack"] + ]), + vec![4], + ), + ( + json!([["client_side:optional"], ["server_side:optional"]]), + vec![1, 4, 5], + ), + (json!([["server_side:optional"]]), vec![1, 4, 5]), + (json!([["server_side:unsupported"]]), vec![8]), + ]; + + // TODO: Untested: + // - downloads (not varied) + // - color (not varied) + // - created_timestamp (not varied) + // - modified_timestamp (not varied) + + // Forcibly reset the search index + let resp = api.reset_search_index().await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Test searches + let stream = futures::stream::iter(pairs); + stream + .for_each_concurrent(1, |(facets, mut expected_project_ids)| { + let id_conversion = id_conversion.clone(); + let test_name = test_name.clone(); + async move { + let projects = api + .search_deserialized( + Some(&format!("\"&{test_name}\"")), + Some(facets.clone()), + USER_USER_PAT, + ) + .await; + let mut found_project_ids: Vec = projects + .hits + .into_iter() + .map(|p| id_conversion[&parse_base62(&p.project_id).unwrap()]) + .collect(); + expected_project_ids.sort(); + found_project_ids.sort(); + println!("Facets: {:?}", facets); + assert_eq!(found_project_ids, expected_project_ids); + } + }) + .await; + + // A couple additional tests for the search type returned, making sure it is properly translated back + let client_side_required = api + .search_deserialized( + Some(&format!("\"&{test_name}\"")), + Some(json!([["client_side:required"]])), + USER_USER_PAT, + ) + .await; + for hit in client_side_required.hits { + assert_eq!(hit.client_side, "required".to_string()); + } + + let server_side_required = api + .search_deserialized( + Some(&format!("\"&{test_name}\"")), + Some(json!([["server_side:required"]])), + USER_USER_PAT, + ) + .await; + for hit in server_side_required.hits { + assert_eq!(hit.server_side, "required".to_string()); + } + + let client_side_unsupported = api + .search_deserialized( + Some(&format!("\"&{test_name}\"")), + Some(json!([["client_side:unsupported"]])), + USER_USER_PAT, + ) + .await; + for hit in client_side_unsupported.hits { + assert_eq!(hit.client_side, "unsupported".to_string()); + } + + let client_side_optional_server_side_optional = api + .search_deserialized( + Some(&format!("\"&{test_name}\"")), + Some(json!([["client_side:optional"], ["server_side:optional"]])), + USER_USER_PAT, + ) + .await; + for hit in client_side_optional_server_side_optional.hits { + assert_eq!(hit.client_side, "optional".to_string()); + assert_eq!(hit.server_side, "optional".to_string()); + } + + // Ensure game_versions return correctly, but also correctly aggregated + // over all versions of a project + let game_versions = api + .search_deserialized( + Some(&format!("\"&{test_name}\"")), + Some(json!([["categories:forge"], ["versions:1.20.2"]])), + USER_USER_PAT, + ) + .await; + assert_eq!(game_versions.hits.len(), 1); + for hit in game_versions.hits { + assert_eq!( + hit.versions, + vec!["1.20.1".to_string(), "1.20.2".to_string()] + ); + assert!(hit.categories.contains(&"forge".to_string())); + assert!(hit.categories.contains(&"fabric".to_string())); + assert!(hit.display_categories.contains(&"forge".to_string())); + assert!(hit.display_categories.contains(&"fabric".to_string())); + + // Also, ensure author is correctly capitalized + assert_eq!(hit.author, "User".to_string()); + } + }) + .await; +} diff --git a/apps/labrinth/tests/v2/tags.rs b/apps/labrinth/tests/v2/tags.rs new file mode 100644 index 000000000..1171e6502 --- /dev/null +++ b/apps/labrinth/tests/v2/tags.rs @@ -0,0 +1,116 @@ +use itertools::Itertools; +use labrinth::routes::v2::tags::DonationPlatformQueryData; + +use std::collections::HashSet; + +use crate::common::{ + api_v2::ApiV2, + environment::{with_test_environment, TestEnvironment}, +}; + +#[actix_rt::test] +async fn get_tags() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + let game_versions = api.get_game_versions_deserialized().await; + let loaders = api.get_loaders_deserialized().await; + let side_types = api.get_side_types_deserialized().await; + + // These tests match dummy data and will need to be updated if the dummy data changes + // Versions should be ordered by: + // - ordering + // - ordering ties settled by date added to database + // - We also expect presentation of NEWEST to OLDEST + // - All null orderings are treated as older than any non-null ordering + // (for this test, the 1.20.1, etc, versions are all null ordering) + let game_version_versions = game_versions + .into_iter() + .map(|x| x.version) + .collect::>(); + assert_eq!( + game_version_versions, + [ + "Ordering_Negative1", + "Ordering_Positive100", + "1.20.5", + "1.20.4", + "1.20.3", + "1.20.2", + "1.20.1" + ] + .iter() + .map(|s| s.to_string()) + .collect_vec() + ); + + let loader_names = + loaders.into_iter().map(|x| x.name).collect::>(); + assert_eq!( + loader_names, + ["fabric", "forge", "bukkit", "waterfall"] + .iter() + .map(|s| s.to_string()) + .collect() + ); + + let side_type_names = + side_types.into_iter().collect::>(); + assert_eq!( + side_type_names, + ["unknown", "required", "optional", "unsupported"] + .iter() + .map(|s| s.to_string()) + .collect() + ); + }, + ) + .await; +} + +#[actix_rt::test] +async fn get_donation_platforms() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + let mut donation_platforms_unsorted = + api.get_donation_platforms_deserialized().await; + + // These tests match dummy data and will need to be updated if the dummy data changes + let mut included = vec![ + DonationPlatformQueryData { + short: "patreon".to_string(), + name: "Patreon".to_string(), + }, + DonationPlatformQueryData { + short: "ko-fi".to_string(), + name: "Ko-fi".to_string(), + }, + DonationPlatformQueryData { + short: "paypal".to_string(), + name: "PayPal".to_string(), + }, + DonationPlatformQueryData { + short: "bmac".to_string(), + name: "Buy Me A Coffee".to_string(), + }, + DonationPlatformQueryData { + short: "github".to_string(), + name: "GitHub Sponsors".to_string(), + }, + DonationPlatformQueryData { + short: "other".to_string(), + name: "Other".to_string(), + }, + ]; + + included.sort_by(|a, b| a.short.cmp(&b.short)); + donation_platforms_unsorted.sort_by(|a, b| a.short.cmp(&b.short)); + + assert_eq!(donation_platforms_unsorted, included); + }, + ) + .await; +} diff --git a/apps/labrinth/tests/v2/teams.rs b/apps/labrinth/tests/v2/teams.rs new file mode 100644 index 000000000..545b821de --- /dev/null +++ b/apps/labrinth/tests/v2/teams.rs @@ -0,0 +1,138 @@ +use actix_http::StatusCode; +use labrinth::models::teams::ProjectPermissions; +use serde_json::json; + +use crate::{ + assert_status, + common::{ + api_common::ApiTeams, + api_v2::ApiV2, + database::{ + FRIEND_USER_ID, FRIEND_USER_ID_PARSED, FRIEND_USER_PAT, + USER_USER_ID_PARSED, USER_USER_PAT, + }, + environment::{with_test_environment, TestEnvironment}, + }, +}; + +// trasnfer ownership (requires being owner, etc) +#[actix_rt::test] +async fn transfer_ownership_v2() { + // Test setup and dummy data + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + + let alpha_team_id = &test_env.dummy.project_alpha.team_id; + + // Cannot set friend as owner (not a member) + let resp = api + .transfer_team_ownership( + alpha_team_id, + FRIEND_USER_ID, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // first, invite friend + let resp = api + .add_user_to_team( + alpha_team_id, + FRIEND_USER_ID, + None, + None, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // still cannot set friend as owner (not accepted) + let resp = api + .transfer_team_ownership( + alpha_team_id, + FRIEND_USER_ID, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + + // accept + let resp = api.join_team(alpha_team_id, FRIEND_USER_PAT).await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Cannot set ourselves as owner if we are not owner + let resp = api + .transfer_team_ownership( + alpha_team_id, + FRIEND_USER_ID, + FRIEND_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::UNAUTHORIZED); + + // Can set friend as owner + let resp = api + .transfer_team_ownership( + alpha_team_id, + FRIEND_USER_ID, + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + // Check + let members = api + .get_team_members_deserialized(alpha_team_id, USER_USER_PAT) + .await; + let friend_member = members + .iter() + .find(|x| x.user.id.0 == FRIEND_USER_ID_PARSED as u64) + .unwrap(); + assert_eq!(friend_member.role, "Owner"); + assert_eq!( + friend_member.permissions.unwrap(), + ProjectPermissions::all() + ); + + let user_member = members + .iter() + .find(|x| x.user.id.0 == USER_USER_ID_PARSED as u64) + .unwrap(); + assert_eq!(user_member.role, "Member"); + assert_eq!( + user_member.permissions.unwrap(), + ProjectPermissions::all() + ); + + // Confirm that user, a user who still has full permissions, cannot then remove the owner + let resp = api + .remove_from_team(alpha_team_id, FRIEND_USER_ID, USER_USER_PAT) + .await; + assert_status!(&resp, StatusCode::UNAUTHORIZED); + + // V2 only- confirm the owner changing the role to member does nothing + let resp = api + .edit_team_member( + alpha_team_id, + FRIEND_USER_ID, + json!({ + "role": "Member" + }), + FRIEND_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + let members = api + .get_team_members_deserialized(alpha_team_id, USER_USER_PAT) + .await; + let friend_member = members + .iter() + .find(|x| x.user.id.0 == FRIEND_USER_ID_PARSED as u64) + .unwrap(); + assert_eq!(friend_member.role, "Owner"); + }, + ) + .await; +} diff --git a/apps/labrinth/tests/v2/version.rs b/apps/labrinth/tests/v2/version.rs new file mode 100644 index 000000000..b4195bef6 --- /dev/null +++ b/apps/labrinth/tests/v2/version.rs @@ -0,0 +1,570 @@ +use actix_http::StatusCode; +use actix_web::test; +use futures::StreamExt; +use labrinth::models::projects::VersionId; +use labrinth::{ + models::projects::{Loader, VersionStatus, VersionType}, + routes::v2::version_file::FileUpdateData, +}; +use serde_json::json; + +use crate::assert_status; +use crate::common::api_common::{ApiProject, ApiVersion}; +use crate::common::api_v2::ApiV2; + +use crate::common::api_v2::request_data::get_public_project_creation_data; +use crate::common::dummy_data::{DummyProjectAlpha, DummyProjectBeta}; +use crate::common::environment::{with_test_environment, TestEnvironment}; +use crate::common::{ + database::{ENEMY_USER_PAT, USER_USER_PAT}, + dummy_data::TestFile, +}; + +#[actix_rt::test] +pub async fn test_patch_version() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + + let alpha_version_id = &test_env.dummy.project_alpha.version_id; + + // // First, we do some patch requests that should fail. + // // Failure because the user is not authorized. + let resp = api + .edit_version( + alpha_version_id, + json!({ + "name": "test 1", + }), + ENEMY_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::UNAUTHORIZED); + + // Failure because these are illegal requested statuses for a normal user. + for req in ["unknown", "scheduled"] { + let resp = api + .edit_version( + alpha_version_id, + json!({ + "status": req, + // requested status it not set here, but in /schedule + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + } + + // Sucessful request to patch many fields. + let resp = api + .edit_version( + alpha_version_id, + json!({ + "name": "new version name", + "version_number": "1.3.0", + "changelog": "new changelog", + "version_type": "beta", + // // "dependencies": [], TODO: test this + "game_versions": ["1.20.5"], + "loaders": ["forge"], + "featured": false, + // "primary_file": [], TODO: test this + // // "downloads": 0, TODO: moderator exclusive + "status": "draft", + // // "filetypes": ["jar"], TODO: test this + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let version = api + .get_version_deserialized(alpha_version_id, USER_USER_PAT) + .await; + assert_eq!(version.name, "new version name"); + assert_eq!(version.version_number, "1.3.0"); + assert_eq!(version.changelog, "new changelog"); + assert_eq!( + version.version_type, + serde_json::from_str::("\"beta\"").unwrap() + ); + assert_eq!(version.game_versions, vec!["1.20.5"]); + assert_eq!(version.loaders, vec![Loader("forge".to_string())]); + assert!(!version.featured); + assert_eq!(version.status, VersionStatus::from_string("draft")); + + // These ones are checking the v2-v3 rerouting, we eneusre that only 'game_versions' + // works as expected, as well as only 'loaders' + let resp = api + .edit_version( + alpha_version_id, + json!({ + "game_versions": ["1.20.1", "1.20.2", "1.20.4"], + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let version = api + .get_version_deserialized(alpha_version_id, USER_USER_PAT) + .await; + assert_eq!( + version.game_versions, + vec!["1.20.1", "1.20.2", "1.20.4"] + ); + assert_eq!(version.loaders, vec![Loader("forge".to_string())]); // From last patch + + let resp = api + .edit_version( + alpha_version_id, + json!({ + "loaders": ["fabric"], + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let version = api + .get_version_deserialized(alpha_version_id, USER_USER_PAT) + .await; + assert_eq!( + version.game_versions, + vec!["1.20.1", "1.20.2", "1.20.4"] + ); // From last patch + assert_eq!(version.loaders, vec![Loader("fabric".to_string())]); + }, + ) + .await; +} + +#[actix_rt::test] +async fn version_updates() { + // Test setup and dummy data + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + let DummyProjectAlpha { + project_id: alpha_project_id, + project_id_parsed: alpha_project_id_parsed, + version_id: alpha_version_id, + file_hash: alpha_version_hash, + .. + } = &test_env.dummy.project_alpha; + let DummyProjectBeta { + version_id: beta_version_id, + file_hash: beta_version_hash, + .. + } = &test_env.dummy.project_beta; + + // Quick test, using get version from hash + let version = api + .get_version_from_hash_deserialized( + alpha_version_hash, + "sha1", + USER_USER_PAT, + ) + .await; + assert_eq!(&version.id.to_string(), alpha_version_id); + + // Get versions from hash + let versions = api + .get_versions_from_hashes_deserialized( + &[alpha_version_hash.as_str(), beta_version_hash.as_str()], + "sha1", + USER_USER_PAT, + ) + .await; + assert_eq!(versions.len(), 2); + assert_eq!( + &versions[alpha_version_hash].id.to_string(), + alpha_version_id + ); + assert_eq!( + &versions[beta_version_hash].id.to_string(), + beta_version_id + ); + + // When there is only the one version, there should be no updates + let version = api + .get_update_from_hash_deserialized_common( + alpha_version_hash, + "sha1", + None, + None, + None, + USER_USER_PAT, + ) + .await; + assert_eq!(&version.id.to_string(), alpha_version_id); + + let versions = api + .update_files_deserialized_common( + "sha1", + vec![alpha_version_hash.to_string()], + None, + None, + None, + USER_USER_PAT, + ) + .await; + assert_eq!(versions.len(), 1); + assert_eq!( + &versions[alpha_version_hash].id.to_string(), + alpha_version_id + ); + + // Add 3 new versions, 1 before, and 2 after, with differing game_version/version_types/loaders + let mut update_ids = vec![]; + for (version_number, patch_value) in [ + ( + "0.9.9", + json!({ + "game_versions": ["1.20.1"], + }), + ), + ( + "1.5.0", + json!({ + "game_versions": ["1.20.3"], + "loaders": ["fabric"], + }), + ), + ( + "1.5.1", + json!({ + "game_versions": ["1.20.4"], + "loaders": ["forge"], + "version_type": "beta" + }), + ), + ] + .iter() + { + let version = api + .add_public_version_deserialized_common( + *alpha_project_id_parsed, + version_number, + TestFile::build_random_jar(), + None, + None, + USER_USER_PAT, + ) + .await; + update_ids.push(version.id); + + // Patch using json + api.edit_version( + &version.id.to_string(), + patch_value.clone(), + USER_USER_PAT, + ) + .await; + } + + let check_expected = + |game_versions: Option>, + loaders: Option>, + version_types: Option>, + result_id: Option| async move { + let (success, result_id) = match result_id { + Some(id) => (true, id), + None => (false, VersionId(0)), + }; + // get_update_from_hash + let resp = api + .get_update_from_hash( + alpha_version_hash, + "sha1", + loaders.clone(), + game_versions.clone(), + version_types.clone(), + USER_USER_PAT, + ) + .await; + if success { + assert_status!(&resp, StatusCode::OK); + let body: serde_json::Value = + test::read_body_json(resp).await; + let id = body["id"].as_str().unwrap(); + assert_eq!(id, &result_id.to_string()); + } else { + assert_status!(&resp, StatusCode::NOT_FOUND); + } + + // update_files + let versions = api + .update_files_deserialized_common( + "sha1", + vec![alpha_version_hash.to_string()], + loaders.clone(), + game_versions.clone(), + version_types.clone(), + USER_USER_PAT, + ) + .await; + if success { + assert_eq!(versions.len(), 1); + let first = versions.iter().next().unwrap(); + assert_eq!(first.1.id, result_id); + } else { + assert_eq!(versions.len(), 0); + } + + // update_individual_files + let hashes = vec![FileUpdateData { + hash: alpha_version_hash.to_string(), + loaders, + game_versions, + version_types: version_types.map(|v| { + v.into_iter() + .map(|v| { + serde_json::from_str(&format!("\"{v}\"")) + .unwrap() + }) + .collect() + }), + }]; + let versions = api + .update_individual_files_deserialized( + "sha1", + hashes, + USER_USER_PAT, + ) + .await; + if success { + assert_eq!(versions.len(), 1); + let first = versions.iter().next().unwrap(); + assert_eq!(first.1.id, result_id); + } else { + assert_eq!(versions.len(), 0); + } + }; + + let tests = vec![ + check_expected( + Some(vec!["1.20.1".to_string()]), + None, + None, + Some(update_ids[0]), + ), + check_expected( + Some(vec!["1.20.3".to_string()]), + None, + None, + Some(update_ids[1]), + ), + check_expected( + Some(vec!["1.20.4".to_string()]), + None, + None, + Some(update_ids[2]), + ), + // Loader restrictions + check_expected( + None, + Some(vec!["fabric".to_string()]), + None, + Some(update_ids[1]), + ), + check_expected( + None, + Some(vec!["forge".to_string()]), + None, + Some(update_ids[2]), + ), + // Version type restrictions + check_expected( + None, + None, + Some(vec!["release".to_string()]), + Some(update_ids[1]), + ), + check_expected( + None, + None, + Some(vec!["beta".to_string()]), + Some(update_ids[2]), + ), + // Specific combination + check_expected( + None, + Some(vec!["fabric".to_string()]), + Some(vec!["release".to_string()]), + Some(update_ids[1]), + ), + // Impossible combination + check_expected( + None, + Some(vec!["fabric".to_string()]), + Some(vec!["beta".to_string()]), + None, + ), + // No restrictions, should do the last one + check_expected(None, None, None, Some(update_ids[2])), + ]; + + // Wait on all tests, 4 at a time + futures::stream::iter(tests) + .buffer_unordered(4) + .collect::>() + .await; + + // We do a couple small tests for get_project_versions_deserialized as well + // TODO: expand this more. + let versions = api + .get_project_versions_deserialized_common( + alpha_project_id, + None, + None, + None, + None, + None, + None, + USER_USER_PAT, + ) + .await; + assert_eq!(versions.len(), 4); + let versions = api + .get_project_versions_deserialized_common( + alpha_project_id, + None, + Some(vec!["forge".to_string()]), + None, + None, + None, + None, + USER_USER_PAT, + ) + .await; + assert_eq!(versions.len(), 1); + }, + ) + .await; +} + +#[actix_rt::test] +async fn add_version_project_types_v2() { + with_test_environment( + None, + |test_env: TestEnvironment| async move { + // Since v2 no longer keeps project_type at the project level but the version level, + // we have to test that the project_type is set correctly when adding a version, if its done in separate requests. + let api = &test_env.api; + + // Create a project in v2 with project_type = modpack, and no initial version set. + let (test_project, test_versions) = api + .add_public_project("test-modpack", None, None, USER_USER_PAT) + .await; + assert_eq!(test_versions.len(), 0); // No initial version set + + // Get as v2 project + let test_project = api + .get_project_deserialized( + &test_project.slug.unwrap(), + USER_USER_PAT, + ) + .await; + assert_eq!(test_project.project_type, "project"); // No project_type set, as no versions are set + // Default to 'project' if none are found + // This is a known difference between older v2 ,but is acceptable. + // This would be the appropriate test on older v2: + // assert_eq!(test_project.project_type, "modpack"); + + // Create a version with a modpack file attached + let test_version = api + .add_public_version_deserialized_common( + test_project.id, + "1.0.0", + TestFile::build_random_mrpack(), + None, + None, + USER_USER_PAT, + ) + .await; + + // When we get the version as v2, it should display 'fabric' as the loader (and no project_type) + let test_version = api + .get_version_deserialized( + &test_version.id.to_string(), + USER_USER_PAT, + ) + .await; + assert_eq!( + test_version.loaders, + vec![Loader("fabric".to_string())] + ); + + // When we get the project as v2, it should display 'modpack' as the project_type, and 'fabric' as the loader + let test_project = api + .get_project_deserialized( + &test_project.slug.unwrap(), + USER_USER_PAT, + ) + .await; + assert_eq!(test_project.project_type, "modpack"); + assert_eq!(test_project.loaders, vec!["fabric"]); + + // When we get the version as v3, it should display 'mrpack' as the loader, and 'modpack' as the project_type + // When we get the project as v3, it should display 'modpack' as the project_type, and 'mrpack' as the loader + + // The project should be a modpack project + }, + ) + .await; +} + +#[actix_rt::test] +async fn test_incorrect_file_parts() { + // Ensures that a version get that 'should' have mrpack_loaders does still display them + // if the file is 'mrpack' but the file_parts are incorrect + with_test_environment( + None, + |test_env: TestEnvironment| async move { + let api = &test_env.api; + + // Patch to set the file_parts to something incorrect + let patch = json!([{ + "op": "add", + "path": "/file_parts", + "value": ["invalid.zip"] // one file, wrong non-mrpack extension + }]); + + // Create an empty project + let slug = "test-project"; + let creation_data = + get_public_project_creation_data(slug, None, None); + let resp = api.create_project(creation_data, USER_USER_PAT).await; + assert_status!(&resp, StatusCode::OK); + + // Get the project + let project = + api.get_project_deserialized(slug, USER_USER_PAT).await; + assert_eq!(project.project_type, "project"); + + // Create a version with a mrpack file, but incorrect file_parts + let resp = api + .add_public_version( + project.id, + "1.0.0", + TestFile::build_random_mrpack(), + None, + Some(serde_json::from_value(patch).unwrap()), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::OK); + + // Get the project now, which should be now correctly identified as a modpack + let project = + api.get_project_deserialized(slug, USER_USER_PAT).await; + assert_eq!(project.project_type, "modpack"); + assert_eq!(project.loaders, vec!["fabric"]); + }, + ) + .await; +} diff --git a/apps/labrinth/tests/v2_tests.rs b/apps/labrinth/tests/v2_tests.rs new file mode 100644 index 000000000..808bcb1bd --- /dev/null +++ b/apps/labrinth/tests/v2_tests.rs @@ -0,0 +1,19 @@ +// importing common module. +mod common; + +// Not all tests expect exactly the same functionality in v2 and v3. +// For example, though we expect the /GET version to return the corresponding project, +// we may want to do different checks for each. +// (such as checking client_side in v2, but loader fields on v3- which are model-exclusie) + +// Such V2 tests are exported here +mod v2 { + mod error; + mod notifications; + mod project; + mod scopes; + mod search; + mod tags; + mod teams; + mod version; +} diff --git a/apps/labrinth/tests/version.rs b/apps/labrinth/tests/version.rs new file mode 100644 index 000000000..b085c435d --- /dev/null +++ b/apps/labrinth/tests/version.rs @@ -0,0 +1,730 @@ +use std::collections::HashMap; + +use crate::common::api_common::ApiVersion; +use crate::common::database::*; +use crate::common::dummy_data::{ + DummyProjectAlpha, DummyProjectBeta, TestFile, +}; +use crate::common::get_json_val_str; +use actix_http::StatusCode; +use actix_web::test; +use common::api_v3::ApiV3; +use common::asserts::assert_common_version_ids; +use common::database::USER_USER_PAT; +use common::environment::{with_test_environment, with_test_environment_all}; +use futures::StreamExt; +use labrinth::database::models::version_item::VERSIONS_NAMESPACE; +use labrinth::models::ids::base62_impl::parse_base62; +use labrinth::models::projects::{ + Dependency, DependencyType, VersionId, VersionStatus, VersionType, +}; +use labrinth::routes::v3::version_file::FileUpdateData; +use serde_json::json; + +// importing common module. +mod common; + +#[actix_rt::test] +async fn test_get_version() { + // Test setup and dummy data + with_test_environment_all(None, |test_env| async move { + let api = &test_env.api; + let DummyProjectAlpha { + project_id: alpha_project_id, + version_id: alpha_version_id, + .. + } = &test_env.dummy.project_alpha; + let DummyProjectBeta { + version_id: beta_version_id, + .. + } = &test_env.dummy.project_beta; + + // Perform request on dummy data + let version = api + .get_version_deserialized_common(alpha_version_id, USER_USER_PAT) + .await; + assert_eq!(&version.project_id.to_string(), alpha_project_id); + assert_eq!(&version.id.to_string(), alpha_version_id); + + let mut redis_conn = test_env.db.redis_pool.connect().await.unwrap(); + let cached_project = redis_conn + .get( + VERSIONS_NAMESPACE, + &parse_base62(alpha_version_id).unwrap().to_string(), + ) + .await + .unwrap() + .unwrap(); + let cached_project: serde_json::Value = + serde_json::from_str(&cached_project).unwrap(); + assert_eq!( + cached_project["val"]["inner"]["project_id"], + json!(parse_base62(alpha_project_id).unwrap()) + ); + + // Request should fail on non-existent version + let resp = api.get_version("false", USER_USER_PAT).await; + assert_status!(&resp, StatusCode::NOT_FOUND); + + // Similarly, request should fail on non-authorized user, on a yet-to-be-approved or hidden project, with a 404 (hiding the existence of the project) + // TODO: beta version should already be draft in dummy data, but theres a bug in finding it that + api.edit_version( + beta_version_id, + json!({ + "status": "draft" + }), + USER_USER_PAT, + ) + .await; + let resp = api.get_version(beta_version_id, USER_USER_PAT).await; + assert_status!(&resp, StatusCode::OK); + let resp = api.get_version(beta_version_id, ENEMY_USER_PAT).await; + assert_status!(&resp, StatusCode::NOT_FOUND); + }) + .await; +} + +#[actix_rt::test] +async fn version_updates() { + // Test setup and dummy data + with_test_environment( + None, + |test_env: common::environment::TestEnvironment| async move { + let api = &test_env.api; + let DummyProjectAlpha { + project_id: alpha_project_id, + project_id_parsed: alpha_project_id_parsed, + version_id: alpha_version_id, + file_hash: alpha_version_hash, + .. + } = &test_env.dummy.project_alpha; + let DummyProjectBeta { + version_id: beta_version_id, + file_hash: beta_version_hash, + .. + } = &test_env.dummy.project_beta; + + // Quick test, using get version from hash + let version = api + .get_version_from_hash_deserialized_common( + alpha_version_hash, + "sha1", + USER_USER_PAT, + ) + .await; + assert_eq!(&version.id.to_string(), alpha_version_id); + + // Get versions from hash + let versions = api + .get_versions_from_hashes_deserialized_common( + &[alpha_version_hash.as_str(), beta_version_hash.as_str()], + "sha1", + USER_USER_PAT, + ) + .await; + assert_eq!(versions.len(), 2); + assert_eq!( + &versions[alpha_version_hash].id.to_string(), + alpha_version_id + ); + assert_eq!( + &versions[beta_version_hash].id.to_string(), + beta_version_id + ); + + // When there is only the one version, there should be no updates + let version = api + .get_update_from_hash_deserialized_common( + alpha_version_hash, + "sha1", + None, + None, + None, + USER_USER_PAT, + ) + .await; + assert_eq!(&version.id.to_string(), alpha_version_id); + + let versions = api + .update_files_deserialized_common( + "sha1", + vec![alpha_version_hash.to_string()], + None, + None, + None, + USER_USER_PAT, + ) + .await; + assert_eq!(versions.len(), 1); + assert_eq!( + &versions[alpha_version_hash].id.to_string(), + alpha_version_id + ); + + // Add 3 new versions, 1 before, and 2 after, with differing game_version/version_types/loaders + let mut update_ids = vec![]; + for (version_number, patch_value) in [ + ( + "0.9.9", + json!({ + "game_versions": ["1.20.1"], + }), + ), + ( + "1.5.0", + json!({ + "game_versions": ["1.20.3"], + "loaders": ["fabric"], + }), + ), + ( + "1.5.1", + json!({ + "game_versions": ["1.20.4"], + "loaders": ["forge"], + "version_type": "beta" + }), + ), + ] + .iter() + { + let version = api + .add_public_version_deserialized( + *alpha_project_id_parsed, + version_number, + TestFile::build_random_jar(), + None, + None, + USER_USER_PAT, + ) + .await; + update_ids.push(version.id); + + // Patch using json + api.edit_version( + &version.id.to_string(), + patch_value.clone(), + USER_USER_PAT, + ) + .await; + } + + let check_expected = + |game_versions: Option>, + loaders: Option>, + version_types: Option>, + result_id: Option| async move { + let (success, result_id) = match result_id { + Some(id) => (true, id), + None => (false, VersionId(0)), + }; + // get_update_from_hash + let resp = api + .get_update_from_hash( + alpha_version_hash, + "sha1", + loaders.clone(), + game_versions.clone(), + version_types.clone(), + USER_USER_PAT, + ) + .await; + if success { + assert_status!(&resp, StatusCode::OK); + let body: serde_json::Value = + test::read_body_json(resp).await; + let id = body["id"].as_str().unwrap(); + assert_eq!(id, &result_id.to_string()); + } else { + assert_status!(&resp, StatusCode::NOT_FOUND); + } + + // update_files + let versions = api + .update_files_deserialized_common( + "sha1", + vec![alpha_version_hash.to_string()], + loaders.clone(), + game_versions.clone(), + version_types.clone(), + USER_USER_PAT, + ) + .await; + if success { + assert_eq!(versions.len(), 1); + let first = versions.iter().next().unwrap(); + assert_eq!(first.1.id, result_id); + } else { + assert_eq!(versions.len(), 0); + } + + // update_individual_files + let mut loader_fields = HashMap::new(); + if let Some(game_versions) = game_versions { + loader_fields.insert( + "game_versions".to_string(), + game_versions + .into_iter() + .map(|v| json!(v)) + .collect::>(), + ); + } + + let hashes = vec![FileUpdateData { + hash: alpha_version_hash.to_string(), + loaders, + loader_fields: Some(loader_fields), + version_types: version_types.map(|v| { + v.into_iter() + .map(|v| { + serde_json::from_str(&format!("\"{v}\"")) + .unwrap() + }) + .collect() + }), + }]; + let versions = api + .update_individual_files_deserialized( + "sha1", + hashes, + USER_USER_PAT, + ) + .await; + if success { + assert_eq!(versions.len(), 1); + let first = versions.iter().next().unwrap(); + assert_eq!(first.1.id, result_id); + } else { + assert_eq!(versions.len(), 0); + } + }; + + let tests = vec![ + check_expected( + Some(vec!["1.20.1".to_string()]), + None, + None, + Some(update_ids[0]), + ), + check_expected( + Some(vec!["1.20.3".to_string()]), + None, + None, + Some(update_ids[1]), + ), + check_expected( + Some(vec!["1.20.4".to_string()]), + None, + None, + Some(update_ids[2]), + ), + // Loader restrictions + check_expected( + None, + Some(vec!["fabric".to_string()]), + None, + Some(update_ids[1]), + ), + check_expected( + None, + Some(vec!["forge".to_string()]), + None, + Some(update_ids[2]), + ), + // Version type restrictions + check_expected( + None, + None, + Some(vec!["release".to_string()]), + Some(update_ids[1]), + ), + check_expected( + None, + None, + Some(vec!["beta".to_string()]), + Some(update_ids[2]), + ), + // Specific combination + check_expected( + None, + Some(vec!["fabric".to_string()]), + Some(vec!["release".to_string()]), + Some(update_ids[1]), + ), + // Impossible combination + check_expected( + None, + Some(vec!["fabric".to_string()]), + Some(vec!["beta".to_string()]), + None, + ), + // No restrictions, should do the last one + check_expected(None, None, None, Some(update_ids[2])), + ]; + + // Wait on all tests, 4 at a time + futures::stream::iter(tests) + .buffer_unordered(4) + .collect::>() + .await; + + // We do a couple small tests for get_project_versions_deserialized as well + // TODO: expand this more. + let versions = api + .get_project_versions_deserialized_common( + alpha_project_id, + None, + None, + None, + None, + None, + None, + USER_USER_PAT, + ) + .await; + assert_eq!(versions.len(), 4); + let versions = api + .get_project_versions_deserialized_common( + alpha_project_id, + None, + Some(vec!["forge".to_string()]), + None, + None, + None, + None, + USER_USER_PAT, + ) + .await; + assert_eq!(versions.len(), 1); + }, + ) + .await; +} + +#[actix_rt::test] +pub async fn test_patch_version() { + with_test_environment_all(None, |test_env| async move { + let api = &test_env.api; + + let alpha_version_id = &test_env.dummy.project_alpha.version_id; + let DummyProjectBeta { + project_id: beta_project_id, + project_id_parsed: beta_project_id_parsed, + .. + } = &test_env.dummy.project_beta; + + // First, we do some patch requests that should fail. + // Failure because the user is not authorized. + let resp = api + .edit_version( + alpha_version_id, + json!({ + "name": "test 1", + }), + ENEMY_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::UNAUTHORIZED); + + // Failure because these are illegal requested statuses for a normal user. + for req in ["unknown", "scheduled"] { + let resp = api + .edit_version( + alpha_version_id, + json!({ + "status": req, + // requested status it not set here, but in /schedule + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::BAD_REQUEST); + } + + // Sucessful request to patch many fields. + let resp = api + .edit_version( + alpha_version_id, + json!({ + "name": "new version name", + "version_number": "1.3.0", + "changelog": "new changelog", + "version_type": "beta", + "dependencies": [{ + "project_id": beta_project_id, + "dependency_type": "required", + "file_name": "dummy_file_name" + }], + "game_versions": ["1.20.5"], + "loaders": ["forge"], + "featured": false, + // "primary_file": [], TODO: test this + // // "downloads": 0, TODO: moderator exclusive + "status": "draft", + // // "filetypes": ["jar"], TODO: test this + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let version = api + .get_version_deserialized_common(alpha_version_id, USER_USER_PAT) + .await; + assert_eq!(version.name, "new version name"); + assert_eq!(version.version_number, "1.3.0"); + assert_eq!(version.changelog, "new changelog"); + assert_eq!( + version.version_type, + serde_json::from_str::("\"beta\"").unwrap() + ); + assert_eq!( + version.dependencies, + vec![Dependency { + project_id: Some(*beta_project_id_parsed), + version_id: None, + file_name: Some("dummy_file_name".to_string()), + dependency_type: DependencyType::Required + }] + ); + assert_eq!(version.loaders, vec!["forge".to_string()]); + assert!(!version.featured); + assert_eq!(version.status, VersionStatus::from_string("draft")); + + // These ones are checking the v2-v3 rerouting, we eneusre that only 'game_versions' + // works as expected, as well as only 'loaders' + let resp = api + .edit_version( + alpha_version_id, + json!({ + "game_versions": ["1.20.1", "1.20.2", "1.20.4"], + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let version = api + .get_version_deserialized_common(alpha_version_id, USER_USER_PAT) + .await; + assert_eq!(version.loaders, vec!["forge".to_string()]); // From last patch + + let resp = api + .edit_version( + alpha_version_id, + json!({ + "loaders": ["fabric"], + }), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let version = api + .get_version_deserialized_common(alpha_version_id, USER_USER_PAT) + .await; + assert_eq!(version.loaders, vec!["fabric".to_string()]); + }) + .await; +} + +#[actix_rt::test] +pub async fn test_project_versions() { + with_test_environment_all(None, |test_env| async move { + let api = &test_env.api; + let alpha_project_id: &String = + &test_env.dummy.project_alpha.project_id; + let alpha_version_id = &test_env.dummy.project_alpha.version_id; + + let versions = api + .get_project_versions_deserialized_common( + alpha_project_id, + None, + None, + None, + None, + None, + None, + USER_USER_PAT, + ) + .await; + assert_eq!(versions.len(), 1); + assert_eq!(&versions[0].id.to_string(), alpha_version_id); + }) + .await; +} + +#[actix_rt::test] +async fn can_create_version_with_ordering() { + with_test_environment( + None, + |env: common::environment::TestEnvironment| async move { + let alpha_project_id_parsed = + env.dummy.project_alpha.project_id_parsed; + + let new_version_id = get_json_val_str( + env.api + .add_public_version_deserialized_common( + alpha_project_id_parsed, + "1.2.3.4", + TestFile::BasicMod, + Some(1), + None, + USER_USER_PAT, + ) + .await + .id, + ); + + let versions = env + .api + .get_versions_deserialized( + vec![new_version_id.clone()], + USER_USER_PAT, + ) + .await; + assert_eq!(versions[0].ordering, Some(1)); + }, + ) + .await; +} + +#[actix_rt::test] +async fn edit_version_ordering_works() { + with_test_environment( + None, + |env: common::environment::TestEnvironment| async move { + let alpha_version_id = env.dummy.project_alpha.version_id.clone(); + + let resp = env + .api + .edit_version_ordering( + &alpha_version_id, + Some(10), + USER_USER_PAT, + ) + .await; + assert_status!(&resp, StatusCode::NO_CONTENT); + + let versions = env + .api + .get_versions_deserialized( + vec![alpha_version_id.clone()], + USER_USER_PAT, + ) + .await; + assert_eq!(versions[0].ordering, Some(10)); + }, + ) + .await; +} + +#[actix_rt::test] +async fn version_ordering_for_specified_orderings_orders_lower_order_first() { + with_test_environment_all(None, |env| async move { + let alpha_project_id_parsed = env.dummy.project_alpha.project_id_parsed; + let alpha_version_id = env.dummy.project_alpha.version_id.clone(); + let new_version_id = get_json_val_str( + env.api + .add_public_version_deserialized_common( + alpha_project_id_parsed, + "1.2.3.4", + TestFile::BasicMod, + Some(1), + None, + USER_USER_PAT, + ) + .await + .id, + ); + env.api + .edit_version_ordering(&alpha_version_id, Some(10), USER_USER_PAT) + .await; + + let versions = env + .api + .get_versions_deserialized_common( + vec![alpha_version_id.clone(), new_version_id.clone()], + USER_USER_PAT, + ) + .await; + + assert_common_version_ids( + &versions, + vec![new_version_id, alpha_version_id], + ); + }) + .await; +} + +#[actix_rt::test] +async fn version_ordering_when_unspecified_orders_oldest_first() { + with_test_environment_all(None, |env| async move { + let alpha_project_id_parsed = env.dummy.project_alpha.project_id_parsed; + let alpha_version_id: String = + env.dummy.project_alpha.version_id.clone(); + let new_version_id = get_json_val_str( + env.api + .add_public_version_deserialized_common( + alpha_project_id_parsed, + "1.2.3.4", + TestFile::BasicMod, + None, + None, + USER_USER_PAT, + ) + .await + .id, + ); + + let versions = env + .api + .get_versions_deserialized_common( + vec![alpha_version_id.clone(), new_version_id.clone()], + USER_USER_PAT, + ) + .await; + assert_common_version_ids( + &versions, + vec![alpha_version_id, new_version_id], + ); + }) + .await +} + +#[actix_rt::test] +async fn version_ordering_when_specified_orders_specified_before_unspecified() { + with_test_environment_all(None, |env| async move { + let alpha_project_id_parsed = env.dummy.project_alpha.project_id_parsed; + let alpha_version_id = env.dummy.project_alpha.version_id.clone(); + let new_version_id = get_json_val_str( + env.api + .add_public_version_deserialized_common( + alpha_project_id_parsed, + "1.2.3.4", + TestFile::BasicMod, + Some(1000), + None, + USER_USER_PAT, + ) + .await + .id, + ); + env.api + .edit_version_ordering(&alpha_version_id, None, USER_USER_PAT) + .await; + + let versions = env + .api + .get_versions_deserialized_common( + vec![alpha_version_id.clone(), new_version_id.clone()], + USER_USER_PAT, + ) + .await; + assert_common_version_ids( + &versions, + vec![new_version_id, alpha_version_id], + ); + }) + .await; +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 000000000..1c0daf3bb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,38 @@ +version: '3' +services: + postgres_db: + image: postgres:alpine + volumes: + - db-data:/var/lib/postgresql/data + ports: + - "5432:5432" + environment: + POSTGRES_DB: postgres + POSTGRES_USER: labrinth + POSTGRES_PASSWORD: labrinth + POSTGRES_HOST_AUTH_METHOD: trust + meilisearch: + image: getmeili/meilisearch:v1.5.0 + restart: on-failure + ports: + - "7700:7700" + volumes: + - meilisearch-data:/data.ms + environment: + MEILI_MASTER_KEY: modrinth + MEILI_HTTP_PAYLOAD_SIZE_LIMIT: 107374182400 + redis: + image: redis:alpine + restart: on-failure + ports: + - '6379:6379' + volumes: + - redis-data:/data + clickhouse: + image: clickhouse/clickhouse-server + ports: + - "8123:8123" +volumes: + meilisearch-data: + db-data: + redis-data: \ No newline at end of file diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml index 0b75ca8bd..a7eff7284 100644 --- a/packages/app-lib/Cargo.toml +++ b/packages/app-lib/Cargo.toml @@ -60,7 +60,7 @@ rand = "0.8" byteorder = "1.5.0" base64 = "0.22.0" -sqlx = { version = "0.8.0", features = [ "runtime-tokio", "sqlite", "macros" ] } +sqlx = { version = "0.8.2", features = [ "runtime-tokio", "sqlite", "macros" ] } [target.'cfg(windows)'.dependencies] winreg = "0.52.0" diff --git a/packages/app-lib/package.json b/packages/app-lib/package.json index b674c825c..931e852f7 100644 --- a/packages/app-lib/package.json +++ b/packages/app-lib/package.json @@ -2,8 +2,8 @@ "name": "@modrinth/app-lib", "scripts": { "build": "cargo build --release", - "lint": "cargo fmt --check && cargo clippy -- -D warnings", + "lint": "cargo fmt --check && cargo clippy --all-targets -- -D warnings", "fix": "cargo fmt && cargo clippy --fix", "test": "cargo test" } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8d8513450..8e11b168a 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -292,6 +292,8 @@ importers: specifier: ^2.0.24 version: 2.0.24(typescript@5.5.3) + apps/labrinth: {} + packages/app-lib: {} packages/assets: diff --git a/turbo.json b/turbo.json index f76adcfdb..73dd66fc0 100644 --- a/turbo.json +++ b/turbo.json @@ -15,17 +15,27 @@ "VERCEL_*", "CF_PAGES_*", "HEROKU_APP_NAME", - "STRIPE_PUBLISHABLE_KEY" + "STRIPE_PUBLISHABLE_KEY", + "SQLX_OFFLINE" + ] + }, + "lint": { + "env": [ + "SQLX_OFFLINE" ] }, - "lint": {}, "dev": { "cache": false, "persistent": true, "inputs": ["$TURBO_DEFAULT$", ".env*"], "env": ["DISPLAY", "WEBKIT_DISABLE_DMABUF_RENDERER"] }, - "test": {}, + "test": { + "env": [ + "SQLX_OFFLINE", + "DATABASE_URL" + ] + }, "fix": { "cache": false }