diff --git a/.github/workflows/labrinth-docker.yml b/.github/workflows/labrinth-docker.yml new file mode 100644 index 00000000..d967b3ba --- /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/.gitignore b/.gitignore index 9807c249..f617decc 100644 --- a/.gitignore +++ b/.gitignore @@ -56,5 +56,4 @@ generated # app testing dir app-playground-data/* -Cargo.lock -pnpm-lock.yaml \ No newline at end of file +.astro diff --git a/Cargo.lock b/Cargo.lock index 947695e1..1f39e6bb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3,14 +3,309 @@ version = 3 [[package]] -name = "addr2line" -version = "0.24.1" +name = "actix-codec" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f5fb1d8e4442bd405fdfd1dacb42792696b0cf9cb15882e5d097b742a676d375" +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 6.0.0", + "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.82", +] + +[[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.82", +] + +[[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.82", +] + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dfbe277e56a376000877090da837660b4427aad530e3028d44e0bffe4f89a1c1" 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", @@ -87,9 +394,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.89" +version = "1.0.90" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86fdf8605db99b54d3cd748a44c6d04df638eb5dafb219b135d0149bd0db01f6" +checksum = "37bf3594c4c988a53154954629820791dde498571819ae4ca50ca811e060cc95" [[package]] name = "arbitrary" @@ -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" @@ -108,9 +439,9 @@ checksum = "d92bec98840b8f03a5ff5413de5293bfcd8bf96467cf5452609f939ec6f5de16" [[package]] name = "ashpd" -version = "0.9.1" +version = "0.9.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bfe7e0dd0ac5a401dc116ed9f9119cf9decc625600474cb41f0fc0a0050abc9a" +checksum = "4d43c03d9e36dd40cab48435be0b09646da362c278223ca535493877b2c1dee9" dependencies = [ "enumflags2", "futures-channel", @@ -133,12 +464,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" @@ -153,9 +495,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.13" +version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e614738943d3f68c628ae3dbce7c3daffb196665f82f8c8ea6b65de73c79429" +checksum = "0cb8f1d480b0ea3783ab015936d2a55c87e219676f0c0b7dec61494043f21857" dependencies = [ "brotli 7.0.0", "bzip2", @@ -178,8 +520,8 @@ checksum = "30ca9a001c1e8ba5149f91a74362376cc6bc5b919d92d988668657bd570bdcec" dependencies = [ "async-task", "concurrent-queue", - "fastrand", - "futures-lite", + "fastrand 2.1.1", + "futures-lite 2.3.0", "slab", ] @@ -191,7 +533,7 @@ checksum = "ebcd09b382f40fcd159c2d695175b2ae620ffa5f3bd6f664131efff4e8b9e04a" dependencies = [ "async-lock", "blocking", - "futures-lite", + "futures-lite 2.3.0", ] [[package]] @@ -204,9 +546,9 @@ dependencies = [ "cfg-if", "concurrent-queue", "futures-io", - "futures-lite", + "futures-lite 2.3.0", "parking", - "polling", + "polling 3.7.3", "rustix", "slab", "tracing", @@ -219,7 +561,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", ] @@ -230,15 +572,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", ] @@ -251,7 +593,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", ] [[package]] @@ -272,6 +614,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" @@ -286,7 +653,7 @@ checksum = "721cae7de5c34fbb2acd27e21e6d2cf7b886dce0c27388d46c4e6c47ea4318dd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", ] [[package]] @@ -301,7 +668,7 @@ dependencies = [ "pin-project-lite", "rustls-pki-types", "tokio", - "tokio-rustls", + "tokio-rustls 0.26.0", "tungstenite", "webpki-roots", ] @@ -315,7 +682,7 @@ dependencies = [ "async-compression", "chrono", "crc32fast", - "futures-lite", + "futures-lite 2.3.0", "pin-project", "thiserror", "tokio", @@ -360,12 +727,52 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +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" @@ -375,7 +782,7 @@ dependencies = [ "addr2line", "cfg-if", "libc", - "miniz_oxide", + "miniz_oxide 0.8.0", "object", "rustc-demangle", "windows-targets 0.52.6", @@ -387,6 +794,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" @@ -405,6 +824,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" @@ -420,12 +869,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" @@ -450,13 +929,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.82", + "syn_derive", +] + [[package]] name = "brotli" version = "6.0.0" @@ -507,10 +1010,32 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "79296716171880943b8470b5f8d03aa55eb2e645a4874bdbb28adb49162e012c" [[package]] -name = "bytemuck" -version = "1.18.0" +name = "bytecheck" +version = "0.6.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94bbb0ad554ad961ddc5da507a12a29b14e4ae5bda06b19f575a3e6079d2e2ae" +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.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8334215b81e418a0a7bdb8ef0849474f40bb10c8b71f1c4ed315cff49f32494d" [[package]] name = "byteorder" @@ -518,6 +1043,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" @@ -527,6 +1058,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" @@ -616,16 +1156,31 @@ dependencies = [ ] [[package]] -name = "cc" -version = "1.1.24" +name = "castaway" +version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812acba72f0a070b003d3697490d2b55b837230ae7c6c6497f05cc2ddbb8d938" +checksum = "a2698f953def977c68f935bb0dfa959375ad4638570e969e2f1e9f433cbf1af6" + +[[package]] +name = "cc" +version = "1.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2e7962b54006dcfcc61cb72735f4d89bb97061dd6a7ed882ec6b8ee53714c6f" dependencies = [ "jobserver", "libc", "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" @@ -640,7 +1195,7 @@ checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" dependencies = [ "byteorder", "fnv", - "uuid 1.10.0", + "uuid 1.11.0", ] [[package]] @@ -680,6 +1235,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" @@ -690,6 +1255,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.11.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" @@ -750,6 +1360,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" @@ -757,7 +1382,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]] @@ -775,7 +1404,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", @@ -814,12 +1443,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" @@ -998,6 +1653,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" @@ -1022,7 +1687,28 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" dependencies = [ "quote", - "syn 2.0.79", + "syn 2.0.82", +] + +[[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]] @@ -1032,7 +1718,38 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "edb49164822f3ee45b17acd4a208cfc1251410cf0cad9a833234c9890774dd9f" dependencies = [ "quote", - "syn 2.0.79", + "syn 2.0.82", +] + +[[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]] @@ -1048,14 +1765,38 @@ 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.14.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "109c1ca6e6b7f82cc233a97004ea8ed7ca123a9af07a8230878fcfda9b158bf0" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim 0.10.0", + "syn 1.0.109", ] [[package]] @@ -1068,8 +1809,19 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", - "syn 2.0.79", + "strsim 0.11.1", + "syn 2.0.82", +] + +[[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]] @@ -1078,9 +1830,22 @@ 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", + "syn 2.0.82", +] + +[[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]] @@ -1115,6 +1880,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.11.0", +] + [[package]] name = "deflate64" version = "0.1.9" @@ -1142,6 +1947,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.82", +] + [[package]] name = "derive_arbitrary" version = "1.3.2" @@ -1150,7 +1966,38 @@ checksum = "67e77553c4162a157adbf834ebae5b415acbecbeafc7a74b0e886657506a7611" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", +] + +[[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]] @@ -1159,11 +2006,20 @@ 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", + "syn 2.0.82", +] + +[[package]] +name = "digest" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3dd60d1080a57a05ab032377049e0591415d2b31afd7028356dbf3cc6dcb066" +dependencies = [ + "generic-array", ] [[package]] @@ -1172,19 +2028,28 @@ 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]] @@ -1197,6 +2062,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" @@ -1247,7 +2123,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", ] [[package]] @@ -1279,9 +2155,15 @@ checksum = "f2b99bf03862d7f545ebc28ddd33a665b50865f4dfd84031a393823879bd4c54" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", ] +[[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" @@ -1346,7 +2228,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca" dependencies = [ "der", - "digest", + "digest 0.10.7", "elliptic-curve", "rfc6979", "signature", @@ -1370,7 +2252,7 @@ checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47" dependencies = [ "base16ct", "crypto-bigint", - "digest", + "digest 0.10.7", "ff", "generic-array", "group", @@ -1382,6 +2264,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" @@ -1393,7 +2291,7 @@ dependencies = [ "rustc_version", "toml 0.8.19", "vswhom", - "winreg", + "winreg 0.52.0", ] [[package]] @@ -1408,6 +2306,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" @@ -1441,7 +2345,20 @@ checksum = "de0d48a183585823424a4ce1aa132d174a6a81bd540895822eb4c8373a8e49e8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", +] + +[[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]] @@ -1481,6 +2398,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" @@ -1498,10 +2421,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" @@ -1549,6 +2507,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" @@ -1556,7 +2526,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a1b589b4dc103969ad3cf85c950899926ec64300a1a46d76c03a6072957036f0" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.8.0", ] [[package]] @@ -1570,13 +2540,13 @@ dependencies = [ [[package]] name = "flume" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55ac459de2512911e4b674ce33cf20befaba382d05b62b008afc1c8b57cbf181" +checksum = "da0e4dd2a88388a1f4ccc7c9ce104604dab68d9f408dc34cd45823d5a9069095" dependencies = [ "futures-core", "futures-sink", - "spin", + "spin 0.9.8", ] [[package]] @@ -1612,7 +2582,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", ] [[package]] @@ -1645,6 +2615,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" @@ -1657,9 +2633,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "645c6916888f6cb6350d2550b80fb63e734897a8498abe35cfb732b6487804b0" +checksum = "65bc07b1a8bc7c85c5f2e110c476c7389b4554ba72af57d8445ea63a576b0876" dependencies = [ "futures-channel", "futures-core", @@ -1672,9 +2648,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac8f7d7865dcb88bd4373ab671c8cf4508703796caa2b1985a9ca867b3fcb78" +checksum = "2dff15bf788c671c1934e366d07e30c1814a8ef514e1af724a602e8a2fbe1b10" dependencies = [ "futures-core", "futures-sink", @@ -1682,15 +2658,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dfc6580bb841c5a68e9ef15c77ccc837b40a7504914d52e47b8b0e9bbda25a1d" +checksum = "05f29059c0c2090612e8d742178b0580d2dc940c837851ad723096f87af6663e" [[package]] name = "futures-executor" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a576fc72ae164fca6b9db127eaa9a9dda0d61316034f33a0a0d4eda41f02b01d" +checksum = "1e28d1d997f585e54aebc3f97d39e72338912123a67330d723fdbb564d646c9f" dependencies = [ "futures-core", "futures-task", @@ -1710,9 +2686,24 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a44623e20b9681a318efdd71c299b6b222ed6f231972bfe2f224ebad6311f0c1" +checksum = "9e5c1b78ca4aae1ac06c48a526a655760685149f0d465d21f37abfe57ce075c6" + +[[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" @@ -1720,7 +2711,7 @@ 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", @@ -1729,32 +2720,38 @@ dependencies = [ [[package]] name = "futures-macro" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "87750cf4b7a4c0625b1529e4c543c2182106e4dedc60a2a6455e00d212c489ac" +checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", ] [[package]] name = "futures-sink" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9fb8e00e87438d937621c1c6269e53f536c14d3fbd6a042bb24879e57d474fb5" +checksum = "e575fab7d1e0dcb8d0c7bcf9a63ee213816ab51902e6d244a95819acacf1d4f7" [[package]] name = "futures-task" -version = "0.3.30" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "38d84fa142264698cdce1a9f9172cf383a0c82de1bddcf3092901442c4097004" +checksum = "f90f7dce0722e95104fcb095585910c0977252f286e354b5e3bd38902cd99988" + +[[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" +version = "0.3.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d6401deb83407ab3da39eba7e33987a73c3df0c82b4bb5813ee871c19c41d48" +checksum = "9fa08315bb612088cc391249efdc3bc77536f16c91f6cf495e6fbe85b20a4a81" dependencies = [ "futures-channel", "futures-core", @@ -1920,10 +2917,20 @@ dependencies = [ ] [[package]] -name = "gimli" -version = "0.31.0" +name = "gif" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "32085ea23f3234fc7846555e85283ba4de91e21016dc0455a16286d87a292d64" +checksum = "3fb2d69b19215e18bb912fa30f7ce15846e301408695e44e0ef719f1da9e19f2" +dependencies = [ + "color_quant", + "weezl", +] + +[[package]] +name = "gimli" +version = "0.31.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07e28edb80900c19c28f1072f2e8aeca7fa06b23cd4169cefe1af5aa3260783f" [[package]] name = "gio" @@ -1991,7 +2998,7 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", ] [[package]] @@ -2021,6 +3028,27 @@ dependencies = [ "system-deps", ] +[[package]] +name = "governor" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7a7ecdc5898f6a43e08a7e2c9e2266beb98fd4dfbf2634182540fbb715245093" +dependencies = [ + "cfg-if", + "dashmap 5.5.3", + "futures-sink", + "futures-timer", + "futures-util", + "no-std-compat", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.8.5", + "smallvec", + "spinning_top", +] + [[package]] name = "group" version = "0.13.0" @@ -2081,7 +3109,26 @@ dependencies = [ "proc-macro-error", "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", +] + +[[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.6.0", + "slab", + "tokio", + "tokio-util", + "tracing", ] [[package]] @@ -2095,7 +3142,7 @@ dependencies = [ "fnv", "futures-core", "futures-sink", - "http", + "http 1.1.0", "indexmap 2.6.0", "slab", "tokio", @@ -2103,11 +3150,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" @@ -2115,7 +3175,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", ] @@ -2134,6 +3194,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" @@ -2170,7 +3239,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]] @@ -2179,7 +3258,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" dependencies = [ - "digest", + "digest 0.10.7", ] [[package]] @@ -2191,6 +3270,17 @@ dependencies = [ "windows-sys 0.52.0", ] +[[package]] +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" @@ -2205,6 +3295,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" @@ -2216,6 +3317,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" @@ -2223,7 +3335,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http", + "http 1.1.0", ] [[package]] @@ -2234,8 +3346,8 @@ checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" dependencies = [ "bytes", "futures-util", - "http", - "http-body", + "http 1.1.0", + "http-body 1.0.1", "pin-project-lite", ] @@ -2245,6 +3357,27 @@ 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.5" @@ -2252,17 +3385,53 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d71d3574edd2771538b901e6549113b4006ece66150fb69c0fb6d9a2adae946" [[package]] -name = "hyper" -version = "1.4.1" +name = "httpdate" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50dfd22e0e76d0f662d429a5f80fcaf3855009297eab6a0a9f8543834744ba05" +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.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbbff0a806a4728c99295b254c8838933b5b082d75e3cb70c8dab21fdfbcfa9a" 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", @@ -2271,6 +3440,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" @@ -2278,17 +3463,30 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "08afdbb5c31130e3034af566421053ab03787c640246a446327f550d11bcb333" dependencies = [ "futures-util", - "http", - "hyper", + "http 1.1.0", + "hyper 1.5.0", "hyper-util", - "rustls", + "rustls 0.23.15", "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" @@ -2297,7 +3495,7 @@ checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" dependencies = [ "bytes", "http-body-util", - "hyper", + "hyper 1.5.0", "hyper-util", "native-tls", "tokio", @@ -2314,9 +3512,9 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http", - "http-body", - "hyper", + "http 1.1.0", + "http-body 1.0.1", + "hyper 1.5.0", "pin-project-lite", "socket2", "tokio", @@ -2357,12 +3555,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.82", +] + [[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" @@ -2373,6 +3699,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.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc144d44a31d753b02ce64093d532f55ff8dc4ebf2ffb8a63c0dda691385acae" +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" @@ -2408,6 +3787,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" @@ -2461,6 +3846,15 @@ version = "2.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ddc24109865250148c2e0f3d25d4f0f479571723792d3802153c60922a4fb708" +[[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" @@ -2470,6 +3864,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" @@ -2480,6 +3885,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" @@ -2489,6 +3939,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" @@ -2524,6 +3983,26 @@ dependencies = [ "system-deps", ] +[[package]] +name = "jemalloc-sys" +version = "0.5.4+5.3.0-patched" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac6c1946e1cea1788cbfde01c993b52a10e2da07f4bac608228d1bed20bfebf2" +dependencies = [ + "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" @@ -2556,10 +4035,19 @@ dependencies = [ ] [[package]] -name = "js-sys" -version = "0.3.70" +name = "jpeg-decoder" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1868808506b929d7b0cfa8f75951347aa71bb21144b7791bae35d9bccfcfe37a" +checksum = "f5d4a7da358eff58addd2877a45865158f0d78c911d43a5784ceb7bbf52833b0" +dependencies = [ + "rayon", +] + +[[package]] +name = "js-sys" +version = "0.3.72" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a88f1bda2bd75b0452a14784937d796722fdebfe50df998aeb3f0b7603019a9" dependencies = [ "wasm-bindgen", ] @@ -2570,7 +4058,19 @@ version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5b1fb8864823fad91877e6caea0baca82e49e8db50f8e5c9f9a453e27d3330fc" dependencies = [ - "jsonptr", + "jsonptr 0.4.7", + "serde", + "serde_json", + "thiserror", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr 0.6.3", "serde", "serde_json", "thiserror", @@ -2587,6 +4087,28 @@ dependencies = [ "serde_json", ] +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "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" @@ -2631,13 +4153,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 3.0.1", + "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.11.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]] @@ -2666,9 +4307,9 @@ dependencies = [ [[package]] name = "libc" -version = "0.2.159" +version = "0.2.161" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "561d97a539a36e26a9a5fad1ea11a3039a67714694aaa379433e580854bc3dc5" +checksum = "8e9489c2807c139ffd9c1794f4af0ebe86a828db53ecdc7fea2111d0fed085d1" [[package]] name = "libdbus-sys" @@ -2706,6 +4347,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" @@ -2728,12 +4379,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" @@ -2750,6 +4452,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" @@ -2794,6 +4524,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.82", +] + [[package]] name = "md-5" version = "0.10.6" @@ -2801,7 +4554,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.11.0", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "yaup", ] [[package]] @@ -2825,6 +4623,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" @@ -2837,6 +4654,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" @@ -2867,6 +4693,7 @@ checksum = "80e04d1dcff3aae0704555fe5fee3bcfaf3d1fdf8a7e521d5b9d2b42acb52cec" dependencies = [ "hermit-abi 0.3.9", "libc", + "log", "wasi 0.11.0+wasi-snapshot-preview1", "windows-sys 0.52.0", ] @@ -2891,6 +4718,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" @@ -2980,6 +4813,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" @@ -2996,6 +4835,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" @@ -3053,6 +4898,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" @@ -3106,6 +4961,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" @@ -3124,7 +4989,7 @@ dependencies = [ "proc-macro-crate 3.2.0", "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", ] [[package]] @@ -3140,7 +5005,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "915b1b472bc21c53464d6c8461c9d3af805ba1ef837e1cac254428f4a77177b1" dependencies = [ "malloc_buf", - "objc_exception", ] [[package]] @@ -3159,6 +5023,9 @@ name = "objc-sys" version = "0.3.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb91bdd390c7ce1a8607f35f3ca7151b65afc0ff5ff3b34fa350f7d7c7e4310" +dependencies = [ + "cc", +] [[package]] name = "objc2" @@ -3186,6 +5053,30 @@ dependencies = [ "objc2-quartz-core", ] +[[package]] +name = "objc2-cloud-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74dd3b56391c7a0596a295029734d3c1c5e7e510a4cb30245f8221ccea96b009" +dependencies = [ + "bitflags 2.6.0", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "objc2-contacts" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5ff520e9c33812fd374d8deecef01d4a840e7b41862d849513de77e44aa4889" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + [[package]] name = "objc2-core-data" version = "0.2.2" @@ -3210,6 +5101,18 @@ dependencies = [ "objc2-metal", ] +[[package]] +name = "objc2-core-location" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "000cfee34e683244f284252ee206a27953279d370e309649dc3ee317b37e5781" +dependencies = [ + "block2", + "objc2", + "objc2-contacts", + "objc2-foundation", +] + [[package]] name = "objc2-encode" version = "4.0.3" @@ -3229,6 +5132,18 @@ dependencies = [ "objc2", ] +[[package]] +name = "objc2-link-presentation" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1a1ae721c5e35be65f01a03b6d2ac13a54cb4fa70d8a5da293d7b0020261398" +dependencies = [ + "block2", + "objc2", + "objc2-app-kit", + "objc2-foundation", +] + [[package]] name = "objc2-metal" version = "0.2.2" @@ -3255,12 +5170,71 @@ dependencies = [ ] [[package]] -name = "objc_exception" -version = "0.1.2" +name = "objc2-symbols" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad970fb455818ad6cba4c122ad012fae53ae8b4795f86378bce65e4f6bab2ca4" +checksum = "0a684efe3dec1b305badae1a28f6555f6ddd3bb2c2267896782858d5a78404dc" dependencies = [ - "cc", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8bb46798b20cd6b91cbd113524c490f1686f4c4e8f49502431415f3512e2b6f" +dependencies = [ + "bitflags 2.6.0", + "block2", + "objc2", + "objc2-cloud-kit", + "objc2-core-data", + "objc2-core-image", + "objc2-core-location", + "objc2-foundation", + "objc2-link-presentation", + "objc2-quartz-core", + "objc2-symbols", + "objc2-uniform-type-identifiers", + "objc2-user-notifications", +] + +[[package]] +name = "objc2-uniform-type-identifiers" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44fa5f9748dbfe1ca6c0b79ad20725a11eca7c2218bceb4b005cb1be26273bfe" +dependencies = [ + "block2", + "objc2", + "objc2-foundation", +] + +[[package]] +name = "objc2-user-notifications" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76cfcbf642358e8689af64cee815d139339f3ed8ad05103ed5eaf73db8d84cb3" +dependencies = [ + "bitflags 2.6.0", + "block2", + "objc2", + "objc2-core-location", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68bc69301064cebefc6c4c90ce9cba69225239e4b8ff99d445a2b5563797da65" +dependencies = [ + "bitflags 2.6.0", + "block2", + "objc2", + "objc2-app-kit", + "objc2-foundation", ] [[package]] @@ -3274,21 +5248,30 @@ dependencies = [ [[package]] name = "object" -version = "0.36.4" +version = "0.36.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "084f1a5821ac4c651660a94a7153d27ac9d8a53736203f58b31945ded098070a" +checksum = "aedf0a2d09c573ed1d8d85b30c119153926a2b36dce0ab28322c09a117a4683e" dependencies = [ "memchr", ] [[package]] name = "once_cell" -version = "1.20.1" +version = "1.20.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82881c4be219ab5faaf2ad5e5e5ecdff8c66bd7402ca3160975c93b24961afd1" -dependencies = [ - "portable-atomic", -] +checksum = "1261fe7e33c73b354eab43b1273a57c8f967d0391e80353e51f764ac02cf6775" + +[[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" @@ -3316,9 +5299,9 @@ dependencies = [ [[package]] name = "openssl" -version = "0.10.66" +version = "0.10.68" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9529f4786b70a3e8c61e11179af17ab6188ad8d0ded78c5529441ed39d4bd9c1" +checksum = "6174bc48f102d208783c2c84bf931bb75927a617866870de8a4ea85597f871f5" dependencies = [ "bitflags 2.6.0", "cfg-if", @@ -3337,7 +5320,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", ] [[package]] @@ -3357,9 +5340,9 @@ dependencies = [ [[package]] name = "openssl-sys" -version = "0.9.103" +version = "0.9.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f9e8deee91df40a943c71b917e5874b951d32a802526c85721ce3b776c929d6" +checksum = "45abf306cbf99debc8195b66b7346498d7b10c210de50418b5ccd7ceba08c741" dependencies = [ "cc", "libc", @@ -3374,13 +5357,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", ] @@ -3430,7 +5423,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.8", ] [[package]] @@ -3487,6 +5480,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" @@ -3498,6 +5497,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" @@ -3506,9 +5516,9 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pathdiff" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8835116a5c179084a830efb3adc117ab007512b535bc1a21c991d3b32a6b44dd" +checksum = "d61c5ce1153ab5b689d0c074c4e7fc613e942dfb7dd9eea5ab202d2ad91fe361" [[package]] name = "pbkdf2" @@ -3516,10 +5526,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]] @@ -3641,7 +5651,7 @@ dependencies = [ "phf_shared 0.11.2", "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", ] [[package]] @@ -3672,23 +5682,44 @@ dependencies = [ ] [[package]] -name = "pin-project" -version = "1.1.5" +name = "phonenumber" +version = "0.3.6+8.13.36" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6bf43b791c5b9e34c3d182969b4abb522f9343702850a2e57f460d00d09b4b3" +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.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf123a161dde1e524adf36f90bc5d8d3462824a9c43553ad07a8183161189ec" dependencies = [ "pin-project-internal", ] [[package]] name = "pin-project-internal" -version = "1.1.5" +version = "1.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f38a4412a78282e09a2cf38d195ea5420d15ba0602cb375210efbc877243965" +checksum = "a4502d8515ca9f32f1fb543d987f63d95a14934883db45bdb48060b6b69257f8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", ] [[package]] @@ -3710,7 +5741,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c8c490f422ef9a4efd2cb5b42b76c8613d7e7dfc1caf667b8a3350a5acc066" dependencies = [ "atomic-waker", - "fastrand", + "fastrand 2.1.1", "futures-io", ] @@ -3764,7 +5795,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]] @@ -3809,6 +5856,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" @@ -3878,13 +5939,130 @@ checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" [[package]] name = "proc-macro2" -version = "1.0.86" +version = "1.0.88" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e719e8df665df0d1c8fbfd238015744736151d4445ec0836b8e628aae103b77" +checksum = "7c3a7fc5db1e57d5a779a352c8cdb57b29aa4c40cc69c3a68a7fedc815fbf2f9" 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" @@ -3914,7 +6092,7 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls", + "rustls 0.23.15", "socket2", "thiserror", "tokio", @@ -3929,9 +6107,9 @@ checksum = "fadfaed2cd7f389d0161bb73eeb07b7b78f8691047a6f3e73caaeae55310a4a6" dependencies = [ "bytes", "rand 0.8.5", - "ring", + "ring 0.17.8", "rustc-hash", - "rustls", + "rustls 0.23.15", "slab", "thiserror", "tinyvec", @@ -3960,6 +6138,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" @@ -4041,6 +6242,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" @@ -4073,6 +6283,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.7" @@ -4125,6 +6360,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" @@ -4137,6 +6390,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.8" @@ -4147,15 +6452,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.5.0", + "hyper-rustls 0.27.3", + "hyper-tls 0.6.0", "hyper-util", "ipnet", "js-sys", @@ -4166,17 +6472,17 @@ dependencies = [ "percent-encoding", "pin-project-lite", "quinn", - "rustls", - "rustls-pemfile", + "rustls 0.23.15", + "rustls-pemfile 2.2.0", "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", @@ -4200,7 +6506,7 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2" dependencies = [ - "hmac", + "hmac 0.12.1", "subtle", ] @@ -4227,6 +6533,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" @@ -4237,11 +6567,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.11.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" @@ -4249,7 +6608,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", @@ -4262,6 +6621,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" @@ -4269,10 +6638,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" @@ -4309,18 +6749,51 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.13" +version = "0.21.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2dabaac7466917e566adb06783a81ca48944c6898a1b08b9374106dd671f4c8" +checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +dependencies = [ + "log", + "ring 0.17.8", + "rustls-webpki 0.101.7", + "sct", +] + +[[package]] +name = "rustls" +version = "0.23.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fbb44d7acc4e873d613422379f69f237a1b141928c02f6bc6ccfddddc2d7993" 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.2.0" @@ -4332,9 +6805,19 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.9.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e696e35370c65c9c541198af4543ccd580cf17fc25d8e05c5a242b202488c55" +checksum = "16f1201b3c9a7ee8039bcadc17b7e605e2945b27eee7631788c1bd2b0643674b" + +[[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" @@ -4342,11 +6825,44 @@ 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" @@ -4364,13 +6880,22 @@ dependencies = [ [[package]] name = "schannel" -version = "0.1.24" +version = "0.1.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e9aaafd5a2b6e3d657ff009d82fbd630b6bd54dd4eb06f21693925cdf80f9b8b" +checksum = "01227be5826fa0690321a2ba6c5cd57a19cf3f6a09e76973b58e61de6ab9d1c1" 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" @@ -4383,7 +6908,7 @@ dependencies = [ "serde", "serde_json", "url", - "uuid 1.10.0", + "uuid 1.11.0", ] [[package]] @@ -4394,8 +6919,8 @@ checksum = "b1eee588578aff73f856ab961cd2f79e36bc45d7ded33a7562adba4667aecc0e" dependencies = [ "proc-macro2", "quote", - "serde_derive_internals", - "syn 2.0.79", + "serde_derive_internals 0.29.1", + "syn 2.0.82", ] [[package]] @@ -4410,6 +6935,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" @@ -4476,6 +7029,125 @@ 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.8", + "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 = "sentry-panic" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eaa3ecfa3c8750c78dcfd4637cfa2598b95b52897ed184b4dc77fcf7d95060d" +dependencies = [ + "sentry-backtrace", + "sentry-core", +] + +[[package]] +name = "sentry-tracing" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f715932bf369a61b7256687c6f0554141b7ce097287e30e3f7ed6e9de82498fe" +dependencies = [ + "sentry-backtrace", + "sentry-core", + "tracing-core", + "tracing-subscriber", +] + +[[package]] +name = "sentry-types" +version = "0.32.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4519c900ce734f7a0eb7aba0869dfb225a7af8820634a7dd51449e3b093cfb7c" +dependencies = [ + "debugid", + "hex", + "rand 0.8.5", + "serde", + "serde_json", + "thiserror", + "time", + "url", + "uuid 1.11.0", +] + [[package]] name = "serde" version = "1.0.210" @@ -4504,7 +7176,18 @@ checksum = "243902eda00fad750862fc144cea25caca5e20d615af0a81bee94ca738f1df1f" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", +] + +[[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]] @@ -4515,7 +7198,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", ] [[package]] @@ -4531,9 +7214,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.128" +version = "1.0.132" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ff5456707a1de34e7e37f2a6fd3d3f808c318259cbd01ab6377795054b483d8" +checksum = "d726bfaff4b320266d395898905d0eba0345aae23b54aee3a737e260fd46db03" dependencies = [ "itoa 1.0.11", "memchr", @@ -4541,6 +7224,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" @@ -4549,7 +7273,7 @@ checksum = "6c64451ba24fc7a6a2d60fc75dd9c83c90903b19028d4eff35e88fc1e86564e9" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", ] [[package]] @@ -4575,9 +7299,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.10.0" +version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9720086b3357bcb44fce40117d769a4d068c70ecfa190850a980a71755f66fcc" +checksum = "8e28bdad6db2b8340e449f7108f020b3b092e8583a9e3fb82713e1d4e71fe817" dependencies = [ "base64 0.22.1", "chrono", @@ -4593,14 +7317,14 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.10.0" +version = "3.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f1abbfe725f27678f4663bcacb75a83e829fd464c25d78dd038a3a29e307cec" +checksum = "9d846214a9854ef724f3da161b426242d8de7c1fc7de2f89bb1efcb154dca79d" dependencies = [ - "darling", + "darling 0.20.10", "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", ] [[package]] @@ -4635,6 +7359,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" @@ -4643,7 +7376,7 @@ checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", "cpufeatures", - "digest", + "digest 0.10.7", ] [[package]] @@ -4652,6 +7385,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" @@ -4660,7 +7406,7 @@ checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" dependencies = [ "cfg-if", "cpufeatures", - "digest", + "digest 0.10.7", ] [[package]] @@ -4703,7 +7449,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", ] @@ -4713,6 +7459,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" @@ -4728,6 +7480,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" @@ -4737,6 +7500,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" @@ -4795,6 +7589,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" @@ -4804,6 +7613,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" @@ -4846,10 +7664,11 @@ dependencies = [ "atoi", "byteorder", "bytes", + "chrono", "crc", "crossbeam-queue", "either", - "event-listener", + "event-listener 5.3.1", "futures-channel", "futures-core", "futures-intrusive", @@ -4864,9 +7683,12 @@ dependencies = [ "once_cell", "paste", "percent-encoding", + "rust_decimal", + "rustls 0.23.15", + "rustls-pemfile 2.2.0", "serde", "serde_json", - "sha2", + "sha2 0.10.8", "smallvec", "sqlformat", "thiserror", @@ -4874,6 +7696,7 @@ dependencies = [ "tokio-stream", "tracing", "url", + "webpki-roots", ] [[package]] @@ -4886,7 +7709,7 @@ dependencies = [ "quote", "sqlx-core", "sqlx-macros-core", - "syn 2.0.79", + "syn 2.0.82", ] [[package]] @@ -4904,12 +7727,12 @@ dependencies = [ "quote", "serde", "serde_json", - "sha2", + "sha2 0.10.8", "sqlx-core", "sqlx-mysql", "sqlx-postgres", "sqlx-sqlite", - "syn 2.0.79", + "syn 2.0.82", "tempfile", "tokio", "url", @@ -4926,8 +7749,9 @@ dependencies = [ "bitflags 2.6.0", "byteorder", "bytes", + "chrono", "crc", - "digest", + "digest 0.10.7", "dotenvy", "either", "futures-channel", @@ -4937,7 +7761,7 @@ dependencies = [ "generic-array", "hex", "hkdf", - "hmac", + "hmac 0.12.1", "itoa 1.0.11", "log", "md-5", @@ -4946,9 +7770,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", @@ -4967,6 +7792,7 @@ dependencies = [ "base64 0.22.1", "bitflags 2.6.0", "byteorder", + "chrono", "crc", "dotenvy", "etcetera", @@ -4976,7 +7802,7 @@ dependencies = [ "futures-util", "hex", "hkdf", - "hmac", + "hmac 0.12.1", "home", "itoa 1.0.11", "log", @@ -4984,9 +7810,10 @@ dependencies = [ "memchr", "once_cell", "rand 0.8.5", + "rust_decimal", "serde", "serde_json", - "sha2", + "sha2 0.10.8", "smallvec", "sqlx-core", "stringprep", @@ -5002,6 +7829,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d5b2cf34a45953bfd3daaf3db0f7a7878ab9b7a6b91b422d24a7a9e4c857b680" dependencies = [ "atoi", + "chrono", "flume", "futures-channel", "futures-core", @@ -5024,12 +7852,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" @@ -5067,12 +7914,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.82", +] + [[package]] name = "subtle" version = "2.6.1" @@ -5103,15 +7978,33 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.79" +version = "2.0.82" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "89132cd0bf050864e1d38dc3bbc07a0eb8e7530af26344d3d2bbbef83499f590" +checksum = "83540f837a8afc019423a8edb95b52a8effe46957ee402287f4292fae35be021" dependencies = [ "proc-macro2", "quote", "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.82", +] + +[[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" @@ -5121,6 +8014,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.82", +] + [[package]] name = "sys-info" version = "0.9.1" @@ -5155,6 +8059,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" @@ -5163,7 +8078,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]] @@ -5191,9 +8116,9 @@ dependencies = [ [[package]] name = "tao" -version = "0.30.2" +version = "0.30.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "06e48d7c56b3f7425d061886e8ce3b6acfab1993682ed70bef50fd133d721ee6" +checksum = "a0dbbebe82d02044dfa481adca1550d6dd7bd16e086bc34fa0fbecceb5a63751" dependencies = [ "bitflags 2.6.0", "cocoa 0.26.0", @@ -5236,9 +8161,15 @@ checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", ] +[[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" @@ -5258,13 +8189,13 @@ checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" [[package]] name = "tauri" -version = "2.0.1" +version = "2.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3fad474c02a3bcd4a304afff97159a31b9bab84e29563f7109c7b0ce8cd774e" +checksum = "5ce2818e803ce3097987296623ed8c0d9f65ed93b4137ff9a83e168bdbf62932" dependencies = [ "anyhow", "bytes", - "dirs", + "dirs 5.0.1", "dunce", "embed_plist", "futures-util", @@ -5272,7 +8203,7 @@ dependencies = [ "glob", "gtk", "heck 0.5.0", - "http", + "http 1.1.0", "http-range", "jni", "libc", @@ -5285,7 +8216,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle 0.6.2", - "reqwest", + "reqwest 0.12.8", "serde", "serde_json", "serde_repr", @@ -5315,10 +8246,10 @@ checksum = "935f9b3c49b22b3e2e485a57f46d61cd1ae07b1cbb2ba87387a387caf2d8c4e7" dependencies = [ "anyhow", "cargo_toml", - "dirs", + "dirs 5.0.1", "glob", "heck 0.5.0", - "json-patch", + "json-patch 2.0.0", "quote", "schemars", "semver", @@ -5340,7 +8271,7 @@ dependencies = [ "base64 0.22.1", "brotli 6.0.0", "ico", - "json-patch", + "json-patch 2.0.0", "plist", "png", "proc-macro2", @@ -5348,13 +8279,13 @@ dependencies = [ "semver", "serde", "serde_json", - "sha2", - "syn 2.0.79", + "sha2 0.10.8", + "syn 2.0.82", "tauri-utils", "thiserror", "time", "url", - "uuid 1.10.0", + "uuid 1.11.0", "walkdir", ] @@ -5367,7 +8298,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", "tauri-codegen", "tauri-utils", ] @@ -5397,7 +8328,7 @@ checksum = "31a9b5725027c6e9e075b06cb2d5c2cd3b5c29daa8012b404e1ff755cc56082f" dependencies = [ "dunce", "log", - "rust-ini", + "rust-ini 0.21.1", "serde", "serde_json", "tauri", @@ -5411,9 +8342,9 @@ dependencies = [ [[package]] name = "tauri-plugin-dialog" -version = "2.0.1" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddb2fe88b602461c118722c574e2775ab26a4e68886680583874b2f6520608b7" +checksum = "6ff33d09331cc22bf4771a2633b62afc1038e36273eef22bff12f53d33decd91" dependencies = [ "log", "raw-window-handle 0.6.2", @@ -5429,9 +8360,9 @@ dependencies = [ [[package]] name = "tauri-plugin-fs" -version = "2.0.1" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab300488ebec3487ca5f56289692e7e45feb07eea8d5e1dba497f7dc9dd9c407" +checksum = "16d0816b27207c29aaff632c73b702785140cf16fe6150c201a2720245b8ea88" dependencies = [ "anyhow", "dunce", @@ -5445,7 +8376,7 @@ dependencies = [ "tauri-plugin", "thiserror", "url", - "uuid 1.10.0", + "uuid 1.11.0", ] [[package]] @@ -5468,9 +8399,9 @@ dependencies = [ [[package]] name = "tauri-plugin-shell" -version = "2.0.1" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "371fb9aca2823990a2d0db7970573be5fdf07881fcaa2b835b29631feb84aec1" +checksum = "0ad7880c5586b6b2104be451e3d7fc0f3800c84bda69e9ba81c828f87cb34267" dependencies = [ "encoding_rs", "log", @@ -5509,14 +8440,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1dd3d2fe0f02bf52eebb5a9d23b987fffac6684646ab6fd683d706dafb18da87" 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", "percent-encoding", - "reqwest", + "reqwest 0.12.8", "semver", "serde", "serde_json", @@ -5549,13 +8480,13 @@ dependencies = [ [[package]] name = "tauri-runtime" -version = "2.0.1" +version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af12ad1af974b274ef1d32a94e6eba27a312b429ef28fcb98abc710df7f9151d" +checksum = "c8f437293d6f5e5dce829250f4dbdce4e0b52905e297a6689cc2963eb53ac728" dependencies = [ "dpi", "gtk", - "http", + "http 1.1.0", "jni", "raw-window-handle 0.6.2", "serde", @@ -5568,12 +8499,12 @@ dependencies = [ [[package]] name = "tauri-runtime-wry" -version = "2.0.1" +version = "2.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e45e88aa0b11b302d836e6ea3e507a6359044c4a8bc86b865ba99868c695753d" +checksum = "62fa2068e8498ad007b54d5773d03d57c3ff6dd96f8c8ce58beff44d0d5e0d30" dependencies = [ "gtk", - "http", + "http 1.1.0", "jni", "log", "objc2", @@ -5594,18 +8525,18 @@ dependencies = [ [[package]] name = "tauri-utils" -version = "2.0.1" +version = "2.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c38b0230d6880cf6dd07b6d7dd7789a0869f98ac12146e0d18d1c1049215a045" +checksum = "1fc65d6f5c54e56b66258948a6d9e47a82ea41f4b5a7612bfbdd1634c2913ed0" dependencies = [ - "brotli 6.0.0", + "brotli 7.0.0", "cargo_metadata", "ctor", "dunce", "glob", "html5ever", - "infer", - "json-patch", + "infer 0.16.0", + "json-patch 2.0.0", "kuchikiki", "log", "memchr", @@ -5624,7 +8555,7 @@ dependencies = [ "toml 0.8.19", "url", "urlpattern", - "uuid 1.10.0", + "uuid 1.11.0", "walkdir", ] @@ -5645,7 +8576,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0f2c9fc62d0beef6951ccffd757e241266a2c833136efbe35af6cd2567dca5b" dependencies = [ "cfg-if", - "fastrand", + "fastrand 2.1.1", "once_cell", "rustix", "windows-sys 0.59.0", @@ -5662,9 +8593,29 @@ dependencies = [ "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 = "termcolor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06794f8f6c5c898b3275aebefa6b8a1cb24cd2c6c79397ab15774837a0bc5755" +dependencies = [ + "winapi-util", +] + [[package]] name = "theseus" -version = "0.8.8" +version = "0.8.9" dependencies = [ "async-recursion", "async-tungstenite", @@ -5674,8 +8625,8 @@ dependencies = [ "bytes", "chrono", "daedalus", - "dashmap", - "dirs", + "dashmap 6.1.0", + "dirs 5.0.1", "discord-rich-presence", "dunce", "flate2", @@ -5688,12 +8639,12 @@ dependencies = [ "paste", "rand 0.8.5", "regex", - "reqwest", + "reqwest 0.12.8", "serde", "serde_ini", "serde_json", "sha1_smol", - "sha2", + "sha2 0.10.8", "sqlx", "sys-info", "sysinfo", @@ -5707,21 +8658,21 @@ dependencies = [ "tracing-subscriber", "url", "urlencoding", - "uuid 1.10.0", + "uuid 1.11.0", "whoami", - "winreg", + "winreg 0.52.0", "zip 0.6.6", ] [[package]] name = "theseus_gui" -version = "0.8.8" +version = "0.8.9" dependencies = [ "chrono", "cocoa 0.25.0", "daedalus", - "dashmap", - "dirs", + "dashmap 6.1.0", + "dirs 5.0.1", "futures", "lazy_static", "native-dialog", @@ -5748,7 +8699,7 @@ dependencies = [ "tracing", "tracing-error", "url", - "uuid 1.10.0", + "uuid 1.11.0", "window-shadows", ] @@ -5767,7 +8718,7 @@ dependencies = [ "tracing-error", "tracing-subscriber", "url", - "uuid 1.10.0", + "uuid 1.11.0", "webbrowser", ] @@ -5794,7 +8745,7 @@ checksum = "08904e7672f5eb876eaaf87e0ce17857500934f4981c4a0ab2b4aa98baac7fc3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", ] [[package]] @@ -5807,6 +8758,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" @@ -5847,6 +8809,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" @@ -5889,7 +8861,7 @@ checksum = "693d596312e88961bc67d7f1f97af8a70227d9f90c31bba5806eec004978d752" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", ] [[package]] @@ -5902,13 +8874,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.15", "rustls-pki-types", "tokio", ] @@ -6008,6 +8990,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" @@ -6034,7 +9030,7 @@ checksum = "34704c8d6ebcbc939824180af020566b01a7c01f80641264eba0999f6c2b6be7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", ] [[package]] @@ -6057,6 +9053,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" @@ -6089,13 +9095,13 @@ dependencies = [ [[package]] name = "tray-icon" -version = "0.19.0" +version = "0.19.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533fc2d4105e0e3d96ce1c71f2d308c9fbbe2ef9c587cab63dd627ab5bde218f" +checksum = "7c92af36a182b46206723bdf8a7942e20838cde1cf062e5b97854d57eb01763b" dependencies = [ "core-graphics 0.24.0", "crossbeam-channel", - "dirs", + "dirs 5.0.1", "libappindicator", "muda", "objc2", @@ -6129,13 +9135,13 @@ dependencies = [ "byteorder", "bytes", "data-encoding", - "http", + "http 1.1.0", "httparse", "log", "rand 0.8.5", - "rustls", + "rustls 0.23.15", "rustls-pki-types", - "sha1", + "sha1 0.10.6", "thiserror", "utf-8", ] @@ -6163,6 +9169,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" @@ -6204,6 +9219,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e51b68083f157f853b6379db119d1c1be0e6e4dec98101079dec41f6f5cf6df" + [[package]] name = "unicode-bidi" version = "0.3.17" @@ -6249,12 +9270,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" @@ -6262,7 +9302,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "22784dbdf76fdde8af1aeda5622b546b422b6fc585325248a2bf9f5e41e94d6c" dependencies = [ "form_urlencoded", - "idna", + "idna 0.5.0", "percent-encoding", "serde", ] @@ -6291,6 +9331,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" @@ -6302,14 +9354,64 @@ dependencies = [ [[package]] name = "uuid" -version = "1.10.0" +version = "1.11.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81dfa00651efa65069b0b6b651f4aaa31ba9e3c3ce0137aaad053604ee7e0314" +checksum = "f8c5f0a0af699448548ad1a2fbf920fb4bee257eae39953ba95cb84891a0446a" 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]] name = "valuable" version = "0.1.0" @@ -6340,7 +9442,7 @@ version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c73a36bc44e3039f51fbee93e39f41225f6b17b380eb70cc2aab942df06b34dd" dependencies = [ - "itertools", + "itertools 0.11.0", "nom", ] @@ -6370,6 +9472,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" @@ -6409,9 +9517,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a82edfc16a6c469f5f44dc7b571814045d60404b55a0ee849f9bcfa2e63dd9b5" +checksum = "128d1e363af62632b8eb57219c8fd7877144af57558fb2ef0368d0087bddeb2e" dependencies = [ "cfg-if", "once_cell", @@ -6420,24 +9528,24 @@ dependencies = [ [[package]] name = "wasm-bindgen-backend" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9de396da306523044d3302746f1208fa71d7532227f15e347e2d93e4145dd77b" +checksum = "cb6dd4d3ca0ddffd1dd1c9c04f94b868c37ff5fac97c30b97cff2d74fce3a358" dependencies = [ "bumpalo", "log", "once_cell", "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-futures" -version = "0.4.43" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61e9300f63a621e96ed275155c108eb6f843b6a26d053f122ab69724559dc8ed" +checksum = "cc7ec4f8827a71586374db3e87abdb5a2bb3a15afed140221307c3ec06b1f63b" dependencies = [ "cfg-if", "js-sys", @@ -6447,9 +9555,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "585c4c91a46b072c92e908d99cb1dcdf95c5218eeb6f3bf1efa991ee7a68cccf" +checksum = "e79384be7f8f5a9dd5d7167216f022090cf1f9ec128e6e6a482a2cb5c5422c56" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6457,22 +9565,22 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "afc340c74d9005395cf9dd098506f7f44e38f2b4a21c6aaacf9a105ea5e1e836" +checksum = "26c6ab57572f7a24a4985830b120de1594465e5d500f24afe89e16b4e833ef68" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", "wasm-bindgen-backend", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.93" +version = "0.2.95" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c62a0a307cb4a311d3a07867860911ca130c3494e8c2719593806c08bc5d0484" +checksum = "65fc09f10666a9f147042251e0dda9c18f166ff7de300607007e96bdebc1068d" [[package]] name = "wasm-streams" @@ -6549,9 +9657,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.70" +version = "0.3.72" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26fdeaafd9bd129f65e7c031593c24d62186301e0c72c8978fa1678be7d532c0" +checksum = "f6488b90108c040df0fe62fa815cbdee25124641df01814dd7282749234c6112" dependencies = [ "js-sys", "wasm-bindgen", @@ -6618,6 +9726,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.4", + "libwebp-sys", +] + [[package]] name = "webpki-roots" version = "0.26.6" @@ -6649,7 +9767,7 @@ checksum = "1d228f15bba3b9d56dde8bddbee66fa24545bd17b48d5128ccf4a8742b18e431" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", ] [[package]] @@ -6663,6 +9781,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" @@ -6803,7 +9927,7 @@ checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", ] [[package]] @@ -6814,7 +9938,7 @@ checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", ] [[package]] @@ -7108,6 +10232,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" @@ -7118,33 +10252,57 @@ 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" -source = "git+https://github.com/modrinth/wry?rev=27fb16b#27fb16b3d80853f35316836f39bf8db982f5e82f" +version = "0.46.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6fa1c8c760041c64ce6be99f83d6cb55fe3fcd85a1ad46d32895f6e65cee87ba" dependencies = [ "base64 0.22.1", - "block", - "cocoa 0.26.0", - "core-graphics 0.24.0", + "block2", "crossbeam-channel", "dpi", "dunce", "gdkx11", "gtk", "html5ever", - "http", + "http 1.1.0", "javascriptcore-rs", "jni", "kuchikiki", "libc", "ndk", - "objc", - "objc_id", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", "once_cell", "percent-encoding", "raw-window-handle 0.6.2", - "sha2", + "sha2 0.10.8", "soup3", "tao-macros", "thiserror", @@ -7157,6 +10315,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" @@ -7199,6 +10366,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.82", + "synstructure", +] + [[package]] name = "zbus" version = "4.4.0" @@ -7216,7 +10447,7 @@ dependencies = [ "async-trait", "blocking", "enumflags2", - "event-listener", + "event-listener 5.3.1", "futures-core", "futures-sink", "futures-util", @@ -7226,7 +10457,7 @@ dependencies = [ "rand 0.8.5", "serde", "serde_repr", - "sha1", + "sha1 0.10.6", "static_assertions", "tokio", "tracing", @@ -7247,7 +10478,7 @@ dependencies = [ "proc-macro-crate 3.2.0", "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", "zvariant_utils", ] @@ -7280,7 +10511,28 @@ checksum = "fa4f8080344d4671fb4e831a13ad1e68092748387dfc4f55e356242fae12ce3e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", +] + +[[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.82", + "synstructure", ] [[package]] @@ -7289,6 +10541,28 @@ 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.82", +] + [[package]] name = "zip" version = "0.6.6" @@ -7298,13 +10572,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", ] @@ -7371,6 +10645,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" @@ -7394,7 +10677,7 @@ dependencies = [ "proc-macro-crate 3.2.0", "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", "zvariant_utils", ] @@ -7406,5 +10689,26 @@ checksum = "c51bcff7cc3dbb5055396bcf774748c3dab426b4b8659046963523cee4808340" dependencies = [ "proc-macro2", "quote", - "syn 2.0.79", + "syn 2.0.82", ] + +[[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", +] + +[[patch.unused]] +name = "wry" +version = "0.44.1" +source = "git+https://github.com/modrinth/wry?rev=27fb16b#27fb16b3d80853f35316836f39bf8db982f5e82f" diff --git a/Cargo.toml b/Cargo.toml index ed2ddbc4..2edb492b 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-frontend/package.json b/apps/app-frontend/package.json index 8eda8d70..ec91b269 100644 --- a/apps/app-frontend/package.json +++ b/apps/app-frontend/package.json @@ -1,7 +1,7 @@ { "name": "@modrinth/app-frontend", "private": true, - "version": "0.8.801", + "version": "0.8.901", "development_build": true, "type": "module", "scripts": { diff --git a/apps/app-frontend/src/components/ui/InstanceCreationModal.vue b/apps/app-frontend/src/components/ui/InstanceCreationModal.vue index 6e3a51d1..6135b995 100644 --- a/apps/app-frontend/src/components/ui/InstanceCreationModal.vue +++ b/apps/app-frontend/src/components/ui/InstanceCreationModal.vue @@ -218,7 +218,6 @@ import { get_game_versions, get_loader_versions } from '@/helpers/metadata' import { handleError } from '@/store/notifications.js' import Multiselect from 'vue-multiselect' import { trackEvent } from '@/helpers/analytics' -import { listen } from '@tauri-apps/api/event' import { install_from_file } from '@/helpers/pack.js' import { get_default_launcher_path, @@ -226,6 +225,7 @@ import { import_instance, } from '@/helpers/import.js' import ProgressBar from '@/components/ui/ProgressBar.vue' +import { getCurrentWebview } from '@tauri-apps/api/webview' const profile_name = ref('') const game_version = ref('') @@ -255,13 +255,15 @@ defineExpose({ isShowing.value = true modal.value.show() - unlistener.value = await listen('tauri://file-drop', async (event) => { + unlistener.value = await getCurrentWebview().onDragDropEvent(async (event) => { // Only if modal is showing if (!isShowing.value) return + if (event.payload.type !== 'drop') return if (creationType.value !== 'from file') return hide() - if (event.payload && event.payload.length > 0 && event.payload[0].endsWith('.mrpack')) { - await install_from_file(event.payload[0]).catch(handleError) + const { paths } = event.payload + if (paths && paths.length > 0 && paths[0].endsWith('.mrpack')) { + await install_from_file(paths[0]).catch(handleError) trackEvent('InstanceCreate', { source: 'CreationModalFileDrop', }) diff --git a/apps/app-frontend/src/pages/instance/Mods.vue b/apps/app-frontend/src/pages/instance/Mods.vue index 91bc317e..f686b6bb 100644 --- a/apps/app-frontend/src/pages/instance/Mods.vue +++ b/apps/app-frontend/src/pages/instance/Mods.vue @@ -378,7 +378,6 @@ import { } from '@/helpers/profile.js' import { handleError } from '@/store/notifications.js' import { trackEvent } from '@/helpers/analytics' -import { listen } from '@tauri-apps/api/event' import { highlightModInProfile } from '@/helpers/utils.js' import { MenuIcon, ToggleIcon, TextInputIcon, AddProjectImage, PackageIcon } from '@/assets/icons' import ExportModal from '@/components/ui/ExportModal.vue' @@ -393,6 +392,7 @@ import { import { profile_listener } from '@/helpers/events.js' import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue' import ShareModalWrapper from '@/components/ui/modal/ShareModalWrapper.vue' +import { getCurrentWebview } from '@tauri-apps/api/webview' const props = defineProps({ instance: { @@ -879,8 +879,10 @@ async function refreshProjects() { refreshingProjects.value = false } -const unlisten = await listen('tauri://file-drop', async (event) => { - for (const file of event.payload) { +const unlisten = await getCurrentWebview().onDragDropEvent(async (event) => { + if (event.payload.type !== 'drop') return + + for (const file of event.payload.paths) { if (file.endsWith('.mrpack')) continue await add_project_from_path(props.instance.path, file).catch(handleError) } diff --git a/apps/app-playground/package.json b/apps/app-playground/package.json index 4e4825b8..0d76eaed 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/Cargo.toml b/apps/app/Cargo.toml index e5a317ca..409038c0 100644 --- a/apps/app/Cargo.toml +++ b/apps/app/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "theseus_gui" -version = "0.8.8" +version = "0.8.9" description = "The Modrinth App is a desktop application for managing your Minecraft mods" license = "GPL-3.0-only" repository = "https://github.com/modrinth/code/apps/app/" diff --git a/apps/app/gen/schemas/desktop-schema.json b/apps/app/gen/schemas/desktop-schema.json index 94dac737..ca504780 100644 --- a/apps/app/gen/schemas/desktop-schema.json +++ b/apps/app/gen/schemas/desktop-schema.json @@ -37,7 +37,7 @@ ], "definitions": { "Capability": { - "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows fine grained access to the Tauri core, application, or plugin commands. If a window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, \"platforms\": [\"macOS\",\"windows\"] } ```", + "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows fine grained access to the Tauri core, application, or plugin commands. If a window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", "type": "object", "required": [ "identifier", diff --git a/apps/app/gen/schemas/macOS-schema.json b/apps/app/gen/schemas/macOS-schema.json index 94dac737..ca504780 100644 --- a/apps/app/gen/schemas/macOS-schema.json +++ b/apps/app/gen/schemas/macOS-schema.json @@ -37,7 +37,7 @@ ], "definitions": { "Capability": { - "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows fine grained access to the Tauri core, application, or plugin commands. If a window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, \"platforms\": [\"macOS\",\"windows\"] } ```", + "description": "A grouping and boundary mechanism developers can use to isolate access to the IPC layer.\n\nIt controls application windows fine grained access to the Tauri core, application, or plugin commands. If a window is not matching any capability then it has no access to the IPC layer at all.\n\nThis can be done to create groups of windows, based on their required system access, which can reduce impact of frontend vulnerabilities in less privileged windows. Windows can be added to a capability by exact name (e.g. `main-window`) or glob patterns like `*` or `admin-*`. A Window can have none, one, or multiple associated capabilities.\n\n## Example\n\n```json { \"identifier\": \"main-user-files-write\", \"description\": \"This capability allows the `main` window on macOS and Windows access to `filesystem` write related commands and `dialog` commands to enable programatic access to files selected by the user.\", \"windows\": [ \"main\" ], \"permissions\": [ \"core:default\", \"dialog:open\", { \"identifier\": \"fs:allow-write-text-file\", \"allow\": [{ \"path\": \"$HOME/test.txt\" }] }, ], \"platforms\": [\"macOS\",\"windows\"] } ```", "type": "object", "required": [ "identifier", diff --git a/apps/app/package.json b/apps/app/package.json index 30d9cfff..9ed027b2 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/app/tauri.conf.json b/apps/app/tauri.conf.json index 1ecf5dd4..66163cc3 100644 --- a/apps/app/tauri.conf.json +++ b/apps/app/tauri.conf.json @@ -49,9 +49,9 @@ ] }, "productName": "AstralRinth App", - "version": "0.8.8", - "identifier": "AstralRinthApp", + "version": "0.8.9", "mainBinaryName": "AstralRinth App", + "identifier": "AstralRinthApp", "plugins": { "deep-link": { "desktop": { diff --git a/apps/docs/.gitignore b/apps/docs/.gitignore new file mode 100644 index 00000000..6240da8b --- /dev/null +++ b/apps/docs/.gitignore @@ -0,0 +1,21 @@ +# build output +dist/ +# generated types +.astro/ + +# dependencies +node_modules/ + +# logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + + +# environment variables +.env +.env.production + +# macOS-specific files +.DS_Store diff --git a/apps/docs/.vscode/extensions.json b/apps/docs/.vscode/extensions.json new file mode 100644 index 00000000..22a15055 --- /dev/null +++ b/apps/docs/.vscode/extensions.json @@ -0,0 +1,4 @@ +{ + "recommendations": ["astro-build.astro-vscode"], + "unwantedRecommendations": [] +} diff --git a/apps/docs/.vscode/launch.json b/apps/docs/.vscode/launch.json new file mode 100644 index 00000000..d6422097 --- /dev/null +++ b/apps/docs/.vscode/launch.json @@ -0,0 +1,11 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "command": "./node_modules/.bin/astro dev", + "name": "Development server", + "request": "launch", + "type": "node-terminal" + } + ] +} diff --git a/apps/docs/LICENSE b/apps/docs/LICENSE new file mode 100644 index 00000000..1ac4f7cb --- /dev/null +++ b/apps/docs/LICENSE @@ -0,0 +1,34 @@ +Creative Commons Legal Code +CC0 1.0 Universal +Official translations of this legal tool are available + + CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER. + +Statement of Purpose + +The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work"). + +Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others. + +For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights. + +1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following: + + the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; + moral rights retained by the original author(s) and/or performer(s); + publicity and privacy rights pertaining to a person's image or likeness depicted in a Work; + rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below; + rights protecting the extraction, dissemination, use and reuse of data in a Work; + database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and + other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof. + +2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose. + +3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose. + +4. Limitations and Disclaimers. + + No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. + Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. + Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. + Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. \ No newline at end of file diff --git a/apps/docs/README.md b/apps/docs/README.md new file mode 100644 index 00000000..b3ee74c8 --- /dev/null +++ b/apps/docs/README.md @@ -0,0 +1,23 @@ +# Modrinth Documentation + +Welcome to the Modrinth documentation! + +## Development + +### Pre-requisites + +Before you begin, ensure you have the following installed on your machine: + +- [Node.js](https://nodejs.org/en/) +- [pnpm](https://pnpm.io/) + +### Setup + +Follow these steps to set up your development environment: + +```bash +pnpm install +pnpm docs:dev +``` + +You should now have a development build of the documentation site running with hot-reloading enabled. Any changes you make to the code will automatically refresh the browser. diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs new file mode 100644 index 00000000..a311a155 --- /dev/null +++ b/apps/docs/astro.config.mjs @@ -0,0 +1,51 @@ +import { defineConfig } from 'astro/config'; +import starlight from '@astrojs/starlight'; +import starlightOpenAPI, { openAPISidebarGroups } from 'starlight-openapi' + +// https://astro.build/config +export default defineConfig({ + site: 'https://docs.modrinth.com', + integrations: [ + starlight({ + title: 'Modrinth Documentation', + editLink: { + baseUrl: 'https://github.com/modrinth/code/edit/main/apps/docs/', + }, + social: { + github: 'https://github.com/modrinth/code', + discord: 'https://discord.modrinth.com', + 'x.com': 'https://x.com/modrinth', + mastodon: 'https://floss.social/@modrinth', + threads: 'https://threads.net/@modrinth', + }, + logo: { + light: './src/assets/light-logo.svg', + dark: './src/assets/dark-logo.svg', + replacesTitle: true, + }, + customCss: [ + '@modrinth/assets/styles/variables.scss', + '@modrinth/assets/styles/inter.scss', + './src/styles/modrinth.css', + ], + plugins: [ + // Generate the OpenAPI documentation pages. + starlightOpenAPI([ + { + base: 'api', + label: 'Modrinth API', + schema: './public/openapi.yaml', + }, + ]) + ], + sidebar: [ + { + label: 'Contributing to Modrinth', + autogenerate: { directory: 'contributing' }, + }, + // Add the generated sidebar group to the sidebar. + ...openAPISidebarGroups, + ], + }), + ], +}); diff --git a/apps/docs/package.json b/apps/docs/package.json new file mode 100644 index 00000000..5b226627 --- /dev/null +++ b/apps/docs/package.json @@ -0,0 +1,21 @@ +{ + "name": "@modrinth/docs", + "type": "module", + "version": "0.0.1", + "scripts": { + "dev": "astro dev", + "start": "astro dev", + "build": "astro check && astro build", + "preview": "astro preview", + "astro": "astro" + }, + "dependencies": { + "@astrojs/check": "^0.9.3", + "@astrojs/starlight": "^0.26.3", + "@modrinth/assets": "workspace:*", + "astro": "^4.10.2", + "sharp": "^0.32.5", + "starlight-openapi": "^0.7.0", + "typescript": "^5.5.4" + } +} diff --git a/apps/docs/public/favicon.ico b/apps/docs/public/favicon.ico new file mode 100644 index 00000000..c2ccbab9 Binary files /dev/null and b/apps/docs/public/favicon.ico differ diff --git a/apps/docs/public/openapi.yaml b/apps/docs/public/openapi.yaml new file mode 100644 index 00000000..c4e023be --- /dev/null +++ b/apps/docs/public/openapi.yaml @@ -0,0 +1,3882 @@ +openapi: '3.0.0' + +info: + version: v2.7.0/15cf3fc + title: Labrinth + termsOfService: https://modrinth.com/legal/terms + contact: + name: Modrinth Support + url: https://support.modrinth.com + email: support@modrinth.com + description: | + This documentation doesn't provide a way to test our API. In order to facilitate testing, we recommend the following tools: + + - [cURL](https://curl.se/) (recommended, command-line) + - [ReqBIN](https://reqbin.com/) (recommended, online) + - [Postman](https://www.postman.com/downloads/) + - [Insomnia](https://insomnia.rest/) + - Your web browser, if you don't need to send headers or a request body + + Once you have a working client, you can test that it works by making a `GET` request to `https://staging-api.modrinth.com/`: + + ```json + { + "about": "Welcome traveler!", + "documentation": "https://docs.modrinth.com", + "name": "modrinth-labrinth", + "version": "2.7.0" + } + ``` + + If you got a response similar to the one above, you can use the Modrinth API! + When you want to go live using the production API, use `api.modrinth.com` instead of `staging-api.modrinth.com`. + + ## Authentication + This API has two options for authentication: personal access tokens and [OAuth2](https://en.wikipedia.org/wiki/OAuth). + All tokens are tied to a Modrinth user and use the `Authorization` header of the request. + + Example: + ``` + Authorization: mrp_RNtLRSPmGj2pd1v1ubi52nX7TJJM9sznrmwhAuj511oe4t1jAqAQ3D6Wc8Ic + ``` + + You do not need a token for most requests. Generally speaking, only the following types of requests require a token: + - those which create data (such as version creation) + - those which modify data (such as editing a project) + - those which access private data (such as draft projects, notifications, emails, and payout data) + + Each request requiring authentication has a certain scope. For example, to view the email of the user being requested, the token must have the `USER_READ_EMAIL` scope. + You can find the list of available scopes [on GitHub](https://github.com/modrinth/labrinth/blob/master/src/models/pats.rs#L15). Making a request with an invalid scope will return a 401 error. + + Please note that certain scopes and requests cannot be completed with a personal access token or using OAuth. + For example, deleting a user account can only be done through Modrinth's frontend. + + ### OAuth2 + Applications interacting with the authenticated API should create an OAuth2 application. + You can do this in [the developer settings](https://modrinth.com/settings/applications). + + Once you have created a client, use the following URL to have a user authorize your client: + ``` + https://modrinth.com/auth/authorize?client_id=&redirect_uri=&scope=++ + ``` + + Then, use the following URL to get the token: + ``` + https://api.modrinth.com/_internal/oauth/token + ``` + + This route will be changed in the future to move the `_internal` part to `v3`. + + ### Personal access tokens + Personal access tokens (PATs) can be generated in from [the user settings](https://modrinth.com/settings/account). + + ### GitHub tokens + For backwards compatibility purposes, some types of GitHub tokens also work for authenticating a user with Modrinth's API, granting all scopes. + **We urge any application still using GitHub tokens to start using personal access tokens for security and reliability purposes.** + GitHub tokens will cease to function to authenticate with Modrinth's API as soon as version 3 of the API is made generally available. + + ## Cross-Origin Resource Sharing + This API features Cross-Origin Resource Sharing (CORS) implemented in compliance with the [W3C spec](https://www.w3.org/TR/cors/). + This allows for cross-domain communication from the browser. + All responses have a wildcard same-origin which makes them completely public and accessible to everyone, including any code on any site. + + ## Identifiers + The majority of items you can interact with in the API have a unique eight-digit base62 ID. + Projects, versions, users, threads, teams, and reports all use this same way of identifying themselves. + Version files use the sha1 or sha512 file hashes as identifiers. + + Each project and user has a friendlier way of identifying them; slugs and usernames, respectively. + While unique IDs are constant, slugs and usernames can change at any moment. + If you want to store something in the long term, it is recommended to use the unique ID. + + ## Ratelimits + The API has a ratelimit defined per IP. Limits and remaining amounts are given in the response headers. + - `X-Ratelimit-Limit`: the maximum number of requests that can be made in a minute + - `X-Ratelimit-Remaining`: the number of requests remaining in the current ratelimit window + - `X-Ratelimit-Reset`: the time in seconds until the ratelimit window resets + + Ratelimits are the same no matter whether you use a token or not. + The ratelimit is currently 300 requests per minute. If you have a use case requiring a higher limit, please [contact us](mailto:admin@modrinth.com). + + ## User Agents + To access the Modrinth API, you **must** use provide a uniquely-identifying `User-Agent` header. + Providing a user agent that only identifies your HTTP client library (such as "okhttp/4.9.3") increases the likelihood that we will block your traffic. + It is recommended, but not required, to include contact information in your user agent. + This allows us to contact you if we would like a change in your application's behavior without having to block your traffic. + - Bad: `User-Agent: okhttp/4.9.3` + - Good: `User-Agent: project_name` + - Better: `User-Agent: github_username/project_name/1.56.0` + - Best: `User-Agent: github_username/project_name/1.56.0 (launcher.com)` or `User-Agent: github_username/project_name/1.56.0 (contact@launcher.com)` + + ## Versioning + Modrinth follows a simple pattern for its API versioning. + In the event of a breaking API change, the API version in the URL path is bumped, and migration steps will be published below. + + When an API is no longer the current one, it will immediately be considered deprecated. + No more support will be provided for API versions older than the current one. + It will be kept for some time, but this amount of time is not certain. + + We will exercise various tactics to get people to update their implementation of our API. + One example is by adding something like `STOP USING THIS API` to various data returned by the API. + + Once an API version is completely deprecated, it will permanently return a 410 error. + Please ensure your application handles these 410 errors. + + ### Migrations + Inside the following spoiler, you will be able to find all changes between versions of the Modrinth API, accompanied by tips and a guide to migrate applications to newer versions. + + Here, you can also find changes for [Minotaur](https://github.com/modrinth/minotaur), Modrinth's official Gradle plugin. Major versions of Minotaur directly correspond to major versions of the Modrinth API. + +
API v1 to API v2 + + These bullet points cover most changes in the v2 API, but please note that fields containing `mod` in most contexts have been shifted to `project`. For example, in the search route, the field `mod_id` was renamed to `project_id`. + + - The search route has been moved from `/api/v1/mod` to `/v2/search` + - New project fields: `project_type` (may be `mod` or `modpack`), `moderation_message` (which has a `message` and `body`), `gallery` + - New search facet: `project_type` + - Alphabetical sort removed (it didn't work and is not possible due to limits in MeiliSearch) + - New search fields: `project_type`, `gallery` + - The gallery field is an array of URLs to images that are part of the project's gallery + - The gallery is a new feature which allows the user to upload images showcasing their mod to the CDN which will be displayed on their mod page + - Internal change: Any project file uploaded to Modrinth is now validated to make sure it's a valid Minecraft mod, Modpack, etc. + - For example, a Forge 1.17 mod with a JAR not containing a mods.toml will not be allowed to be uploaded to Modrinth + - In project creation, projects may not upload a mod with no versions to review, however they can be saved as a draft + - Similarly, for version creation, a version may not be uploaded without any files + - Donation URLs have been enabled + - New project status: `archived`. Projects with this status do not appear in search + - Tags (such as categories, loaders) now have icons (SVGs) and specific project types attached + - Dependencies have been wiped and replaced with a new system + - Notifications now have a `type` field, such as `project_update` + + Along with this, project subroutes (such as `/v2/project/{id}/version`) now allow the slug to be used as the ID. This is also the case with user routes. + +
Minotaur v1 to Minotaur v2 + + Minotaur 2.x introduced a few breaking changes to how your buildscript is formatted. + + First, instead of registering your own `publishModrinth` task, Minotaur now automatically creates a `modrinth` task. As such, you can replace the `task publishModrinth(type: TaskModrinthUpload) {` line with just `modrinth {`. + + To declare supported Minecraft versions and mod loaders, the `gameVersions` and `loaders` arrays must now be used. The syntax for these are pretty self-explanatory. + + Instead of using `releaseType`, you must now use `versionType`. This was actually changed in v1.2.0, but very few buildscripts have moved on from v1.1.0. + + Dependencies have been changed to a special DSL. Create a `dependencies` block within the `modrinth` block, and then use `scope.type("project/version")`. For example, `required.project("fabric-api")` adds a required project dependency on Fabric API. + + You may now use the slug anywhere that a project ID was previously required. + +
+ +# The above snippet about User Agents was adapted from https://crates.io/policies, copyright (c) 2014 The Rust Project Developers under MIT license + +servers: + - url: https://api.modrinth.com/v2 + description: Production server + - url: https://staging-api.modrinth.com/v2 + description: Staging server + +components: + parameters: + ProjectIdentifier: + name: id|slug + in: path + required: true + description: The ID or slug of the project + schema: + type: string + example: [AABBCCDD, my_project] + MultipleProjectIdentifier: + in: query + name: ids + description: The IDs and/or slugs of the projects + schema: + type: string + example: "[\"AABBCCDD\", \"EEFFGGHH\"]" + required: true + UserIdentifier: + name: id|username + in: path + required: true + description: The ID or username of the user + schema: + type: string + example: [EEFFGGHH, my_user] + VersionIdentifier: + name: id + in: path + required: true + description: The ID of the version + schema: + type: string + example: [IIJJKKLL] + TeamIdentifier: + name: id + in: path + required: true + description: The ID of the team + schema: + type: string + example: [MMNNOOPP] + ReportIdentifier: + name: id + in: path + required: true + description: The ID of the report + schema: + type: string + example: [RRSSTTUU] + ThreadIdentifier: + name: id + in: path + required: true + description: The ID of the thread + schema: + type: string + example: [QQRRSSTT] + NotificationIdentifier: + name: id + in: path + required: true + description: The ID of the notification + schema: + type: string + example: [NNOOPPQQ] + AlgorithmIdentifier: + name: algorithm + in: query + required: true + description: The algorithm of the hash + schema: + type: string + enum: [sha1, sha512] + example: sha512 + default: sha1 + MultipleHashQueryIdentifier: + name: multiple + in: query + required: false + description: Whether to return multiple results when looking for this hash + schema: + type: boolean + default: false + FileHashIdentifier: + name: hash + in: path + required: true + description: The hash of the file, considering its byte content, and encoded in hexadecimal + schema: + type: string + example: 619e250c133106bacc3e3b560839bd4b324dfda8 + requestBodies: + Image: + content: + image/png: + schema: + type: string + format: binary + image/jpeg: + schema: + type: string + format: binary + image/bmp: + schema: + type: string + format: binary + image/gif: + schema: + type: string + format: binary + image/webp: + schema: + type: string + format: binary + image/svg: + schema: + type: string + format: binary + image/svgz: + schema: + type: string + format: binary + image/rgb: + schema: + type: string + format: binary + schemas: + # Version + BaseVersion: + type: object + properties: + name: + type: string + description: The name of this version + example: "Version 1.0.0" + version_number: + type: string + description: "The version number. Ideally will follow semantic versioning" + example: "1.0.0" + changelog: + type: string + description: "The changelog for this version" + example: "List of changes in this version: ..." + nullable: true + dependencies: + type: array + items: + $ref: "#/components/schemas/VersionDependency" + description: A list of specific versions of projects that this version depends on + game_versions: + type: array + items: + type: string + description: A list of versions of Minecraft that this version supports + example: ["1.16.5", "1.17.1"] + version_type: + type: string + description: The release channel for this version + enum: [release, beta, alpha] + example: release + loaders: + type: array + items: + type: string + description: The mod loaders that this version supports + example: ["fabric", "forge"] + featured: + type: boolean + description: Whether the version is featured or not + example: true + status: + type: string + enum: [listed, archived, draft, unlisted, scheduled, unknown] + example: listed + requested_status: + type: string + enum: [listed, archived, draft, unlisted] + nullable: true + VersionDependency: + type: object + properties: + version_id: + type: string + description: The ID of the version that this version depends on + example: IIJJKKLL + nullable: true + project_id: + type: string + description: The ID of the project that this version depends on + example: QQRRSSTT + nullable: true + file_name: + type: string + description: The file name of the dependency, mostly used for showing external dependencies on modpacks + example: sodium-fabric-mc1.19-0.4.2+build.16.jar + nullable: true + dependency_type: + type: string + enum: [ required, optional, incompatible, embedded ] + description: The type of dependency that this version has + example: required + required: + - dependency_type + + # https://github.com/modrinth/labrinth/blob/master/src/routes/versions.rs#L169-L190 + EditableVersion: + allOf: + - $ref: '#/components/schemas/BaseVersion' + - type: object + properties: + primary_file: + type: array + items: + type: string + example: [sha1, aaaabbbbccccddddeeeeffffgggghhhhiiiijjjj] + description: The hash format and the hash of the new primary file + file_types: + type: array + items: + $ref: '#/components/schemas/EditableFileType' + description: A list of file_types to edit + EditableFileType: + type: object + properties: + algorithm: + type: string + description: The hash algorithm of the hash specified in the hash field + example: sha1 + hash: + type: string + description: The hash of the file you're editing + example: aaaabbbbccccddddeeeeffffgggghhhhiiiijjjj + file_type: + type: string + enum: [ required-resource-pack, optional-resource-pack ] + description: The hash algorithm of the file you're editing + example: required-resource-pack + nullable: true + required: + - algorithm + - hash + - file_type + # https://github.com/modrinth/labrinth/blob/master/src/routes/version_creation.rs#L27-L57 + CreatableVersion: + allOf: + - $ref: '#/components/schemas/BaseVersion' + - type: object + properties: + project_id: + type: string + description: The ID of the project this version is for + example: AABBCCDD + file_parts: + type: array + items: + type: string + description: An array of the multipart field names of each file that goes with this version + primary_file: + type: string + description: The multipart field name of the primary file + required: + - file_parts + - project_id + - name + - version_number + - game_versions + - version_type + - loaders + - featured + - dependencies + CreateVersionBody: + type: object + properties: + data: + $ref: '#/components/schemas/CreatableVersion' + required: [ data ] + Version: + allOf: + - $ref: '#/components/schemas/BaseVersion' + - type: object + properties: + id: + type: string + description: The ID of the version, encoded as a base62 string + example: IIJJKKLL + project_id: + type: string + description: The ID of the project this version is for + example: AABBCCDD + author_id: + type: string + description: The ID of the author who published this version + example: EEFFGGHH + date_published: + type: string + format: ISO-8601 + downloads: + type: integer + description: The number of times this version has been downloaded + changelog_url: + type: string + description: A link to the changelog for this version. Always null, only kept for legacy compatibility. + deprecated: true + example: null + nullable: true + files: + type: array + items: + $ref: '#/components/schemas/VersionFile' + description: A list of files available for download for this version + required: + - id + - project_id + - author_id + - date_published + - downloads + - files + - name + - version_number + - game_versions + - version_type + - loaders + - featured + VersionFile: + type: object + properties: + hashes: + $ref: '#/components/schemas/VersionFileHashes' + url: + type: string + example: "https://cdn.modrinth.com/data/AABBCCDD/versions/1.0.0/my_file.jar" + description: A direct link to the file + filename: + type: string + example: "my_file.jar" + description: The name of the file + primary: + type: boolean + example: false + description: Whether this file is the primary one for its version. Only a maximum of one file per version will have this set to true. If there are not any primary files, it can be inferred that the first file is the primary one. + size: + type: integer + example: 1097270 + description: The size of the file in bytes + file_type: + type: string + enum: [ required-resource-pack, optional-resource-pack ] + description: The type of the additional file, used mainly for adding resource packs to datapacks + example: required-resource-pack + nullable: true + required: + - hashes + - url + - filename + - primary + - size + VersionFileHashes: + type: object + properties: + sha512: + type: string + example: 93ecf5fe02914fb53d94aa3d28c1fb562e23985f8e4d48b9038422798618761fe208a31ca9b723667a4e05de0d91a3f86bcd8d018f6a686c39550e21b198d96f + sha1: + type: string + example: c84dd4b3580c02b79958a0590afd5783d80ef504 + description: A map of hashes of the file. The key is the hashing algorithm and the value is the string version of the hash. + GetLatestVersionFromHashBody: + type: object + properties: + loaders: + type: array + items: + type: string + example: [ fabric ] + game_versions: + type: array + items: + type: string + example: [ "1.18", 1.18.1 ] + required: + - loaders + - game_versions + HashVersionMap: + description: "A map from hashes to versions" + type: object + additionalProperties: + $ref: '#/components/schemas/Version' + HashList: + description: "A list of hashes and the algorithm used to create them" + type: object + properties: + hashes: + type: array + items: + type: string + example: [ ea0f38408102e4d2efd53c2cc11b88b711996b48d8922f76ea6abf731219c5bd1efe39ddf9cce77c54d49a62ff10fb685c00d2e4c524ab99d20f6296677ab2c4, 925a5c4899affa4098d997dfa4a4cb52c636d539e94bc489d1fa034218cb96819a70eb8b01647a39316a59fcfe223c1a8c05ed2e2ae5f4c1e75fa48f6af1c960 ] + algorithm: + type: string + enum: [ sha1, sha512 ] + example: sha512 + required: + - hashes + - algorithm + GetLatestVersionsFromHashesBody: + allOf: + - $ref: '#/components/schemas/HashList' + - type: object + properties: + loaders: + type: array + items: + type: string + example: [ fabric ] + game_versions: + type: array + items: + type: string + example: [ "1.18", 1.18.1 ] + required: + - loaders + - game_versions + # Project + # Fields that can be used in everything. Search, direct project lookup, project editing, you name it. + BaseProject: + type: object + properties: + slug: + type: string + description: "The slug of a project, used for vanity URLs. Regex: ```^[\\w!@$()`.+,\"\\-']{3,64}$```" + example: my_project + title: + type: string + description: The title or name of the project + example: My Project + description: + type: string + description: A short description of the project + example: A short description + categories: + type: array + items: + type: string + example: [technology, adventure, fabric] + description: A list of the categories that the project has + client_side: + type: string + enum: [required, optional, unsupported, unknown] + description: The client side support of the project + example: required + server_side: + type: string + enum: [required, optional, unsupported, unknown] + description: The server side support of the project + example: optional + # Fields added to search results and direct project lookups that cannot be edited. + ServerRenderedProject: + allOf: + - $ref: '#/components/schemas/BaseProject' + - type: object + properties: + project_type: + type: string + enum: [mod, modpack, resourcepack, shader] + description: The project type of the project + example: mod + downloads: + type: integer + description: The total number of downloads of the project + icon_url: + type: string + example: https://cdn.modrinth.com/data/AABBCCDD/b46513nd83hb4792a9a0e1fn28fgi6090c1842639.png + description: The URL of the project's icon + nullable: true + color: + type: integer + example: 8703084 + description: The RGB color of the project, automatically generated from the project icon + nullable: true + thread_id: + type: string + example: TTUUVVWW + description: The ID of the moderation thread associated with this project + monetization_status: + type: string + enum: [monetized, demonetized, force-demonetized] + required: + - project_type + - downloads + # The actual result in search. + ProjectResult: + allOf: + - $ref: '#/components/schemas/ServerRenderedProject' + - type: object + properties: + project_id: + type: string + description: The ID of the project + example: AABBCCDD + author: + type: string + description: The username of the project's author + example: my_user + display_categories: + type: array + items: + type: string + description: A list of the categories that the project has which are not secondary + example: ["technology", "fabric"] + versions: + type: array + items: + type: string + description: A list of the minecraft versions supported by the project + example: ["1.8", "1.8.9"] + follows: + type: integer + description: The total number of users following the project + date_created: + type: string + format: ISO-8601 + description: The date the project was added to search + date_modified: + type: string + format: ISO-8601 + description: The date the project was last modified + latest_version: + type: string + description: The latest version of minecraft that this project supports + example: 1.8.9 + license: + type: string + description: The SPDX license ID of a project + example: MIT + gallery: + type: array + description: All gallery images attached to the project + example: [https://cdn.modrinth.com/data/AABBCCDD/images/009b7d8d6e8bf04968a29421117c59b3efe2351a.png, https://cdn.modrinth.com/data/AABBCCDD/images/c21776867afb6046fdc3c21dbcf5cc50ae27a236.png] + items: + type: string + featured_gallery: + type: string + description: The featured gallery image of the project + nullable: true + required: + - slug + - title + - description + - client_side + - server_side + - project_id + - author + - versions + - follows + - date_created + - date_modified + - license + # Fields that appear everywhere EXCEPT search. + NonSearchProject: + allOf: + - $ref: '#/components/schemas/BaseProject' + - type: object + properties: + body: + type: string + description: A long form description of the project + example: A long body describing my project in detail + status: + type: string + enum: [approved, archived, rejected, draft, unlisted, processing, withheld, scheduled, private, unknown] + description: The status of the project + example: approved + requested_status: + type: string + enum: [approved, archived, unlisted, private, draft] + description: The requested status when submitting for review or scheduling the project for release + nullable: true + additional_categories: + type: array + items: + type: string + description: A list of categories which are searchable but non-primary + example: [technology, adventure, fabric] + issues_url: + type: string + description: An optional link to where to submit bugs or issues with the project + example: https://github.com/my_user/my_project/issues + nullable: true + source_url: + type: string + description: An optional link to the source code of the project + example: https://github.com/my_user/my_project + nullable: true + wiki_url: + type: string + description: An optional link to the project's wiki page or other relevant information + example: https://github.com/my_user/my_project/wiki + nullable: true + discord_url: + type: string + description: An optional invite link to the project's discord + example: https://discord.gg/AaBbCcDd + nullable: true + donation_urls: + type: array + items: + $ref: '#/components/schemas/ProjectDonationURL' + description: A list of donation links for the project + ProjectDonationURL: + type: object + properties: + id: + type: string + description: The ID of the donation platform + example: patreon + platform: + type: string + description: The donation platform this link is to + example: Patreon + url: + type: string + description: The URL of the donation platform and user + example: https://www.patreon.com/my_user + # Fields available only when editing or creating a project + ModifiableProject: + allOf: + - $ref: '#/components/schemas/NonSearchProject' + - type: object + properties: + license_id: + type: string + description: The SPDX license ID of a project + example: LGPL-3.0-or-later + license_url: + type: string + description: The URL to this license + nullable: true + # Fields that can be edited through a PATCH request. https://github.com/modrinth/labrinth/blob/master/src/routes/projects.rs#L195-L269 + EditableProject: + allOf: + - $ref: '#/components/schemas/ModifiableProject' + - type: object + properties: + moderation_message: + type: string + description: The title of the moderators' message for the project + nullable: true + moderation_message_body: + type: string + description: The body of the moderators' message for the project + nullable: true + # Fields only available for project creation. https://github.com/modrinth/labrinth/blob/master/src/routes/project_creation.rs#L129-L197 + CreatableProject: + allOf: + - $ref: '#/components/schemas/ModifiableProject' + - type: object + properties: + project_type: + type: string + enum: [mod, modpack] + example: modpack + initial_versions: + type: array + items: + $ref: '#/components/schemas/EditableVersion' + description: A list of initial versions to upload with the created project. Deprecated - please upload version files after initial upload. + deprecated: true + is_draft: + type: boolean + description: Whether the project should be saved as a draft instead of being sent to moderation for review. Deprecated - please always mark this as true. + example: true + deprecated: true + gallery_items: + type: array + description: Gallery images to be uploaded with the created project. Deprecated - please upload gallery images after initial upload. + deprecated: true + items: + $ref: '#/components/schemas/CreatableProjectGalleryItem' + required: + - project_type + - slug + - title + - description + - body + - categories + - client_side + - server_side + - license_id + CreatableProjectGalleryItem: + type: object + nullable: true + properties: + item: + type: string + description: The name of the multipart item where the gallery media is located + featured: + type: boolean + description: Whether the image is featured in the gallery + example: true + title: + type: string + description: The title of the gallery image + example: My awesome screenshot! + nullable: true + description: + type: string + description: The description of the gallery image + example: This awesome screenshot shows all of the blocks in my mod! + nullable: true + ordering: + type: integer + description: The order of the gallery image. Gallery images are sorted by this field and then alphabetically by title. + example: 0 + Project: + allOf: + - $ref: '#/components/schemas/NonSearchProject' + - $ref: '#/components/schemas/ServerRenderedProject' + - type: object + properties: + id: + type: string + example: AABBCCDD + description: The ID of the project, encoded as a base62 string + team: + type: string + example: MMNNOOPP + description: The ID of the team that has ownership of this project + body_url: + type: string + deprecated: true + default: null + description: The link to the long description of the project. Always null, only kept for legacy compatibility. + example: null + nullable: true + moderator_message: + $ref: '#/components/schemas/ModeratorMessage' + published: + type: string + format: ISO-8601 + description: The date the project was published + updated: + type: string + format: ISO-8601 + description: The date the project was last updated + approved: + type: string + format: ISO-8601 + description: The date the project's status was set to an approved status + nullable: true + queued: + type: string + format: ISO-8601 + description: The date the project's status was submitted to moderators for review + nullable: true + followers: + type: integer + description: The total number of users following the project + license: + $ref: '#/components/schemas/ProjectLicense' + versions: + type: array + items: + type: string + example: [IIJJKKLL, QQRRSSTT] + description: A list of the version IDs of the project (will never be empty unless `draft` status) + game_versions: + type: array + items: + type: string + example: ["1.19", "1.19.1", "1.19.2", "1.19.3"] + description: A list of all of the game versions supported by the project + loaders: + type: array + items: + type: string + example: ["forge", "fabric", "quilt"] + description: A list of all of the loaders supported by the project + gallery: + type: array + items: + $ref: '#/components/schemas/GalleryImage' + description: A list of images that have been uploaded to the project's gallery + required: + - id + - team + - published + - updated + - followers + - title + - description + - categories + - client_side + - server_side + - slug + - body + - status + ModeratorMessage: + deprecated: true + type: object + properties: + message: + type: string + description: The message that a moderator has left for the project + body: + type: string + description: The longer body of the message that a moderator has left for the project + nullable: true + nullable: true + example: null + description: A message that a moderator sent regarding the project + ProjectLicense: + type: object + properties: + id: + type: string + description: The SPDX license ID of a project + example: LGPL-3.0-or-later + name: + type: string + description: The long name of a license + example: GNU Lesser General Public License v3 or later + url: + type: string + description: The URL to this license + nullable: true + description: The license of the project + GalleryImage: + type: object + nullable: true + properties: + url: + type: string + description: The URL of the gallery image + example: https://cdn.modrinth.com/data/AABBCCDD/images/009b7d8d6e8bf04968a29421117c59b3efe2351a.png + featured: + type: boolean + description: Whether the image is featured in the gallery + example: true + title: + type: string + description: The title of the gallery image + example: My awesome screenshot! + nullable: true + description: + type: string + description: The description of the gallery image + example: This awesome screenshot shows all of the blocks in my mod! + nullable: true + created: + type: string + format: ISO-8601 + description: The date and time the gallery image was created + ordering: + type: integer + description: The order of the gallery image. Gallery images are sorted by this field and then alphabetically by title. + example: 0 + required: + - url + - featured + - created + ProjectDependencyList: + type: object + properties: + projects: + type: array + items: + $ref: '#/components/schemas/Project' + description: Projects that the project depends upon + versions: + type: array + items: + $ref: '#/components/schemas/Version' + description: Versions that the project depends upon + PatchProjectsBody: + type: object + properties: + categories: + description: Set all of the categories to the categories specified here + type: array + items: + type: string + add_categories: + description: Add all of the categories specified here + type: array + items: + type: string + remove_categories: + description: Remove all of the categories specified here + type: array + items: + type: string + additional_categories: + description: Set all of the additional categories to the categories specified here + type: array + items: + type: string + add_additional_categories: + description: Add all of the additional categories specified here + type: array + items: + type: string + remove_additional_categories: + description: Remove all of the additional categories specified here + type: array + items: + type: string + donation_urls: + description: Set all of the donation links to the donation links specified here + type: array + items: + $ref: '#/components/schemas/ProjectDonationURL' + add_donation_urls: + description: Add all of the donation links specified here + type: array + items: + $ref: '#/components/schemas/ProjectDonationURL' + remove_donation_urls: + description: Remove all of the donation links specified here + type: array + items: + $ref: '#/components/schemas/ProjectDonationURL' + issues_url: + type: string + description: An optional link to where to submit bugs or issues with the projects + example: https://github.com/my_user/my_project/issues + nullable: true + source_url: + type: string + description: An optional link to the source code of the projects + example: https://github.com/my_user/my_project + nullable: true + wiki_url: + type: string + description: An optional link to the projects' wiki page or other relevant information + example: https://github.com/my_user/my_project/wiki + nullable: true + discord_url: + type: string + description: An optional invite link to the projects' discord + example: https://discord.gg/AaBbCcDd + nullable: true + CreateProjectBody: + type: object + properties: + data: + $ref: '#/components/schemas/CreatableProject' + icon: + type: string + format: binary + enum: [ "*.png", "*.jpg", "*.jpeg", "*.bmp", "*.gif", "*.webp", "*.svg", "*.svgz", "*.rgb" ] + description: Project icon file + required: [ data ] + ProjectIdentifier: + type: object + properties: + id: + type: string + example: AABBCCDD + Schedule: + type: object + properties: + time: + type: string + format: ISO-8601 + example: "2023-02-05T19:39:55.551839Z" + requested_status: + type: string + enum: [ approved, archived, unlisted, private, draft ] + description: The requested status when scheduling the project for release + required: + - time + - requested_status + # Search + SearchResults: + type: object + properties: + hits: + type: array + items: + $ref: '#/components/schemas/ProjectResult' + description: The list of results + offset: + type: integer + description: The number of results that were skipped by the query + example: 0 + limit: + type: integer + description: The number of results that were returned by the query + example: 10 + total_hits: + type: integer + description: The total number of results that match the query + example: 10 + required: + - hits + - offset + - limit + - total_hits + # User + UserIdentifier: + properties: + user_id: + type: string + example: EEFFGGHH + required: + - user_id + EditableUser: + type: object + properties: + username: + type: string + description: The user's username + example: my_user + name: + type: string + example: My User + description: The user's display name + nullable: true + email: + type: string + format: email + description: The user's email (only displayed if requesting your own account). Requires `USER_READ_EMAIL` PAT scope. + nullable: true + bio: + type: string + example: My short biography + description: A description of the user + payout_data: + $ref: '#/components/schemas/UserPayoutData' + required: + - username + UserPayoutData: + type: object + description: Various data relating to the user's payouts status (you can only see your own) + nullable: true + properties: + balance: + type: integer + description: The payout balance available for the user to withdraw (note, you cannot modify this in a PATCH request) + example: 10.11223344556677889900 + payout_wallet: + type: string + enum: [ paypal, venmo ] + description: The wallet that the user has selected + example: paypal + payout_wallet_type: + type: string + enum: [ email, phone, user_handle ] + description: The type of the user's wallet + example: email + payout_address: + type: string + description: The user's payout address + example: support@modrinth.com + User: + allOf: + - $ref: '#/components/schemas/EditableUser' + - type: object + properties: + id: + type: string + example: EEFFGGHH + description: The user's ID + avatar_url: + type: string + example: https://avatars.githubusercontent.com/u/11223344?v=1 + description: The user's avatar url + created: + type: string + format: ISO-8601 + description: The time at which the user was created + role: + type: string + enum: [admin, moderator, developer] + description: The user's role + example: developer + badges: + type: integer + format: bitfield + example: 63 + description: | + Any badges applicable to this user. These are currently unused and undisplayed, and as such are subject to change + + In order from first to seventh bit, the current bits are: + - (unused) + - EARLY_MODPACK_ADOPTER + - EARLY_RESPACK_ADOPTER + - EARLY_PLUGIN_ADOPTER + - ALPHA_TESTER + - CONTRIBUTOR + - TRANSLATOR + auth_providers: + type: array + items: + type: string + example: [github, gitlab, steam, microsoft, google, discord] + description: A list of authentication providers you have signed up for (only displayed if requesting your own account) + nullable: true + email_verified: + type: boolean + description: Whether your email is verified (only displayed if requesting your own account) + nullable: true + has_password: + type: boolean + description: Whether you have a password associated with your account (only displayed if requesting your own account) + nullable: true + has_totp: + type: boolean + description: Whether you have TOTP two-factor authentication connected to your account (only displayed if requesting your own account) + nullable: true + github_id: + deprecated: true + type: integer + description: Deprecated - this is no longer public for security reasons and is always null + example: null + nullable: true + required: + - id + - avatar_url + - created + - role + UserPayoutHistory: + type: object + properties: + all_time: + type: string + description: The all-time balance accrued by this user in USD + example: 10.11223344556677889900 + last_month: + type: string + description: The amount in USD made by the user in the previous 30 days + example: 2.22446688002244668800 + payouts: + type: array + description: A history of all of the user's past transactions + items: + $ref: '#/components/schemas/UserPayoutHistoryEntry' + UserPayoutHistoryEntry: + type: object + properties: + created: + type: string + format: ISO-8601 + description: The date of this transaction + amount: + type: integer + description: The amount of this transaction in USD + example: 10.00 + status: + type: string + description: The status of this transaction + example: success + # Notifications + Notification: + type: object + properties: + id: + type: string + description: The id of the notification + example: UUVVWWXX + user_id: + type: string + description: The id of the user who received the notification + example: EEFFGGHH + type: + type: string + enum: [project_update, team_invite, status_change, moderator_message] + description: The type of notification + example: project_update + nullable: true + title: + type: string + description: The title of the notification + example: "**My Project** has been updated!" + text: + type: string + description: The body text of the notification + example: "The project, My Project, has released a new version: 1.0.0" + link: + type: string + description: A link to the related project or version + example: mod/AABBCCDD/version/IIJJKKLL + read: + type: boolean + example: false + description: Whether the notification has been read or not + created: + type: string + format: ISO-8601 + description: The time at which the notification was created + actions: + type: array + items: + $ref: '#/components/schemas/NotificationAction' + description: A list of actions that can be performed + required: + - id + - user_id + - title + - text + - link + - read + - created + - actions + NotificationAction: + type: object + description: An action that can be performed on a notification + properties: + title: + type: string + description: The friendly name for this action + example: Accept + action_route: + type: array + items: + type: string + description: The HTTP code and path to request in order to perform this action. + example: [POST, 'team/{id}/join'] + # Reports + CreatableReport: + type: object + properties: + report_type: + type: string + description: The type of the report being sent + example: copyright + item_id: + type: string + description: The ID of the item (project, version, or user) being reported + example: EEFFGGHH + item_type: + type: string + enum: [project, user, version] + description: The type of the item being reported + example: project + body: + type: string + description: The extended explanation of the report + example: This is a reupload of my mod, AABBCCDD! + required: + - report_type + - item_id + - item_type + - body + Report: + type: object + allOf: + - $ref: '#/components/schemas/CreatableReport' + - type: object + properties: + id: + type: string + description: The ID of the report + example: VVWWXXYY + reporter: + type: string + description: The ID of the user who reported the item + example: UUVVWWXX + created: + type: string + format: ISO-8601 + description: The time at which the report was created + closed: + type: boolean + description: Whether the report is resolved + thread_id: + type: string + example: TTUUVVWW + description: The ID of the moderation thread associated with this report + required: + - reporter + - created + - closed + - thread_id + # Threads + Thread: + type: object + properties: + id: + type: string + example: WWXXYYZZ + description: The ID of the thread + type: + type: string + enum: [project, report, direct_message] + project_id: + type: string + nullable: true + description: The ID of the associated project if a project thread + report_id: + type: string + nullable: true + description: The ID of the associated report if a report thread + messages: + type: array + items: + $ref: '#/components/schemas/ThreadMessage' + members: + type: array + items: + $ref: '#/components/schemas/User' + required: + - id + - type + - messages + - members + ThreadMessage: + type: object + properties: + id: + type: string + description: The ID of the message itself + example: MMNNOOPP + author_id: + type: string + description: The ID of the author + example: QQRRSSTT + nullable: true + body: + $ref: '#/components/schemas/ThreadMessageBody' + created: + type: string + format: ISO-8601 + description: The time at which the message was created + required: + - id + - body + - created + ThreadMessageBody: + type: object + description: The contents of the message. **Fields will vary depending on message type.** + properties: + type: + type: string + enum: [ status_change, text, thread_closure, deleted ] + description: The type of message + example: status_change + body: + type: string + description: The actual message text. **Only present for `text` message type** + example: This is the text of the message. + private: + type: boolean + description: Whether the message is only visible to moderators. **Only present for `text` message type** + example: false + replying_to: + type: string + description: The ID of the message being replied to by this message. **Only present for `text` message type** + nullable: true + example: SSTTUUVV + old_status: + type: string + enum: [ approved, archived, rejected, draft, unlisted, processing, withheld, scheduled, private, unknown ] + description: The old status of the project. **Only present for `status_change` message type** + example: processing + new_status: + type: string + enum: [ approved, archived, rejected, draft, unlisted, processing, withheld, scheduled, private, unknown ] + description: The new status of the project. **Only present for `status_change` message type** + example: approved + required: + - type + # Team + TeamMember: + type: object + properties: + team_id: + type: string + example: MMNNOOPP + description: The ID of the team this team member is a member of + user: + $ref: '#/components/schemas/User' + role: + type: string + example: Member + description: The user's role on the team + permissions: + type: integer + format: bitfield + example: 127 + description: | + The user's permissions in bitfield format (requires authorization to view) + + In order from first to tenth bit, the bits are: + - UPLOAD_VERSION + - DELETE_VERSION + - EDIT_DETAILS + - EDIT_BODY + - MANAGE_INVITES + - REMOVE_MEMBER + - EDIT_MEMBER + - DELETE_PROJECT + - VIEW_ANALYTICS + - VIEW_PAYOUTS + accepted: + type: boolean + example: true + description: Whether or not the user has accepted to be on the team (requires authorization to view) + payouts_split: + type: integer + example: 100 + description: The split of payouts going to this user. The proportion of payouts they get is their split divided by the sum of the splits of all members. + ordering: + type: integer + example: 0 + description: The order of the team member. + required: + - team_id + - user + - role + - accepted + # Tags + CategoryTag: + type: object + properties: + icon: + type: string + description: The SVG icon of a category + example: + name: + type: string + description: The name of the category + example: "adventure" + project_type: + type: string + description: The project type this category is applicable to + example: mod + header: + type: string + description: The header under which the category should go + example: "resolutions" + required: + - icon + - name + - project_type + - header + LoaderTag: + type: object + properties: + icon: + type: string + description: The SVG icon of a loader + example: + name: + type: string + description: The name of the loader + example: fabric + supported_project_types: + type: array + items: + type: string + description: The project type + description: The project types that this loader is applicable to + example: [mod, modpack] + required: + - icon + - name + - supported_project_types + GameVersionTag: + type: object + properties: + version: + type: string + description: The name/number of the game version + example: 1.18.1 + version_type: + type: string + enum: [release, snapshot, alpha, beta] + description: The type of the game version + example: release + date: + type: string + format: ISO-8601 + description: The date of the game version release + major: + type: boolean + description: Whether or not this is a major version, used for Featured Versions + example: true + required: + - version + - version_type + - date + - major + DonationPlatformTag: + type: object + properties: + short: + type: string + description: The short identifier of the donation platform + example: bmac + name: + type: string + description: The full name of the donation platform + example: Buy Me a Coffee + required: + - short + - name + ModifyTeamMemberBody: + properties: + role: + type: string + example: Contributor + permissions: + type: integer + format: bitfield + example: 127 + description: | + The user's permissions in bitfield format + + In order from first to tenth bit, the bits are: + - UPLOAD_VERSION + - DELETE_VERSION + - EDIT_DETAILS + - EDIT_BODY + - MANAGE_INVITES + - REMOVE_MEMBER + - EDIT_MEMBER + - DELETE_PROJECT + - VIEW_ANALYTICS + - VIEW_PAYOUTS + payouts_split: + type: integer + example: 100 + description: The split of payouts going to this user. The proportion of payouts they get is their split divided by the sum of the splits of all members. + ordering: + type: integer + example: 0 + description: The order of the team member. + LicenseTag: + type: object + description: A short overview of a license + properties: + short: + type: string + description: The short identifier of the license + example: lgpl-3 + name: + type: string + description: The full name of the license + example: GNU Lesser General Public License v3 + required: + - short + - name + License: + type: object + description: A full license + properties: + title: + type: string + example: GNU Lesser General Public License v3.0 or later + body: + type: string + example: Insert the entire text of the LGPL-3.0 here... + # Errors + InvalidInputError: + type: object + properties: + error: + type: string + description: The name of the error + example: "invalid_input" + description: + type: string + description: The contents of the error + example: "Error while parsing multipart payload" + required: + - error + - description + AuthError: + type: object + properties: + error: + type: string + description: The name of the error + example: "unauthorized" + description: + type: string + description: The contents of the error + example: "Authentication Error: Invalid Authentication Credentials" + required: + - error + - description + # Other + Statistics: + type: object + properties: + projects: + type: integer + description: Number of projects on Modrinth + versions: + type: integer + description: Number of projects on Modrinth + files: + type: integer + description: Number of version files on Modrinth + authors: + type: integer + description: Number of authors (users with projects) on Modrinth + ForgeUpdates: + type: object + description: Mod version information that can be consumed by Forge's update checker + properties: + homepage: + type: string + description: A link to the mod page + example: https://modrinth.com + promos: + $ref: '#/components/schemas/ForgeUpdateCheckerPromos' + ForgeUpdateCheckerPromos: + type: object + description: A list of the recommended and latest versions for each Minecraft release + properties: + "{version}-recommended": + type: string + description: The mod version that is recommended for `{version}`. Excludes versions with the `alpha` and `beta` version types. + "{version}-latest": + type: string + description: The latest mod version for `{version}`. Shows versions with the `alpha` and `beta` version types. + securitySchemes: + TokenAuth: + type: apiKey + in: header + name: Authorization + +tags: + - name: projects + x-displayName: Projects + description: Projects are what Modrinth is centered around, be it mods, modpacks, resource packs, etc. + - name: versions + x-displayName: Versions + description: Versions contain download links to files with additional metadata. + - name: version-files + x-displayName: Version Files + description: Versions can contain multiple files, and these routes help manage those files. + - name: users + x-displayName: Users + description: Users can create projects, join teams, access notifications, manage settings, and follow projects. Admins and moderators have more advanced permissions such as reviewing new projects. + - name: notifications + x-displayName: Notifications + description: Notifications are sent to users for various reasons, including for project updates, team invites, and moderation purposes. + - name: threads + x-displayName: Threads + description: Threads are a way of communicating between users and moderators, for the purposes of project reviews and reports. + - name: teams + x-displayName: Teams + description: Through teams, user permissions limit how team members can modify projects. + - name: tags + x-displayName: Tags + description: Tags are common and reusable lists of metadata types such as categories or versions. Some can be applied to projects and/or versions. + - name: misc + x-displayName: Miscellaneous + - name: project_model + x-displayName: Project Model + description: | + + - name: project_result_model + x-displayName: Search Result Model + description: | + + - name: version_model + x-displayName: Version Model + description: | + + - name: user_model + x-displayName: User Model + description: | + + - name: team_member_model + x-displayName: Team Member Model + description: | + + +x-tagGroups: + - name: Routes + tags: + - projects + - versions + - version-files + - users + - notifications + - threads + - teams + - tags + - misc + - name: Models + tags: + - project_model + - project_result_model + - version_model + - user_model + - team_member_model + +paths: + # Project + /search: + get: + summary: Search projects + operationId: searchProjects + parameters: + - in: query + name: query + schema: + type: string + example: gravestones + description: The query to search for + - in: query + name: facets + schema: + type: string + example: "[[\"categories:forge\"],[\"versions:1.17.1\"],[\"project_type:mod\"],[\"license:mit\"]]" + description: | + Facets are an essential concept for understanding how to filter out results. + + These are the most commonly used facet types: + - `project_type` + - `categories` (loaders are lumped in with categories in search) + - `versions` + - `client_side` + - `server_side` + - `open_source` + + Several others are also available for use, though these should not be used outside very specific use cases. + - `title` + - `author` + - `follows` + - `project_id` + - `license` + - `downloads` + - `color` + - `created_timestamp` + - `modified_timestamp` + + In order to then use these facets, you need a value to filter by, as well as an operation to perform on this value. + The most common operation is `:` (same as `=`), though you can also use `!=`, `>=`, `>`, `<=`, and `<`. + Join together the type, operation, and value, and you've got your string. + ``` + {type} {operation} {value} + ``` + + Examples: + ``` + categories = adventure + versions != 1.20.1 + downloads <= 100 + ``` + + You then join these strings together in arrays to signal `AND` and `OR` operators. + + ##### OR + All elements in a single array are considered to be joined by OR statements. + For example, the search `[["versions:1.16.5", "versions:1.17.1"]]` translates to `Projects that support 1.16.5 OR 1.17.1`. + + ##### AND + Separate arrays are considered to be joined by AND statements. + For example, the search `[["versions:1.16.5"], ["project_type:modpack"]]` translates to `Projects that support 1.16.5 AND are modpacks`. + - in: query + name: index + schema: + type: string + enum: + - relevance + - downloads + - follows + - newest + - updated + default: relevance + example: downloads + description: The sorting method used for sorting search results + - in: query + name: offset + schema: + type: integer + default: 0 + example: 20 + description: The offset into the search. Skips this number of results + - in: query + name: limit + schema: + type: integer + default: 10 + example: 20 + minimum: 0 + maximum: 100 + description: The number of results returned by the search + tags: + - projects + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/SearchResults' + "400": + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + /project/{id|slug}: + parameters: + - $ref: '#/components/parameters/ProjectIdentifier' + get: + summary: Get a project + operationId: getProject + tags: + - projects + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Project' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + patch: + summary: Modify a project + operationId: modifyProject + tags: + - projects + security: + - TokenAuth: ['PROJECT_WRITE'] + requestBody: + description: "Modified project fields" + content: + application/json: + schema: + $ref: '#/components/schemas/EditableProject' + responses: + "204": + description: Expected response to a valid request + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + delete: + summary: Delete a project + operationId: deleteProject + tags: + - projects + security: + - TokenAuth: ['PROJECT_DELETE'] + responses: + "204": + description: Expected response to a valid request + "400": + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + /projects: + parameters: + - $ref: '#/components/parameters/MultipleProjectIdentifier' + get: + summary: Get multiple projects + operationId: getProjects + tags: + - projects + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Project' + patch: + summary: Bulk-edit multiple projects + operationId: patchProjects + tags: + - projects + security: + - TokenAuth: ['PROJECT_WRITE'] + requestBody: + description: Fields to edit on all projects specified + content: + application/json: + schema: + $ref: '#/components/schemas/PatchProjectsBody' + responses: + "204": + description: Expected response to a valid request + "400": + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + /projects_random: + get: + summary: Get a list of random projects + operationId: randomProjects + parameters: + - in: query + name: count + required: true + schema: + type: integer + example: 70 + minimum: 0 + maximum: 100 + description: The number of random projects to return + tags: + - projects + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Project' + "400": + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + /project: + post: + summary: Create a project + operationId: createProject + tags: + - projects + security: + - TokenAuth: ['PROJECT_CREATE'] + requestBody: + description: "New project" + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/CreateProjectBody' + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Project' + "400": + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + /project/{id|slug}/icon: + parameters: + - $ref: '#/components/parameters/ProjectIdentifier' + patch: + summary: Change project's icon + description: The new icon may be up to 256KiB in size. + operationId: changeProjectIcon + tags: + - projects + parameters: + - description: Image extension + in: query + name: ext + required: true + schema: + type: string + enum: [png, jpg, jpeg, bmp, gif, webp, svg, svgz, rgb] + requestBody: + $ref: '#/components/requestBodies/Image' + security: + - TokenAuth: ['PROJECT_WRITE'] + responses: + "204": + description: Expected response to a valid request + "400": + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + delete: + summary: Delete project's icon + operationId: deleteProjectIcon + tags: + - projects + security: + - TokenAuth: ['PROJECT_WRITE'] + responses: + "204": + description: Expected response to a valid request + "400": + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + /project/{id|slug}/check: + parameters: + - $ref: '#/components/parameters/ProjectIdentifier' + get: + summary: Check project slug/ID validity + operationId: checkProjectValidity + tags: + - projects + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectIdentifier' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + /project/{id|slug}/gallery: + parameters: + - $ref: '#/components/parameters/ProjectIdentifier' + post: + summary: Add a gallery image + description: Modrinth allows you to upload files of up to 5MiB to a project's gallery. + operationId: addGalleryImage + tags: + - projects + security: + - TokenAuth: ['PROJECT_WRITE'] + parameters: + - description: Image extension + in: query + name: ext + required: true + schema: + type: string + enum: [png, jpg, jpeg, bmp, gif, webp, svg, svgz, rgb] + - description: Whether an image is featured + in: query + name: featured + required: true + schema: + type: boolean + - description: Title of the image + in: query + name: title + schema: + type: string + - description: Description of the image + in: query + name: description + schema: + type: string + - description: Ordering of the image + in: query + name: ordering + schema: + type: integer + requestBody: + $ref: '#/components/requestBodies/Image' + responses: + "204": + description: Expected response to a valid request + "400": + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + patch: + summary: Modify a gallery image + operationId: modifyGalleryImage + tags: + - projects + security: + - TokenAuth: ['PROJECT_WRITE'] + parameters: + - description: URL link of the image to modify + in: query + name: url + required: true + schema: + type: string + format: uri + - description: Whether the image is featured + in: query + name: featured + schema: + type: boolean + - description: New title of the image + in: query + name: title + schema: + type: string + - description: New description of the image + in: query + name: description + schema: + type: string + - description: New ordering of the image + in: query + name: ordering + schema: + type: integer + responses: + "204": + description: Expected response to a valid request + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + delete: + summary: Delete a gallery image + operationId: deleteGalleryImage + tags: + - projects + security: + - TokenAuth: ['PROJECT_WRITE'] + parameters: + - description: URL link of the image to delete + in: query + name: url + required: true + schema: + type: string + format: uri + responses: + "204": + description: Expected response to a valid request + "400": + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + /project/{id|slug}/dependencies: + parameters: + - $ref: '#/components/parameters/ProjectIdentifier' + get: + summary: Get all of a project's dependencies + operationId: getDependencies + tags: + - projects + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/ProjectDependencyList' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + /project/{id|slug}/follow: + parameters: + - $ref: '#/components/parameters/ProjectIdentifier' + post: + summary: Follow a project + operationId: followProject + tags: + - projects + security: + - TokenAuth: ['USER_WRITE'] + responses: + "204": + description: Expected response to a valid request + "400": + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + delete: + summary: Unfollow a project + operationId: unfollowProject + tags: + - projects + security: + - TokenAuth: ['USER_WRITE'] + responses: + "204": + description: Expected response to a valid request + "400": + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + /project/{id|slug}/schedule: + parameters: + - $ref: '#/components/parameters/ProjectIdentifier' + post: + summary: Schedule a project + operationId: scheduleProject + tags: + - projects + security: + - TokenAuth: ['PROJECT_WRITE'] + requestBody: + description: Information about date and requested status + content: + application/json: + schema: + $ref: '#/components/schemas/Schedule' + responses: + "204": + description: Expected response to a valid request + "400": + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + # Version + /project/{id|slug}/version: + parameters: + - $ref: '#/components/parameters/ProjectIdentifier' + get: + summary: List project's versions + operationId: getProjectVersions + tags: + - versions + parameters: + - in: query + name: loaders + required: false + description: "The types of loaders to filter for" + schema: + type: string + example: "[\"fabric\"]" + - in: query + name: game_versions + required: false + description: "The game versions to filter for" + schema: + type: string + example: "[\"1.18.1\"]" + - in: query + name: featured + required: false + description: "Allows to filter for featured or non-featured versions only" + schema: + type: boolean + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Version' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + /version/{id}: + parameters: + - $ref: '#/components/parameters/VersionIdentifier' + get: + summary: Get a version + operationId: getVersion + tags: + - versions + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Version' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + patch: + summary: Modify a version + operationId: modifyVersion + tags: + - versions + security: + - TokenAuth: ['VERSION_WRITE'] + requestBody: + description: "Modified version fields" + content: + application/json: + schema: + $ref: '#/components/schemas/EditableVersion' + responses: + "204": + description: Expected response to a valid request + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + delete: + summary: Delete a version + operationId: deleteVersion + tags: + - versions + security: + - TokenAuth: ['VERSION_DELETE'] + responses: + "204": + description: Expected response to a valid request + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + /project/{id|slug}/version/{id|number}: + parameters: + - $ref: '#/components/parameters/ProjectIdentifier' + - name: id|number + in: path + required: true + description: The version ID or version number + schema: + type: string + example: [IIJJKKLL] + get: + summary: Get a version given a version number or ID + description: Please note that, if the version number provided matches multiple versions, only the **oldest matching version** will be returned. + operationId: getVersionFromIdOrNumber + tags: + - versions + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Version' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + /version: + post: + summary: Create a version + description: | + This route creates a version on an existing project. There must be at least one file attached to each new version, unless the new version's status is `draft`. `.mrpack`, `.jar`, `.zip`, and `.litemod` files are accepted. + + The request is a [multipart request](https://www.ietf.org/rfc/rfc2388.txt) with at least two form fields: one is `data`, which includes a JSON body with the version metadata as shown below, and at least one field containing an upload file. + + You can name the file parts anything you would like, but you must list each of the parts' names in `file_parts`, and optionally, provide one to use as the primary file in `primary_file`. + operationId: createVersion + tags: + - versions + security: + - TokenAuth: ['VERSION_CREATE'] + requestBody: + description: "New version" + content: + multipart/form-data: + schema: + $ref: '#/components/schemas/CreateVersionBody' + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Version' + "400": + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + /version/{id}/schedule: + parameters: + - $ref: '#/components/parameters/VersionIdentifier' + post: + summary: Schedule a version + operationId: scheduleVersion + tags: + - versions + security: + - TokenAuth: ['VERSION_WRITE'] + requestBody: + description: Information about date and requested status + content: + application/json: + schema: + $ref: '#/components/schemas/Schedule' + responses: + "204": + description: Expected response to a valid request + "400": + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + /versions: + parameters: + - in: query + name: ids + description: The IDs of the versions + schema: + type: string + example: "[\"AABBCCDD\", \"EEFFGGHH\"]" + required: true + get: + summary: Get multiple versions + operationId: getVersions + tags: + - versions + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Version' + /version/{id}/file: + parameters: + - $ref: '#/components/parameters/VersionIdentifier' + post: + summary: Add files to version + description: Project files are attached. `.mrpack` and `.jar` files are accepted. + operationId: addFilesToVersion + tags: + - versions + security: + - TokenAuth: ['VERSION_WRITE'] + requestBody: + description: "New version files" + content: + multipart/form-data: + schema: + type: object + properties: + data: + type: object + enum: + - { } + responses: + "204": + description: Expected response to a valid request + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + # Version file + /version_file/{hash}: + parameters: + - $ref: '#/components/parameters/FileHashIdentifier' + - $ref: '#/components/parameters/AlgorithmIdentifier' + get: + summary: Get version from hash + operationId: versionFromHash + tags: + - version-files + parameters: + - $ref: '#/components/parameters/MultipleHashQueryIdentifier' + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Version' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + delete: + summary: Delete a file from its hash + operationId: deleteFileFromHash + tags: + - version-files + security: + - TokenAuth: ['VERSION_WRITE'] + parameters: + - description: Version ID to delete the version from, if multiple files of the same hash exist + required: false + in: query + name: version_id + schema: + type: string + example: [IIJJKKLL] + responses: + "204": + description: Expected response to a valid request + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + /version_file/{hash}/update: + parameters: + - $ref: '#/components/parameters/FileHashIdentifier' + - $ref: '#/components/parameters/AlgorithmIdentifier' + post: + summary: Latest version of a project from a hash, loader(s), and game version(s) + operationId: getLatestVersionFromHash + tags: + - version-files + requestBody: + description: Parameters of the updated version requested + content: + application/json: + schema: + $ref: '#/components/schemas/GetLatestVersionFromHashBody' + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Version' + "400": + description: Request was invalid, see given error + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + /version_files: + post: + summary: Get versions from hashes + description: This is the same as [`/version_file/{hash}`](#operation/versionFromHash) except it accepts multiple hashes. + operationId: versionsFromHashes + tags: + - version-files + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/HashVersionMap' + "400": + description: Request was invalid, see given error + requestBody: + description: Hashes and algorithm of the versions requested + content: + application/json: + schema: + $ref: '#/components/schemas/HashList' + /version_files/update: + post: + summary: Latest versions of multiple project from hashes, loader(s), and game version(s) + description: This is the same as [`/version_file/{hash}/update`](#operation/getLatestVersionFromHash) except it accepts multiple hashes. + operationId: getLatestVersionsFromHashes + tags: + - version-files + requestBody: + description: Parameters of the updated version requested + content: + application/json: + schema: + $ref: '#/components/schemas/GetLatestVersionsFromHashesBody' + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/HashVersionMap' + "400": + description: Request was invalid, see given error + # TODO check this out? https://github.com/modrinth/labrinth/blob/ec80c2b9dbf0bae98eb41714d3455b98095563b7/src/routes/v2/version_file.rs#L381 + #/version_files/project: + # post: + # summary: Get projects from hashes + # operationId: projectsFromHashes + # tags: + # - version-files + # responses: + # "200": + # description: Expected response to a valid request + # content: + # application/json: + # schema: + # type: object + # properties: + # your_hash_here: + # $ref: '#/components/schemas/Project' + # "400": + # description: Input is invalid + # requestBody: + # description: Hashes and algorithm of the projects requested + # content: + # application/json: + # schema: + # type: object + # properties: + # hashes: + # type: array + # items: + # type: string + # example: [ ea0f38408102e4d2efd53c2cc11b88b711996b48d8922f76ea6abf731219c5bd1efe39ddf9cce77c54d49a62ff10fb685c00d2e4c524ab99d20f6296677ab2c4, 925a5c4899affa4098d997dfa4a4cb52c636d539e94bc489d1fa034218cb96819a70eb8b01647a39316a59fcfe223c1a8c05ed2e2ae5f4c1e75fa48f6af1c960 ] + # algorithm: + # type: string + # enum: [ sha1, sha512 ] + # example: sha512 + # required: + # - hashes + # - algorithm + # User + /user/{id|username}: + parameters: + - $ref: '#/components/parameters/UserIdentifier' + get: + summary: Get a user + operationId: getUser + tags: + - users + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/User' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + patch: + summary: Modify a user + operationId: modifyUser + tags: + - users + security: + - TokenAuth: ['USER_WRITE'] + requestBody: + description: "Modified user fields" + content: + application/json: + schema: + $ref: '#/components/schemas/EditableUser' + responses: + "204": + description: Expected response to a valid request + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + /user: + get: + summary: Get user from authorization header + operationId: getUserFromAuth + tags: + - users + security: + - TokenAuth: ['USER_READ'] + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/User' + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + /users: + parameters: + - in: query + name: ids + description: The IDs of the users + schema: + type: string + example: "[\"AABBCCDD\", \"EEFFGGHH\"]" + required: true + get: + summary: Get multiple users + operationId: getUsers + tags: + - users + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/User' + /user/{id|username}/icon: + parameters: + - $ref: '#/components/parameters/UserIdentifier' + patch: + summary: Change user's avatar + description: The new avatar may be up to 2MiB in size. + operationId: changeUserIcon + tags: + - users + requestBody: + $ref: '#/components/requestBodies/Image' + security: + - TokenAuth: ['USER_WRITE'] + responses: + "204": + description: Expected response to a valid request + "400": + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + /user/{id|username}/projects: + parameters: + - $ref: '#/components/parameters/UserIdentifier' + get: + summary: Get user's projects + operationId: getUserProjects + tags: + - users + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Project' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + /user/{id|username}/follows: + parameters: + - $ref: '#/components/parameters/UserIdentifier' + get: + summary: Get user's followed projects + operationId: getFollowedProjects + tags: + - users + security: + - TokenAuth: ['USER_READ'] + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Project' + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + /user/{id|username}/payouts: + parameters: + - $ref: '#/components/parameters/UserIdentifier' + get: + summary: Get user's payout history + operationId: getPayoutHistory + tags: + - users + security: + - TokenAuth: ['PAYOUTS_READ'] + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/UserPayoutHistory' + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + post: + summary: Withdraw payout balance to PayPal or Venmo + operationId: withdrawPayout + description: "Warning: certain amounts get withheld for fees. Please do not call this API endpoint without first acknowledging the warnings on the corresponding frontend page." + tags: + - users + security: + - TokenAuth: ['PAYOUTS_WRITE'] + parameters: + - name: amount + in: query + description: Amount to withdraw + schema: + type: integer + required: true + responses: + "204": + description: Expected response to a valid request + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + # Notifications + /user/{id|username}/notifications: + parameters: + - $ref: '#/components/parameters/UserIdentifier' + get: + summary: Get user's notifications + operationId: getUserNotifications + tags: + - notifications + security: + - TokenAuth: ['NOTIFICATION_READ'] + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Notification' + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + /notification/{id}: + parameters: + - $ref: '#/components/parameters/NotificationIdentifier' + get: + summary: Get notification from ID + operationId: getNotification + tags: + - notifications + security: + - TokenAuth: ['NOTIFICATION_READ'] + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Notification' + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + patch: + summary: Mark notification as read + operationId: readNotification + tags: + - notifications + security: + - TokenAuth: ['NOTIFICATION_WRITE'] + responses: + "204": + description: Expected response to a valid request + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + delete: + summary: Delete notification + operationId: deleteNotification + tags: + - notifications + security: + - TokenAuth: ['NOTIFICATION_WRITE'] + responses: + "204": + description: Expected response to a valid request + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + /notifications: + parameters: + - in: query + name: ids + description: The IDs of the notifications + schema: + type: string + example: "[\"AABBCCDD\", \"EEFFGGHH\"]" + required: true + get: + summary: Get multiple notifications + operationId: getNotifications + tags: + - notifications + security: + - TokenAuth: ['NOTIFICATION_READ'] + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Notification' + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + patch: + summary: Mark multiple notifications as read + operationId: readNotifications + tags: + - notifications + security: + - TokenAuth: ['NOTIFICATION_WRITE'] + responses: + "204": + description: Expected response to a valid request + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + delete: + summary: Delete multiple notifications + operationId: deleteNotifications + tags: + - notifications + security: + - TokenAuth: ['NOTIFICATION_WRITE'] + responses: + "204": + description: Expected response to a valid request + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + # Threads + /report: + post: + summary: Report a project, user, or version + description: Bring a project, user, or version to the attention of the moderators by reporting it. + operationId: submitReport + tags: + - threads + security: + - TokenAuth: ['REPORT_CREATE'] + requestBody: + description: The report to be sent + content: + application/json: + schema: + $ref: '#/components/schemas/CreatableReport' + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + "400": + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + get: + summary: Get your open reports + operationId: getOpenReports + tags: + - threads + security: + - TokenAuth: ['REPORT_READ'] + parameters: + - in: query + name: count + schema: + type: integer + example: 100 + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Report' + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + /report/{id}: + parameters: + - $ref: '#/components/parameters/ReportIdentifier' + get: + summary: Get report from ID + operationId: getReport + tags: + - threads + security: + - TokenAuth: ['REPORT_READ'] + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Report' + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + patch: + summary: Modify a report + operationId: modifyReport + tags: + - threads + security: + - TokenAuth: ['REPORT_WRITE'] + requestBody: + description: What to modify about the report + content: + application/json: + schema: + type: object + properties: + body: + type: string + description: The contents of the report + example: This is the meat and potatoes of the report! + closed: + type: boolean + description: Whether the thread should be closed + responses: + "204": + description: Expected response to a valid request + "400": + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + /reports: + parameters: + - in: query + name: ids + description: The IDs of the reports + schema: + type: string + example: "[\"AABBCCDD\", \"EEFFGGHH\"]" + required: true + get: + summary: Get multiple reports + operationId: getReports + tags: + - threads + security: + - TokenAuth: ['REPORT_READ'] + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Report' + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + /thread/{id}: + parameters: + - $ref: '#/components/parameters/ThreadIdentifier' + get: + summary: Get a thread + operationId: getThread + tags: + - threads + security: + - TokenAuth: ['THREAD_READ'] + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Thread' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + post: + summary: Send a text message to a thread + operationId: sendThreadMessage + tags: + - threads + security: + - TokenAuth: ['THREAD_WRITE'] + requestBody: + description: The message to be sent. Note that you only need the fields applicable for the `text` type. + content: + application/json: + schema: + $ref: '#/components/schemas/ThreadMessageBody' + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Thread' + "400": + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + /threads: + parameters: + - in: query + name: ids + description: The IDs of the threads + schema: + type: string + example: "[\"AABBCCDD\", \"EEFFGGHH\"]" + required: true + get: + summary: Get multiple threads + operationId: getThreads + tags: + - threads + security: + - TokenAuth: ['THREAD_READ'] + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/Thread' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + /message/{id}: + parameters: + - name: id + in: path + required: true + description: The ID of the message + schema: + type: string + example: [IIJJKKLL] + delete: + summary: Delete a thread message + operationId: deleteThreadMessage + tags: + - threads + security: + - TokenAuth: ['THREAD_WRITE'] + responses: + "204": + description: Expected response to a valid request + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + # Teams + /project/{id|slug}/members: + parameters: + - $ref: '#/components/parameters/ProjectIdentifier' + get: + summary: Get a project's team members + operationId: getProjectTeamMembers + tags: + - teams + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TeamMember' + description: An array of team members + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + /team/{id}/members: + parameters: + - $ref: '#/components/parameters/TeamIdentifier' + get: + summary: Get a team's members + operationId: getTeamMembers + tags: + - teams + security: + - TokenAuth: ['PROJECT_READ'] + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/TeamMember' + description: An array of team members + post: + summary: Add a user to a team + operationId: addTeamMember + tags: + - teams + security: + - TokenAuth: ['PROJECT_WRITE'] + requestBody: + description: User to be added (must be the ID, usernames cannot be used here) + content: + application/json: + schema: + $ref: '#/components/schemas/UserIdentifier' + responses: + "204": + description: Expected response to a valid request + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + /teams: + parameters: + - in: query + name: ids + description: The IDs of the teams + schema: + type: string + example: "[\"AABBCCDD\", \"EEFFGGHH\"]" + required: true + get: + summary: Get the members of multiple teams + operationId: getTeams + tags: + - teams + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + type: array + items: + $ref: '#/components/schemas/TeamMember' + /team/{id}/join: + parameters: + - $ref: '#/components/parameters/TeamIdentifier' + post: + summary: Join a team + operationId: joinTeam + tags: + - teams + security: + - TokenAuth: ['PROJECT_WRITE'] + responses: + "204": + description: Expected response to a valid request + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + /team/{id}/members/{id|username}: + parameters: + - $ref: '#/components/parameters/TeamIdentifier' + - $ref: '#/components/parameters/UserIdentifier' + patch: + summary: Modify a team member's information + operationId: modifyTeamMember + tags: + - teams + security: + - TokenAuth: ['PROJECT_WRITE'] + requestBody: + description: Contents to be modified + content: + application/json: + schema: + $ref: '#/components/schemas/ModifyTeamMemberBody' + responses: + "204": + description: Expected response to a valid request + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + delete: + summary: Remove a member from a team + operationId: deleteTeamMember + tags: + - teams + security: + - TokenAuth: ['PROJECT_WRITE'] + responses: + "204": + description: Expected response to a valid request + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + /team/{id}/owner: + parameters: + - $ref: '#/components/parameters/TeamIdentifier' + patch: + summary: Transfer team's ownership to another user + operationId: transferTeamOwnership + tags: + - teams + security: + - TokenAuth: ['PROJECT_WRITE'] + requestBody: + description: New owner's ID + content: + application/json: + schema: + $ref: '#/components/schemas/UserIdentifier' + responses: + "204": + description: Expected response to a valid request + "401": + description: Incorrect token scopes or no authorization to access the requested item(s) + content: + application/json: + schema: + $ref: '#/components/schemas/AuthError' + "404": + description: The requested item(s) were not found or no authorization to access the requested item(s) + # Tags + /tag/category: + get: + summary: Get a list of categories + description: Gets an array of categories, their icons, and applicable project types + operationId: categoryList + tags: + - tags + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/CategoryTag' + /tag/loader: + get: + summary: Get a list of loaders + description: Gets an array of loaders, their icons, and supported project types + operationId: loaderList + tags: + - tags + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/LoaderTag' + /tag/game_version: + get: + summary: Get a list of game versions + description: Gets an array of game versions and information about them + operationId: versionList + tags: + - tags + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/GameVersionTag' + /tag/license: + get: + deprecated: true + summary: Get a list of licenses + description: Deprecated - simply use SPDX IDs. + operationId: licenseList + tags: + - tags + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/LicenseTag' + /tag/license/{id}: + parameters: + - name: id + in: path + required: true + description: The license ID to get the text of + schema: + type: string + example: [LGPL-3.0-or-later] + get: + summary: Get the text and title of a license + operationId: licenseText + tags: + - tags + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/License' + "400": + description: Request was invalid, see given error + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + /tag/donation_platform: + get: + summary: Get a list of donation platforms + description: Gets an array of donation platforms and information about them + operationId: donationPlatformList + tags: + - tags + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/DonationPlatformTag' + /tag/report_type: + get: + summary: Get a list of report types + description: Gets an array of valid report types + operationId: reportTypeList + tags: + - tags + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + type: string + example: [ spam, copyright, inappropriate, malicious, name-squatting, other ] + /tag/project_type: + get: + summary: Get a list of project types + description: Gets an array of valid project types + operationId: projectTypeList + tags: + - tags + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + type: string + example: [ mod, modpack, resourcepack, shader ] + /tag/side_type: + get: + summary: Get a list of side types + description: Gets an array of valid side types + operationId: sideTypeList + tags: + - tags + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + type: array + items: + type: string + example: [ required, optional, unsupported, unknown ] + # Miscellaneous + /updates/{id|slug}/forge_updates.json: + parameters: + - $ref: '#/components/parameters/ProjectIdentifier' + servers: + - url: https://api.modrinth.com + description: Production server + - url: https://staging-api.modrinth.com + description: Staging server + get: + summary: Forge Updates JSON file + operationId: forgeUpdates + description: | + If you're a Forge mod developer, your Modrinth mods have an automatically generated `updates.json` using the + [Forge Update Checker](https://docs.minecraftforge.net/en/latest/misc/updatechecker/). + + The only setup is to insert the URL into the `[[mods]]` section of your `mods.toml` file as such: + + ```toml + [[mods]] + # the other stuff here - ID, version, display name, etc. + updateJSONURL = "https://api.modrinth.com/updates/{slug|ID}/forge_updates.json" + ``` + + Replace `{slug|id}` with the slug or ID of your project. + + Modrinth will handle the rest! When you update your mod, Forge will notify your users that their copy of your mod is out of date. + + Make sure that the version format you use for your Modrinth releases is the same as the version format you use in your `mods.toml`. + If you use a format such as `1.2.3-forge` or `1.2.3+1.19` with your Modrinth releases but your `mods.toml` only has `1.2.3`, + the update checker may not function properly. + tags: + - misc + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/ForgeUpdates' + "400": + description: Invalid request + content: + application/json: + schema: + $ref: '#/components/schemas/InvalidInputError' + /statistics: + get: + summary: Various statistics about this Modrinth instance + operationId: statistics + tags: + - misc + responses: + "200": + description: Expected response to a valid request + content: + application/json: + schema: + $ref: '#/components/schemas/Statistics' diff --git a/apps/docs/public/welcome-channel.yaml b/apps/docs/public/welcome-channel.yaml new file mode 100644 index 00000000..63376eb1 --- /dev/null +++ b/apps/docs/public/welcome-channel.yaml @@ -0,0 +1,79 @@ +- type: text + text: https://cdn.discordapp.com/attachments/734084240408444949/975414177550200902/welcome-channel.png + +- type: embed + embeds: + - title: __Welcome to Modrinth's Discord server!__ + url: https://modrinth.com + color: 0x1bd96a + description: "Modrinth is the place for Minecraft mods, plugins, data packs, shaders, resource packs, and + modpacks. Discover, play, and share Minecraft content through our open-source platform built for the community." + +- type: embed + embeds: + - title: "**:scroll: __Rules__**" + color: 0x4f9cff + description: "Modrinth's rules are easy to follow. Despite this, please keep in mind that this is not an entirely + open forum. First and foremost, this Discord server is intended to facilitate the development of Modrinth and + for communication regarding Modrinth. Ultimately, it is up to the discretion of the moderators whether your + messages are in violation of our rules.\n\n + Modrinth's rules are split up into two categories: the **__DOs__** and the **__DO NOTs__**." + - title: ":white_check_mark: Do:" + color: 0x1bd96a + description: >- + 1. Treat every user with respect and consider the opinions and viewpoints of others + + 2. Stay on-topic in all channels; all channels are only for discussion of **Modrinth itself** with the + exceptions of <#783091855616901200>, <#1109517383074328686>, and <#1061855024252207167> + + 3. Follow Discord's rules, including the [Community Guidelines](https://discord.com/guidelines) and the [Terms + of Service](https://discord.com/terms) (this also means that discussions regarding "cracked" launchers and + Discord client modifications are **prohibited under all circumstances**) + + 4. Contact the moderators at any time via the <@&895382919772766219> ping + + 5. Respect the use of accessibility and self-identity tools such as [PluralKit](https://pluralkit.me) + - title: ":no_entry: Do not:" + color: 0xff496e + description: >- + 6. Harass, bother, provoke, or insult anyone, including by sending unsolicited DMs or friend requests + + 7. Cause problems or impede Modrinth's development + + 8. Discuss drama from other places, including bashing or hating on other websites and platforms (though + constructive criticism for the betterment of Modrinth is encouraged) + + 9. Report Modrinth content in the Discord (use the Report button on the website) + + 10. Assume staff member's opinions reflect those of Modrinth + - title: ":pencil2: Nickname policy:" + color: 0xffa347 + description: >- + We want to keep this server clean and therefore require that display names of all members on the server are + readable, accessible, and free of attention-seeking elements, which includes, but is not limited to, display + names that begin with hoisting characters, have an excessive number of emojis in them, or use "fancy fonts", + "glitch effects" and any other Unicode characters, which are either very inaccessible to screen readers or cause + annoyance to other members. + + When we find that your display name does not adhere to this policy, we will try to correct it by changing your + nickname on the server. Repetitive attempts to revert to a violating display name may result in your removal + from the server. We will also permanently remove any users whose profiles contain inappropriate content. +- type: links + color: 0x4f9cff + title: "**:link: __Links__**" + links: + Website: https://modrinth.com + Support: https://support.modrinth.com + Status page: https://status.modrinth.com + Roadmap: https://roadmap.modrinth.com + Blog and newsletter: https://blog.modrinth.com/subscribe?utm_medium=social&utm_source=discord&utm_campaign=welcome + API documentation: https://docs.modrinth.com + Modrinth source code: https://github.com/modrinth + Help translate Modrinth: https://crowdin.com/project/modrinth + Follow Modrinth on Mastodon: https://floss.social/@modrinth + Follow Modrinth on Twitter: https://twitter.com/modrinth + +- type: text + text: | + **Main server:** + **Testing server:** diff --git a/apps/docs/src/assets/dark-logo.svg b/apps/docs/src/assets/dark-logo.svg new file mode 100644 index 00000000..eead0bb1 --- /dev/null +++ b/apps/docs/src/assets/dark-logo.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/docs/src/assets/light-logo.svg b/apps/docs/src/assets/light-logo.svg new file mode 100644 index 00000000..963a15e1 --- /dev/null +++ b/apps/docs/src/assets/light-logo.svg @@ -0,0 +1,21 @@ + + + + + + + + + + + + + + + + + + + + + diff --git a/apps/docs/src/content/config.ts b/apps/docs/src/content/config.ts new file mode 100644 index 00000000..45f60b01 --- /dev/null +++ b/apps/docs/src/content/config.ts @@ -0,0 +1,6 @@ +import { defineCollection } from 'astro:content'; +import { docsSchema } from '@astrojs/starlight/schema'; + +export const collections = { + docs: defineCollection({ schema: docsSchema() }), +}; diff --git a/apps/docs/src/content/docs/contributing/daedalus.md b/apps/docs/src/content/docs/contributing/daedalus.md new file mode 100644 index 00000000..87aa4179 --- /dev/null +++ b/apps/docs/src/content/docs/contributing/daedalus.md @@ -0,0 +1,4 @@ +--- +title: Daedalus (Metadata service) +description: Guide for contributing to Modrinth's frontend +--- diff --git a/apps/docs/src/content/docs/contributing/getting-started.md b/apps/docs/src/content/docs/contributing/getting-started.md new file mode 100644 index 00000000..de9772cf --- /dev/null +++ b/apps/docs/src/content/docs/contributing/getting-started.md @@ -0,0 +1,52 @@ +--- +title: Getting started +description: How can I contribute to Modrinth? +sidebar: + order: 1 +--- + +# Contributing to Modrinth + +Every public-facing aspect of Modrinth, including everything from our [API/backend][labrinth] and [frontend][knossos] to our [Gradle plugin][minotaur] and [launcher][theseus], is released under free and open source licenses on [GitHub]. As such, we love contributions from community members! Before proceeding to do so, though, there are a number of things you'll want to keep in mind throughout the process, as well as some details specific to certain projects. + +## Things to keep in mind + +### Consult people on Discord + +There are a number of reasons to want to consult with people on our [Discord] before making a pull request. For example, if you're not sure whether something is a good idea or not, if you're not sure how to implement something, or if you can't get something working, these would all be good opportunities to create a thread in the `#development` forum channel. + +If you intend to work on new features, to make significant codebase changes, or to make UI/design changes, please open a discussion thread first to ensure your work is put to its best use. + +### Don't get discouraged + +At times, pull requests may be rejected or left unmerged for a variation of reasons. Don't take it personally, and don't get discouraged! Sometimes a contribution just isn't the right fit for the time, or it might have just been lost in the mess of other things to do. Remember, the core Modrinth team are often busy, whether it be on a specific project/task or on external factors such as offline responsibilities. It all falls back to the same thing: don't get discouraged! + +### Code isn't the only way to contribute + +You don't need to know how to program to contribute to Modrinth. Quality assurance, supporting the community, coming up with feature ideas, and making sure your voice is heard in public decisions are all great ways to contribute to Modrinth. If you find bugs, reporting them on the appropriate issue tracker is your responsibility; however, remember that potential security breaches and exploits must instead be reported in accordance with our [security policy](https://modrinth.com/legal/security). + +## Project-specific details + +If you wish to contribute code to a specific project, here's the place to start. Most of Modrinth is written in the [Rust language](https://www.rust-lang.org), but some things are written in other languages/frameworks like [Nuxt.js](https://nuxtjs.org) or Java. + +Most of Modrinth's code is in our monorepo, which you can find [here](https://github.com/modrinth/code). Our monorepo is powered by [Turborepo](https://turborepo.org). + +Follow the project-specific instructions below to get started: +- [Knossos (frontend)](/contributing/knossos) +- [Theseus (Modrinth App)](/contributing/theseus) +- [Minotaur (Gradle plugin)](/contributing/minotaur) +- [Labrinth (API/backend)](/contributing/labrinth) +- [Daedalus (Metadata service)](/contributing/daedalus) + +### Documentation + +The [documentation](https://github.com/modrinth/docs) (which you are reading right now!) is the place to find any and all general information about Modrinth and its API. The instructions are largely the same as [knossos](#knossos-frontend), except that the docs have no lint. + +[Discord]: https://discord.modrinth.com +[GitHub]: https://github.com/modrinth +[knossos]: https://github.com/modrinth/code/tree/main/apps/frontend +[labrinth]: https://github.com/modrinth/labrinth +[theseus]: https://github.com/modrinth/theseus +[minotaur]: https://github.com/modrinth/minotaur +[Rust]: https://www.rust-lang.org/tools/install +[pnpm]: https://pnpm.io \ No newline at end of file diff --git a/apps/docs/src/content/docs/contributing/knossos.md b/apps/docs/src/content/docs/contributing/knossos.md new file mode 100644 index 00000000..4f9aecd2 --- /dev/null +++ b/apps/docs/src/content/docs/contributing/knossos.md @@ -0,0 +1,35 @@ +--- +title: Knossos (Frontend) +description: Guide for contributing to Modrinth's frontend +--- + +This project is our [monorepo](https://github.com/modrinth/code). You can find the frontend in the `apps/frontend` directory. + +[knossos] is the Nuxt.js frontend. You will need to install [pnpm] and run the standard commands: + +```bash +pnpm install +pnpm run web:dev +``` + +Once that's done, you'll be serving knossos on `localhost:3000` with hot reloading. You can replace the `dev` in `pnpm run dev` with `build` to build for a production server and `start` to start the server. You can also use `pnpm run lint` to find any eslint problems, and `pnpm run fix` to try automatically fixing those problems. + +
+.env variables & command line options + +#### Basic configuration + +`SITE_URL`: The URL of the site (used for auth redirects). Default: `http://localhost:3000` +`BASE_URL`: The base URL for the API. Default: `https://staging-api.modrinth.com/v2/` +`BROWSER_BASE_URL`: The base URL for the API used in the browser. Default: `https://staging-api.modrinth.com/v2/` + +
+ +#### Ready to open a PR? + +If you're prepared to contribute by submitting a pull request, ensure you have met the following criteria: + +- `pnpm run fix` has been run. + +[knossos]: https://github.com/modrinth/code/tree/main/apps/frontend +[pnpm]: https://pnpm.io \ No newline at end of file diff --git a/apps/docs/src/content/docs/contributing/labrinth.md b/apps/docs/src/content/docs/contributing/labrinth.md new file mode 100644 index 00000000..a95c389b --- /dev/null +++ b/apps/docs/src/content/docs/contributing/labrinth.md @@ -0,0 +1,115 @@ +--- +title: Labrinth (API) +description: Guide for contributing to Modrinth's backend +--- + + +[labrinth] is the Rust-based backend serving Modrinth's API with the help of the [Actix](https://actix.rs) framework. To get started with a labrinth instance, install docker, docker-compose (which comes with Docker), and [Rust]. The initial startup can be done simply with the command `docker-compose up`, or with `docker compose up` (Compose V2 and later). That will deploy a PostgreSQL database on port 5432 and a MeiliSearch instance on port 7700. To run the API itself, you'll need to use the `cargo run` command, this will deploy the API on port 8000. + +Now, you'll have to install the sqlx CLI, which can be done with cargo: + +```bash +cargo install --git https://github.com/launchbadge/sqlx sqlx-cli --no-default-features --features postgres,rustls +``` + +From there, you can create the database and perform all database migrations with one simple command: + +```bash +sqlx database setup +``` + +Finally, if on Linux, you will need the OpenSSL library. On Debian-based systems, this involves the `pkg-config` and `libssl-dev` packages. + +To enable labrinth to create a project, you need to add two things. + +1. An entry in the `loaders` table. +2. An entry in the `loaders_project_types` table. + +A minimal setup can be done from the command line with [psql](https://www.postgresql.org/docs/current/app-psql.html): + +```bash +psql --host=localhost --port=5432 -U -W +``` + +The default password for the database is `labrinth`. Once you've connected, run + +```sql +INSERT INTO loaders VALUES (0, 'placeholder_loader'); +INSERT INTO loaders_project_types VALUES (0, 1); -- modloader id, supported type id +INSERT INTO categories VALUES (0, 'placeholder_category', 1); -- category id, category, project type id +``` + +This will initialize your database with a modloader called 'placeholder_loader', with id 0, and marked as supporting mods only. It will also create a category called 'placeholder_category' that is marked as supporting mods only +If you would like 'placeholder_loader' to be marked as supporting modpacks too, run + +```sql +INSERT INTO loaders_project_types VALUES (0, 2); -- modloader id, supported type id +``` + +If you would like 'placeholder_category' to be marked as supporting modpacks too, run + +```sql +INSERT INTO categories VALUES (0, 'placeholder_category', 2); -- modloader id, supported type id +``` + +The majority of configuration is done at runtime using [dotenvy](https://crates.io/crates/dotenvy) and the `.env` file. Each of the variables and what they do can be found in the dropdown below. Additionally, there are three command line options that can be used to specify to MeiliSearch what you want to do. + +
+.env variables & command line options + +#### Basic configuration + +`DEBUG`: Whether debugging tools should be enabled +`RUST_LOG`: Specifies what information to log, from rust's [`env-logger`](https://github.com/env-logger-rs/env_logger); a reasonable default is `info,sqlx::query=warn` +`SITE_URL`: The main URL to be used for CORS +`CDN_URL`: The publicly accessible base URL for files uploaded to the CDN +`MODERATION_DISCORD_WEBHOOK`: The URL for a Discord webhook where projects pending approval will be sent +`CLOUDFLARE_INTEGRATION`: Whether labrinth should integrate with Cloudflare's spam protection +`DATABASE_URL`: The URL for the PostgreSQL database +`DATABASE_MIN_CONNECTIONS`: The minimum number of concurrent connections allowed to the database at the same time +`DATABASE_MAX_CONNECTIONS`: The maximum number of concurrent connections allowed to the database at the same time +`MEILISEARCH_ADDR`: The URL for the MeiliSearch instance used for search +`MEILISEARCH_KEY`: The name that MeiliSearch is given +`BIND_ADDR`: The bind address for the server. Supports both IPv4 and IPv6 +`MOCK_FILE_PATH`: The path used to store uploaded files; this has no default value and will panic if unspecified + +#### CDN options + +`STORAGE_BACKEND`: Controls what storage backend is used. This can be either `local`, `backblaze`, or `s3`, but defaults to `local` + +The Backblaze and S3 configuration options are fairly self-explanatory in name, so here's simply their names: +`BACKBLAZE_KEY_ID`, `BACKBLAZE_KEY`, `BACKBLAZE_BUCKET_ID` +`S3_ACCESS_TOKEN`, `S3_SECRET`, `S3_URL`, `S3_REGION`, `S3_BUCKET_NAME` + +#### Search, OAuth, and miscellaneous options + +`LOCAL_INDEX_INTERVAL`: The interval, in seconds, at which the local database is reindexed for searching. Defaults to `3600` seconds (1 hour). +`VERSION_INDEX_INTERVAL`: The interval, in seconds, at which versions are reindexed for searching. Defaults to `1800` seconds (30 minutes). + +The OAuth configuration options are fairly self-explanatory. For help setting up authentication, please contact us on [Discord]. + +`RATE_LIMIT_IGNORE_IPS`: An array of IPs that should have a lower rate limit factor. This can be useful for allowing the front-end to have a lower rate limit to prevent accidental timeouts. + +#### Command line options + +`--skip-first-index`: Skips indexing the local database on startup. This is useful to prevent doing unnecessary work when frequently restarting. +`--reconfigure-indices`: Resets the MeiliSearch settings for the search indices and exits. +`--reset-indices`: Resets the MeiliSearch indices and exits; this clears all previously indexed mods. + +
+ +#### Ready to open a PR? + +If you're prepared to contribute by submitting a pull request, ensure you have met the following criteria: + +- `cargo fmt` has been run. +- `cargo clippy` has been run. +- `cargo check` has been run. +- `cargo sqlx prepare` has been run. + +> Note: If you encounter issues with `sqlx` saying 'no queries found' after running `cargo sqlx prepare`, you may need to ensure the installed version of `sqlx-cli` matches the current version of `sqlx` used [in labrinth](https://github.com/modrinth/labrinth/blob/master/Cargo.toml). + +[Discord]: https://discord.modrinth.com +[GitHub]: https://github.com/modrinth +[labrinth]: https://github.com/modrinth/labrinth +[Rust]: https://www.rust-lang.org/tools/install diff --git a/apps/docs/src/content/docs/contributing/minotaur.md b/apps/docs/src/content/docs/contributing/minotaur.md new file mode 100644 index 00000000..19df6504 --- /dev/null +++ b/apps/docs/src/content/docs/contributing/minotaur.md @@ -0,0 +1,10 @@ +--- +title: Minotaur (Gradle plugin) +description: Guide for contributing to Modrinth's gradle plugin +--- + +[Minotaur][minotaur] is the Gradle plugin used to automatically publish artifacts to Modrinth. To run your copy of the plugin in a project, publish it to your local Maven with `./gradlew publishToMavenLocal` and add `mavenLocal()` to your buildscript. + +Minotaur contains two test environments within it - one with ForgeGradle and one with Fabric Loom. You may tweak with these environments to test whatever you may be trying; just make sure that the `modrinth` task within each still functions properly. GitHub Actions will validate this if you're making a pull request, so you may want to use [`act pull_request`](https://github.com/nektos/act) to test them locally. + +[minotaur]: https://github.com/modrinth/minotaur \ No newline at end of file diff --git a/apps/docs/src/content/docs/contributing/theseus.md b/apps/docs/src/content/docs/contributing/theseus.md new file mode 100644 index 00000000..79635bed --- /dev/null +++ b/apps/docs/src/content/docs/contributing/theseus.md @@ -0,0 +1,43 @@ +--- +title: Theseus (Modrinth App) +description: Guide for contributing to Modrinth's desktop app +--- + +This project is our [monorepo](https://github.com/modrinth/code). + +[theseus] is the Tauri-based launcher that lets users conveniently play any mod or modpack on Modrinth. It uses the Rust-based Tauri as the backend and Vue.js as the frontend. To get started, install [pnpm], [Rust], and the [Tauri prerequisites](https://v2.tauri.app/start/prerequisites/) for your system. Then, run the following commands: + +```bash +pnpm install +pnpm run app:dev +``` + +Once the commands finish, you'll be viewing a Tauri window with Nuxt.js hot reloading. + +You can use `pnpm run lint` to find any eslint problems, and `pnpm run fix` to try automatically fixing those problems. + +### Theseus Architecture + +Theseus is split up into three parts: +- `apps/app-frontend`: The Vue.JS frontend for the app +- `packages/app-lib`: The library holding all the core logic for the desktop app +- `apps/app`: The Tauri-based Rust app. This primarily wraps around the library with some additional logic for Tauri. + +The app's internal database is stored in SQLite. For production builds, this is found at /app.db. + +When running SQLX commands, be sure to set the `DATABASE_URL` environment variable to the path of the database. + +You can edit the app's data directory using the `THESEUS_CONFIG_DIR` environment variable. + +#### Ready to open a PR? + +If you're prepared to contribute by submitting a pull request, ensure you have met the following criteria: + +- Run `pnpm run fix` to address any fixable issues automatically. +- Run `cargo fmt` to format Rust-related code. +- Run `cargo clippy` to validate Rust-related code. +- Run `cargo sqlx prepare --package theseus` if you've changed any SQL code to validate statements. + +[theseus]: https://github.com/modrinth/code/tree/main/apps/app +[Rust]: https://www.rust-lang.org/tools/install +[pnpm]: https://pnpm.io \ No newline at end of file diff --git a/apps/docs/src/content/docs/index.mdx b/apps/docs/src/content/docs/index.mdx new file mode 100644 index 00000000..2bd95320 --- /dev/null +++ b/apps/docs/src/content/docs/index.mdx @@ -0,0 +1,15 @@ +--- +title: Modrinth docs +description: Developer documentation for Modrinth! +template: splash +hero: + tagline: Developer documentation for Modrinth + actions: + - text: API documentation + link: /api + icon: right-arrow + - text: Get support with Modrinth + link: https://support.modrinth.com + icon: external + variant: minimal +--- \ No newline at end of file diff --git a/apps/docs/src/env.d.ts b/apps/docs/src/env.d.ts new file mode 100644 index 00000000..acef35f1 --- /dev/null +++ b/apps/docs/src/env.d.ts @@ -0,0 +1,2 @@ +/// +/// diff --git a/apps/docs/src/styles/modrinth.css b/apps/docs/src/styles/modrinth.css new file mode 100644 index 00000000..237d5df6 --- /dev/null +++ b/apps/docs/src/styles/modrinth.css @@ -0,0 +1,54 @@ +:root, +::backdrop, +:root[data-theme='light'], +[data-theme='light'] ::backdrop{ + --sl-font-system: Inter, -apple-system, BlinkMacSystemFont, Segoe UI, Oxygen, Ubuntu, Roboto, + Cantarell, Fira Sans, Droid Sans, Helvetica Neue, sans-serif; + + --sl-color-white: var(--color-contrast); /* “white” */ + --sl-color-gray-1: var(--color-base); + --sl-color-gray-2: var(--color-base); + --sl-color-gray-3: var(--color-base); + --sl-color-gray-4: var(--color-raised-bg); + --sl-color-gray-5: var(--color-button-bg); + --sl-color-gray-6: var(--color-raised-bg); + --sl-color-black: var(--color-accent-contrast); + + --sl-color-accent-low: var(--color-green-highlight); + --sl-color-accent: var(--color-brand); + --sl-color-accent-high: var(--color-brand-highlight); + + --sl-color-orange-low: var(--color-orange-highlight); + --sl-color-orange: var(--color-orange); + --sl-color-orange-high: var(--color-orange-highlight); + + --sl-color-green-low: var(--color-green-highlight); + --sl-color-green: var(--color-green); + --sl-color-green-high: var(--color-green-highlight); + + --sl-color-blue-low: var(--color-blue-highlight); + --sl-color-blue: var(--color-blue); + --sl-color-blue-high: var(--color-blue-highlight); + + --sl-color-purple-low: var(--color-purple-highlight); + --sl-color-purple: var(--color-purple); + --sl-color-purple-high: var(--color-purple-highlight); + + --sl-color-red-low: var(--color-red-highlight); + --sl-color-red: var(--color-red); + --sl-color-red-high: var(--color-red-highlight); + + --sl-color-text: var(--color-base); + --sl-color-text-accent: var(--color-brand); + --sl-color-text-invert: var(--color-accent-contrast); + --sl-color-bg: var(--color-bg); + --sl-color-bg-nav: var(--color-raised-bg); + --sl-color-bg-sidebar: var(--color-raised-bg); + --sl-color-bg-inline-code: var(--color-button-bg); + --sl-color-bg-accent: var(--color-brand-highlight); +} + +:root[data-theme='light'], +[data-theme='light'] ::backdrop{ + --sl-color-bg: var(--color-raised-bg); +} diff --git a/apps/docs/tsconfig.json b/apps/docs/tsconfig.json new file mode 100644 index 00000000..77da9dd0 --- /dev/null +++ b/apps/docs/tsconfig.json @@ -0,0 +1,3 @@ +{ + "extends": "astro/tsconfigs/strict" +} \ No newline at end of file diff --git a/apps/frontend/src/components/ui/AdPlaceholder.vue b/apps/frontend/src/components/ui/AdPlaceholder.vue index 702cda93..65e76c9e 100644 --- a/apps/frontend/src/components/ui/AdPlaceholder.vue +++ b/apps/frontend/src/components/ui/AdPlaceholder.vue @@ -23,12 +23,16 @@ import { ChevronRightIcon } from "@modrinth/assets"; useHead({ script: [ { - type: "module", - src: "//js.rev.iq", - "data-domain": "modrinth.com", + // Clean.io + src: "https://cadmus.script.ac/d14pdm1b7fi5kh/script.js", + }, + { + // Aditude + src: "https://dn0qt3r0xannq.cloudfront.net/modrinth-7JfmkEIXEp/modrinth-longform/prebid-load.js", async: true, }, { + // Optima src: "https://bservr.com/o.js?uid=8118d1fdb2e0d6f32180bd27", async: true, }, @@ -37,6 +41,28 @@ useHead({ async: true, }, ], + link: [ + { + rel: "preload", + as: "script", + href: "https://www.googletagservices.com/tag/js/gpt.js", + }, + ], +}); + +onMounted(() => { + window.tude = window.tude || { cmd: [] }; + tude.cmd.push(function () { + tude.refreshAdsViaDivMappings([ + { + divId: "modrinth-rail-1", + baseDivId: "pb-slot-square-2", + targeting: { + location: "web", + }, + }, + ]); + }); }); + + + + + + + + + + + + + + +
{{ 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 00000000..85c628e2 --- /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 00000000..80c8bb8e --- /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 00000000..30eca4d1 --- /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 00000000..dab6ff85 --- /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 00000000..bc0a5388 --- /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 00000000..edef0c9d --- /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 00000000..82f35314 --- /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 00000000..6cb20174 --- /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 00000000..47181186 --- /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 00000000..e69d1943 --- /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 00000000..b0245075 --- /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 00000000..f74daa6a --- /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 00000000..2bba7dca --- /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 00000000..90abf1ad --- /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 00000000..80d75de5 --- /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 00000000..0a4c440b --- /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 00000000..95e66b6c --- /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 00000000..aa1b9989 --- /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 00000000..d0ef66ab --- /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 00000000..e7fa7614 --- /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 00000000..70c74150 --- /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 00000000..dabcfdda --- /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 00000000..5fda4a80 --- /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 00000000..617e6fcd --- /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 00000000..820f28ce --- /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 00000000..9c35a590 --- /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 00000000..b0105277 --- /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 00000000..205a70e4 --- /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 00000000..51ed85ab --- /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 00000000..eaca8b7d --- /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 00000000..1bd07d22 --- /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 00000000..703c5d60 --- /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 00000000..adb1659e --- /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 00000000..8f6f811e --- /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 00000000..38d6cbe4 --- /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 00000000..ec0809c1 --- /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 00000000..edf2e1a5 --- /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 00000000..792c9ac0 --- /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 00000000..65601bde --- /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 00000000..24ea51c5 --- /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 00000000..28d30224 --- /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 00000000..9ab9e598 --- /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 00000000..87e24ac3 --- /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 00000000..b43aa1b5 --- /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 00000000..f520bd11 --- /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 00000000..b89d35cb --- /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 00000000..87be229a --- /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 00000000..9bfc70ba --- /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 00000000..336150c8 --- /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 00000000..28f737c1 --- /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 00000000..aea510d7 --- /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 00000000..ed955b3a --- /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 00000000..6e4166a5 --- /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 00000000..a96d5170 --- /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 00000000..4e531326 --- /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 00000000..dfc9356b --- /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 00000000..f265b770 --- /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 00000000..064be280 --- /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 00000000..cc5c6d63 --- /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 00000000..b59254a7 --- /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 00000000..afe87901 --- /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 00000000..52a937bd --- /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 00000000..5a2997c8 --- /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 00000000..5e814f53 --- /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 00000000..d9ffb845 --- /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 00000000..2d081310 --- /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 00000000..73f1ae86 --- /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 00000000..f2817e36 --- /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 00000000..04572184 --- /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 00000000..118db66b --- /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 00000000..ba4b6310 --- /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 00000000..a6f3c8a2 --- /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 00000000..f620bc13 --- /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 00000000..46a8a69a --- /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 00000000..f9f6ef91 --- /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 00000000..1c188317 --- /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 00000000..a69ee9d9 --- /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 00000000..117a51fa --- /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 00000000..e551a8ca --- /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 00000000..7ccf81c0 --- /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 00000000..d31a2ebd --- /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 00000000..46345045 --- /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 00000000..1c3ce7be --- /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 00000000..5105cda3 --- /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 00000000..36fe6feb --- /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 00000000..8e332fe3 --- /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 00000000..411abb12 --- /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 00000000..188d3081 --- /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 00000000..31da67cd --- /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 00000000..6f1d3e61 --- /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 00000000..0472899d --- /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 00000000..9f59e738 --- /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 00000000..8539f259 --- /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 00000000..b928c70d --- /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 00000000..c337127a --- /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 00000000..38247d05 --- /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 00000000..2da930bd --- /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 00000000..64ee7b21 --- /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 00000000..13f823a6 --- /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 00000000..e961da24 --- /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 00000000..85810d61 --- /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 00000000..decc71a3 --- /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 00000000..3ee33336 --- /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 00000000..35173102 --- /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 00000000..bd98da12 --- /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 00000000..3233e9ae --- /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 00000000..444ff13e --- /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 00000000..ab5e781a --- /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 00000000..b7b30c22 --- /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 00000000..ba8248db --- /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 00000000..bb998b66 --- /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 00000000..4d642542 --- /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 00000000..665f11a7 --- /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 00000000..a31e753b --- /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 00000000..6a9f19e3 --- /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 00000000..a1c2c841 --- /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 00000000..23a92a00 --- /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 00000000..abff2e58 --- /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 00000000..a65dcc75 --- /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 00000000..0307341b --- /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 00000000..ad5636de --- /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 00000000..31d75120 --- /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 00000000..5e7277ec --- /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 00000000..1af67462 --- /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 00000000..51144804 --- /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 00000000..d7d5b251 --- /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 00000000..4917d054 --- /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 00000000..ef500e94 --- /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 00000000..dd7d3052 --- /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 00000000..8d531c22 --- /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 00000000..e34d8ef5 --- /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 00000000..ac27a075 --- /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 00000000..7bc5e519 --- /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 00000000..f24af8e2 --- /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 00000000..67947303 --- /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 00000000..244928f2 --- /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 00000000..d89eb17e --- /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 00000000..c4b789cd --- /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 00000000..4f4c425a --- /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 00000000..5f35b2bc --- /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 00000000..3551307b --- /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 00000000..9de970c6 --- /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 00000000..1f2e9fd3 --- /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 00000000..f7ad43cc --- /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 00000000..4e5cb24f --- /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 00000000..b7271c70 --- /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 00000000..f20fb51f --- /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 00000000..b3f34ee2 --- /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 00000000..f12e07d9 --- /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 00000000..312f80f9 --- /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 00000000..5227b82e --- /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 00000000..18cdd7e7 --- /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 00000000..e5bc34c7 --- /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 00000000..503b852b --- /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 00000000..f1a202c2 --- /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 00000000..7f699e94 --- /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 00000000..7cc9733f --- /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 00000000..59670e8b --- /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 00000000..4f637c66 --- /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 00000000..0c3f50bc --- /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 00000000..687c5b4e --- /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 00000000..b24ff500 --- /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 00000000..6a83a819 --- /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 00000000..96e2a440 --- /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 00000000..f07a153e --- /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 00000000..aca326b3 --- /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 00000000..d5850bcd --- /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 00000000..eb3a7813 --- /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 00000000..a3d52ba0 --- /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 00000000..df268fca --- /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 00000000..8eced361 --- /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 00000000..b78a5c0d --- /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 00000000..8af4f005 --- /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 00000000..7031b66f --- /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 00000000..abeab7e3 --- /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 00000000..81ca4bb6 --- /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 00000000..f4a0d889 --- /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 00000000..acd5e173 --- /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 00000000..35b24882 --- /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 00000000..7f405cc0 --- /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 00000000..c59662ff --- /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 00000000..790503b8 --- /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 00000000..6fa0b9a5 --- /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 00000000..0b188b59 --- /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 00000000..9e2c9f7f --- /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 00000000..c563396e --- /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 00000000..f4b7330e --- /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 00000000..95e263c9 --- /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 00000000..5dbe21a0 --- /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 00000000..7747916a --- /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 00000000..840bad66 --- /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 00000000..0f566215 --- /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 00000000..02b11935 --- /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 00000000..637914b8 --- /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 00000000..6bfc8a50 --- /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 00000000..b92e774c --- /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 00000000..bb923179 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 00000000..616131ae 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 00000000..0987832e 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 00000000..61f82078 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 00000000..1b072b20 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 00000000..f3fb1e47 --- /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 00000000..20bf64b8 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 00000000..c078f994 --- /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 00000000..78ee67a4 --- /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 00000000..d63fc819 --- /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 00000000..4ff3fc5c --- /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 00000000..335dbca4 --- /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 00000000..1a570b35 --- /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 00000000..07b130f9 --- /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 00000000..11c63abb --- /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 00000000..1d19d2b4 --- /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 00000000..d0c5fb14 --- /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 00000000..264c7e29 --- /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 00000000..4743ecf4 --- /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 00000000..b1b7bfd0 --- /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 00000000..1ae56a71 --- /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 00000000..692ae138 --- /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 00000000..5e9006af --- /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 00000000..be53bc20 --- /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 00000000..622bbcab --- /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 00000000..1171e650 --- /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 00000000..545b821d --- /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 00000000..b4195bef --- /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 00000000..808bcb1b --- /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 00000000..b085c435 --- /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 00000000..1c0daf3b --- /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/package.json b/package.json index 17aaa74d..6156dc4a 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,7 @@ "web:dev": "turbo run dev --filter=@modrinth/frontend", "web:build": "turbo run build --filter=@modrinth/frontend", "app:dev": "turbo run dev --filter=@modrinth/app", + "docs:dev": "turbo run dev --filter=@modrinth/docs", "app:build": "turbo run build --filter=@modrinth/app", "pages:build": "NITRO_PRESET=cloudflare-pages pnpm --filter frontend run build", "build": "turbo run build --continue", diff --git a/packages/app-lib/Cargo.toml b/packages/app-lib/Cargo.toml index 3f710329..a7eff728 100644 --- a/packages/app-lib/Cargo.toml +++ b/packages/app-lib/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "theseus" -version = "0.8.8" +version = "0.8.9" authors = ["Jai A "] edition = "2021" @@ -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 b674c825..931e852f 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/packages/assets/styles/variables.scss b/packages/assets/styles/variables.scss index de394261..3bacca98 100644 --- a/packages/assets/styles/variables.scss +++ b/packages/assets/styles/variables.scss @@ -26,7 +26,8 @@ html { } .light-mode, -.light { +.light, +:root[data-theme='light'] { --color-bg: #e5e7eb; --color-raised-bg: #ffffff; --color-super-raised-bg: #e9e9e9; @@ -71,7 +72,8 @@ html { } .dark-mode, -.dark { +.dark, +:root[data-theme='dark'] { --color-bg: #16181c; --color-raised-bg: #26292f; --color-super-raised-bg: #40434a; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 86c4214e..909a09e7 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,10 +18,10 @@ importers: version: 3.3.3 turbo: specifier: ^2.0.6 - version: 2.1.1 + version: 2.2.1 vue: specifier: ^3.4.31 - version: 3.5.4(typescript@5.6.2) + version: 3.5.12(typescript@5.6.3) apps/app: dependencies: @@ -49,112 +49,136 @@ importers: version: link:../../packages/utils '@sentry/vue': specifier: ^8.27.0 - version: 8.30.0(vue@3.5.4(typescript@5.6.2)) + version: 8.34.0(vue@3.5.12(typescript@5.6.3)) '@tauri-apps/api': specifier: ^2.0.0-rc.3 - version: 2.0.0-rc.3 + version: 2.0.2 '@tauri-apps/plugin-dialog': specifier: ^2.0.0-rc.0 - version: 2.0.0-rc.1 + version: 2.0.0 '@tauri-apps/plugin-os': specifier: ^2.0.0-rc.0 - version: 2.0.0-rc.1 + version: 2.0.0 '@tauri-apps/plugin-shell': specifier: ^2.0.0-rc.0 - version: 2.0.0-rc.1 + version: 2.0.0 '@tauri-apps/plugin-updater': specifier: ^2.0.0-rc.0 - version: 2.0.0-rc.2 + version: 2.0.0 '@tauri-apps/plugin-window-state': specifier: ^2.0.0-rc.0 - version: 2.0.0-rc.1 + version: 2.0.0 '@vintl/vintl': specifier: ^4.4.1 - version: 4.4.1(typescript@5.6.2)(vue@3.5.4(typescript@5.6.2)) + version: 4.4.1(typescript@5.6.3)(vue@3.5.12(typescript@5.6.3)) dayjs: specifier: ^1.11.10 version: 1.11.13 floating-vue: specifier: ^5.2.2 - version: 5.2.2(@nuxt/kit@3.13.2(magicast@0.3.5))(vue@3.5.4(typescript@5.6.2)) + version: 5.2.2(@nuxt/kit@3.13.2(magicast@0.3.5))(vue@3.5.12(typescript@5.6.3)) ofetch: specifier: ^1.3.4 - version: 1.3.4 + version: 1.4.1 pinia: specifier: ^2.1.7 - version: 2.2.2(typescript@5.6.2)(vue@3.5.4(typescript@5.6.2)) + version: 2.2.4(typescript@5.6.3)(vue@3.5.12(typescript@5.6.3)) posthog-js: specifier: ^1.158.2 - version: 1.161.3 + version: 1.169.0 vite-svg-loader: specifier: ^5.1.0 - version: 5.1.0(vue@3.5.4(typescript@5.6.2)) + version: 5.1.0(vue@3.5.12(typescript@5.6.3)) vue: specifier: ^3.4.21 - version: 3.5.4(typescript@5.6.2) + version: 3.5.12(typescript@5.6.3) vue-multiselect: specifier: 3.0.0 version: 3.0.0 vue-router: specifier: 4.3.0 - version: 4.3.0(vue@3.5.4(typescript@5.6.2)) + version: 4.3.0(vue@3.5.12(typescript@5.6.3)) vue-virtual-scroller: specifier: v2.0.0-beta.8 - version: 2.0.0-beta.8(vue@3.5.4(typescript@5.6.2)) + version: 2.0.0-beta.8(vue@3.5.12(typescript@5.6.3)) devDependencies: '@eslint/compat': specifier: ^1.1.1 - version: 1.1.1 + version: 1.2.0(eslint@9.12.0(jiti@2.3.3)) '@nuxt/eslint-config': specifier: ^0.5.6 - version: 0.5.7(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) + version: 0.5.7(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) '@vitejs/plugin-vue': specifier: ^5.0.4 - version: 5.1.4(vite@5.4.8(@types/node@22.7.4)(sass@1.78.0)(terser@5.34.1))(vue@3.5.4(typescript@5.6.2)) + version: 5.1.4(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1))(vue@3.5.12(typescript@5.6.3)) autoprefixer: specifier: ^10.4.19 - version: 10.4.20(postcss@8.4.45) + version: 10.4.20(postcss@8.4.47) eslint: specifier: ^9.9.1 - version: 9.10.0(jiti@2.1.2) + version: 9.12.0(jiti@2.3.3) eslint-config-custom: specifier: workspace:* version: link:../../packages/eslint-config-custom eslint-plugin-turbo: specifier: ^2.1.1 - version: 2.1.1(eslint@9.10.0(jiti@2.1.2)) + version: 2.1.3(eslint@9.12.0(jiti@2.3.3)) postcss: specifier: ^8.4.39 - version: 8.4.45 + version: 8.4.47 prettier: specifier: ^3.2.5 version: 3.3.3 sass: specifier: ^1.74.1 - version: 1.78.0 + version: 1.79.5 tailwindcss: specifier: ^3.4.4 - version: 3.4.11 + version: 3.4.14 tsconfig: specifier: workspace:* version: link:../../packages/tsconfig typescript: specifier: ^5.5.4 - version: 5.6.2 + version: 5.6.3 vite: specifier: ^5.2.8 - version: 5.4.8(@types/node@22.7.4)(sass@1.78.0)(terser@5.34.1) + version: 5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1) vue-tsc: specifier: ^2.1.6 - version: 2.1.6(typescript@5.6.2) + version: 2.1.6(typescript@5.6.3) apps/app-playground: {} + apps/docs: + dependencies: + '@astrojs/check': + specifier: ^0.9.3 + version: 0.9.4(prettier@3.3.3)(typescript@5.6.3) + '@astrojs/starlight': + specifier: ^0.26.3 + version: 0.26.4(astro@4.16.6(@types/node@20.16.11)(rollup@4.24.0)(sass@1.79.5)(terser@5.34.1)(typescript@5.6.3)) + '@modrinth/assets': + specifier: workspace:* + version: link:../../packages/assets + astro: + specifier: ^4.10.2 + version: 4.16.6(@types/node@20.16.11)(rollup@4.24.0)(sass@1.79.5)(terser@5.34.1)(typescript@5.6.3) + sharp: + specifier: ^0.32.5 + version: 0.32.6 + starlight-openapi: + specifier: ^0.7.0 + version: 0.7.0(@astrojs/markdown-remark@5.3.0)(@astrojs/starlight@0.26.4(astro@4.16.6(@types/node@20.16.11)(rollup@4.24.0)(sass@1.79.5)(terser@5.34.1)(typescript@5.6.3)))(astro@4.16.6(@types/node@20.16.11)(rollup@4.24.0)(sass@1.79.5)(terser@5.34.1)(typescript@5.6.3))(openapi-types@12.1.3) + typescript: + specifier: ^5.5.4 + version: 5.6.3 + apps/frontend: dependencies: '@formatjs/intl-localematcher': specifier: ^0.5.4 - version: 0.5.4 + version: 0.5.5 '@ltd/j-toml': specifier: ^1.38.0 version: 1.38.0 @@ -169,13 +193,13 @@ importers: version: link:../../packages/utils '@vintl/vintl': specifier: ^4.4.1 - version: 4.4.1(typescript@5.6.2)(vue@3.5.11(typescript@5.6.2)) + version: 4.4.1(typescript@5.6.3)(vue@3.5.12(typescript@5.6.3)) dayjs: specifier: ^1.11.7 version: 1.11.13 floating-vue: specifier: 2.0.0-beta.20 - version: 2.0.0-beta.20(vue@3.5.11(typescript@5.6.2)) + version: 2.0.0-beta.20(vue@3.5.12(typescript@5.6.3)) fuse.js: specifier: ^6.6.2 version: 6.6.2 @@ -199,7 +223,7 @@ importers: version: 1.1.2 qrcode.vue: specifier: ^3.4.0 - version: 3.4.1(vue@3.5.11(typescript@5.6.2)) + version: 3.5.0(vue@3.5.12(typescript@5.6.3)) semver: specifier: ^7.5.4 version: 7.6.3 @@ -208,65 +232,67 @@ importers: version: 3.0.0 vue3-apexcharts: specifier: ^1.5.2 - version: 1.6.0(apexcharts@3.53.0)(vue@3.5.11(typescript@5.6.2)) + version: 1.7.0(apexcharts@3.54.1)(vue@3.5.12(typescript@5.6.3)) xss: specifier: ^1.0.14 version: 1.0.15 devDependencies: '@formatjs/cli': specifier: ^6.2.12 - version: 6.2.12(@vue/compiler-core@3.5.11)(vue@3.5.11(typescript@5.6.2)) + version: 6.2.15(@vue/compiler-core@3.5.12)(vue@3.5.12(typescript@5.6.3)) '@nuxt/devtools': specifier: ^1.3.3 - version: 1.5.2(rollup@4.24.0)(vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1))(vue@3.5.11(typescript@5.6.2))(webpack-sources@3.2.3) + version: 1.6.0(rollup@4.24.0)(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1))(vue@3.5.12(typescript@5.6.3)) '@nuxtjs/turnstile': specifier: ^0.8.0 - version: 0.8.0(magicast@0.3.5)(rollup@4.24.0)(webpack-sources@3.2.3) + version: 0.8.0(magicast@0.3.5)(rollup@4.24.0) '@types/node': specifier: ^20.1.0 - version: 20.16.5 + version: 20.16.11 '@vintl/compact-number': specifier: ^2.0.5 - version: 2.0.7(@formatjs/intl@2.10.5(typescript@5.6.2)) + version: 2.0.7(@formatjs/intl@2.10.8(typescript@5.6.3)) '@vintl/how-ago': specifier: ^3.0.1 - version: 3.0.1(@formatjs/intl@2.10.5(typescript@5.6.2)) + version: 3.0.1(@formatjs/intl@2.10.8(typescript@5.6.3)) '@vintl/nuxt': specifier: ^1.9.2 - version: 1.9.2(@vue/compiler-core@3.5.11)(magicast@0.3.5)(rollup@4.24.0)(typescript@5.6.2)(vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1))(vue@3.5.11(typescript@5.6.2))(webpack-sources@3.2.3)(webpack@5.94.0) + version: 1.9.2(@vue/compiler-core@3.5.12)(magicast@0.3.5)(rollup@4.24.0)(typescript@5.6.3)(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1))(vue@3.5.12(typescript@5.6.3)) autoprefixer: specifier: ^10.4.19 - version: 10.4.20(postcss@8.4.45) + version: 10.4.20(postcss@8.4.47) eslint: specifier: ^8.57.0 - version: 8.57.0 + version: 8.57.1 glob: specifier: ^10.2.7 version: 10.4.5 nuxt: specifier: ^3.12.3 - version: 3.13.2(@parcel/watcher@2.4.1)(@types/node@20.16.5)(eslint@8.57.0)(ioredis@5.4.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.24.0)(sass@1.78.0)(terser@5.34.1)(typescript@5.6.2)(vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1))(vue-tsc@2.1.6(typescript@5.6.2))(webpack-sources@3.2.3) + version: 3.13.2(@parcel/watcher@2.4.1)(@types/node@20.16.11)(eslint@8.57.1)(ioredis@5.4.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.24.0)(sass@1.79.5)(terser@5.34.1)(typescript@5.6.3)(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1))(vue-tsc@2.1.6(typescript@5.6.3)) postcss: specifier: ^8.4.39 - version: 8.4.45 + version: 8.4.47 prettier-plugin-tailwindcss: specifier: ^0.6.5 - version: 0.6.6(prettier@3.3.3) + version: 0.6.8(prettier@3.3.3) sass: specifier: ^1.58.0 - version: 1.78.0 + version: 1.79.5 tailwindcss: specifier: ^3.4.4 - version: 3.4.11 + version: 3.4.14 typescript: specifier: ^5.4.5 - version: 5.6.2 + version: 5.6.3 vite-svg-loader: specifier: ^5.1.0 - version: 5.1.0(vue@3.5.11(typescript@5.6.2)) + version: 5.1.0(vue@3.5.12(typescript@5.6.3)) vue-tsc: specifier: ^2.0.24 - version: 2.1.6(typescript@5.6.2) + version: 2.1.6(typescript@5.6.3) + + apps/labrinth: {} packages/app-lib: {} @@ -274,7 +300,7 @@ importers: devDependencies: eslint: specifier: ^8.57.0 - version: 8.57.0 + version: 8.57.1 eslint-config-custom: specifier: workspace:* version: link:../eslint-config-custom @@ -283,31 +309,31 @@ importers: version: link:../tsconfig vue: specifier: ^3.4.31 - version: 3.5.4(typescript@5.6.2) + version: 3.5.12(typescript@5.6.3) packages/eslint-config-custom: devDependencies: '@nuxtjs/eslint-config-typescript': specifier: ^12.1.0 - version: 12.1.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) + version: 12.1.0(eslint-plugin-import-x@4.3.1(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) '@vue/eslint-config-typescript': specifier: ^13.0.0 - version: 13.0.0(eslint-plugin-vue@9.28.0(eslint@9.10.0(jiti@2.1.2)))(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) + version: 13.0.0(eslint-plugin-vue@9.29.0(eslint@9.12.0(jiti@2.3.3)))(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) eslint-config-prettier: specifier: ^9.1.0 - version: 9.1.0(eslint@9.10.0(jiti@2.1.2)) + version: 9.1.0(eslint@9.12.0(jiti@2.3.3)) eslint-config-turbo: specifier: ^2.0.7 - version: 2.1.1(eslint@9.10.0(jiti@2.1.2)) + version: 2.1.3(eslint@9.12.0(jiti@2.3.3)) eslint-plugin-prettier: specifier: ^5.2.1 - version: 5.2.1(@types/eslint@9.6.1)(eslint-config-prettier@9.1.0(eslint@9.10.0(jiti@2.1.2)))(eslint@9.10.0(jiti@2.1.2))(prettier@3.3.3) + version: 5.2.1(eslint-config-prettier@9.1.0(eslint@9.12.0(jiti@2.3.3)))(eslint@9.12.0(jiti@2.3.3))(prettier@3.3.3) eslint-plugin-unicorn: specifier: ^54.0.0 - version: 54.0.0(eslint@9.10.0(jiti@2.1.2)) + version: 54.0.0(eslint@9.12.0(jiti@2.3.3)) typescript: specifier: ^5.5.3 - version: 5.6.2 + version: 5.6.3 packages/tsconfig: devDependencies: @@ -319,19 +345,19 @@ importers: dependencies: '@codemirror/commands': specifier: ^6.3.2 - version: 6.6.1 + version: 6.7.0 '@codemirror/lang-markdown': specifier: ^6.2.3 - version: 6.2.5 + version: 6.3.0 '@codemirror/language': specifier: ^6.9.3 - version: 6.10.2 + version: 6.10.3 '@codemirror/state': specifier: ^6.3.2 version: 6.4.1 '@codemirror/view': specifier: ^6.22.1 - version: 6.33.0 + version: 6.34.1 '@modrinth/assets': specifier: workspace:* version: link:../assets @@ -343,13 +369,13 @@ importers: version: 14.1.2 apexcharts: specifier: ^3.44.0 - version: 3.53.0 + version: 3.54.1 dayjs: specifier: ^1.11.10 version: 1.11.13 floating-vue: specifier: 2.0.0-beta.24 - version: 2.0.0-beta.24(@nuxt/kit@3.13.2(magicast@0.3.5)(webpack-sources@3.2.3))(vue@3.5.4(typescript@5.6.2)) + version: 2.0.0-beta.24(@nuxt/kit@3.13.2(magicast@0.3.5))(vue@3.5.12(typescript@5.6.3)) highlight.js: specifier: ^11.9.0 version: 11.10.0 @@ -358,32 +384,32 @@ importers: version: 13.0.2 qrcode.vue: specifier: ^3.4.1 - version: 3.4.1(vue@3.5.4(typescript@5.6.2)) + version: 3.5.0(vue@3.5.12(typescript@5.6.3)) vue-multiselect: specifier: 3.0.0 version: 3.0.0 vue-select: specifier: 4.0.0-beta.6 - version: 4.0.0-beta.6(vue@3.5.4(typescript@5.6.2)) + version: 4.0.0-beta.6(vue@3.5.12(typescript@5.6.3)) vue3-apexcharts: specifier: ^1.4.4 - version: 1.6.0(apexcharts@3.53.0)(vue@3.5.4(typescript@5.6.2)) + version: 1.7.0(apexcharts@3.54.1)(vue@3.5.12(typescript@5.6.3)) xss: specifier: ^1.0.14 version: 1.0.15 devDependencies: '@formatjs/cli': specifier: ^6.2.12 - version: 6.2.12(@vue/compiler-core@3.5.11)(vue@3.5.4(typescript@5.6.2)) + version: 6.2.15(@vue/compiler-core@3.5.12)(vue@3.5.12(typescript@5.6.3)) '@vintl/unplugin': specifier: ^1.5.1 - version: 1.5.2(@vue/compiler-core@3.5.11)(vue@3.5.4(typescript@5.6.2))(webpack-sources@3.2.3)(webpack@5.94.0) + version: 1.5.2(@vue/compiler-core@3.5.12)(vue@3.5.12(typescript@5.6.3)) '@vintl/vintl': specifier: ^4.4.1 - version: 4.4.1(typescript@5.6.2)(vue@3.5.4(typescript@5.6.2)) + version: 4.4.1(typescript@5.6.3)(vue@3.5.12(typescript@5.6.3)) eslint: specifier: ^8.57.0 - version: 8.57.0 + version: 8.57.1 eslint-config-custom: specifier: workspace:* version: link:../eslint-config-custom @@ -392,25 +418,25 @@ importers: version: link:../tsconfig vue: specifier: ^3.4.31 - version: 3.5.4(typescript@5.6.2) + version: 3.5.12(typescript@5.6.3) packages/utils: dependencies: '@codemirror/commands': specifier: ^6.3.2 - version: 6.6.1 + version: 6.7.0 '@codemirror/lang-markdown': specifier: ^6.2.3 - version: 6.2.5 + version: 6.3.0 '@codemirror/language': specifier: ^6.9.3 - version: 6.10.2 + version: 6.10.3 '@codemirror/state': specifier: ^6.3.2 version: 6.4.1 '@codemirror/view': specifier: ^6.22.1 - version: 6.33.0 + version: 6.34.1 '@types/markdown-it': specifier: ^14.1.1 version: 14.1.2 @@ -429,7 +455,7 @@ importers: devDependencies: eslint: specifier: ^8.57.0 - version: 8.57.0 + version: 8.57.1 eslint-config-custom: specifier: workspace:* version: link:../eslint-config-custom @@ -450,32 +476,75 @@ packages: '@antfu/utils@0.7.10': resolution: {integrity: sha512-+562v9k4aI80m1+VuMHehNJWLOFjBnXn3tdOitzD0il5b7smkSBal4+a3oKiQTbrwMmN/TBUMDvbdoWDehgOww==} - '@babel/code-frame@7.24.7': - resolution: {integrity: sha512-BcYH1CVJBO9tvyIZ2jVeXgSIMvGZ2FDRvDdOIVQyuklNKSsx+eppDEBq/g47Ayw+RqNFE+URvOShmf+f/qwAlA==} - engines: {node: '>=6.9.0'} + '@apidevtools/openapi-schemas@2.1.0': + resolution: {integrity: sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==} + engines: {node: '>=10'} + + '@apidevtools/swagger-methods@3.0.2': + resolution: {integrity: sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==} + + '@astrojs/check@0.9.4': + resolution: {integrity: sha512-IOheHwCtpUfvogHHsvu0AbeRZEnjJg3MopdLddkJE70mULItS/Vh37BHcI00mcOJcH1vhD3odbpvWokpxam7xA==} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + + '@astrojs/compiler@2.10.3': + resolution: {integrity: sha512-bL/O7YBxsFt55YHU021oL+xz+B/9HvGNId3F9xURN16aeqDK9juHGktdkCSXz+U4nqFACq6ZFvWomOzhV+zfPw==} + + '@astrojs/internal-helpers@0.4.1': + resolution: {integrity: sha512-bMf9jFihO8YP940uD70SI/RDzIhUHJAolWVcO1v5PUivxGKvfLZTLTVVxEYzGYyPsA3ivdLNqMnL5VgmQySa+g==} + + '@astrojs/language-server@2.15.3': + resolution: {integrity: sha512-2qYkHkiqduB2F6OY+zAikd2hZP1xq5LqB0RqLCMoT7KLbfspnx6qtxOueF2n1P4+YUXRHUJVfLA4FoJCEfoMDg==} + hasBin: true + peerDependencies: + prettier: ^3.0.0 + prettier-plugin-astro: '>=0.11.0' + peerDependenciesMeta: + prettier: + optional: true + prettier-plugin-astro: + optional: true + + '@astrojs/markdown-remark@5.3.0': + resolution: {integrity: sha512-r0Ikqr0e6ozPb5bvhup1qdWnSPUvQu6tub4ZLYaKyG50BXZ0ej6FhGz3GpChKpH7kglRFPObJd/bDyf2VM9pkg==} + + '@astrojs/mdx@3.1.8': + resolution: {integrity: sha512-4o/+pvgoLFG0eG96cFs4t3NzZAIAOYu57fKAprWHXJrnq/qdBV0av6BYDjoESxvxNILUYoj8sdZVWtlPWVDLog==} + engines: {node: ^18.17.1 || ^20.3.0 || >=21.0.0} + peerDependencies: + astro: ^4.8.0 + + '@astrojs/prism@3.1.0': + resolution: {integrity: sha512-Z9IYjuXSArkAUx3N6xj6+Bnvx8OdUSHA8YoOgyepp3+zJmtVYJIl/I18GozdJVW1p5u/CNpl3Km7/gwTJK85cw==} + engines: {node: ^18.17.1 || ^20.3.0 || >=21.0.0} + + '@astrojs/sitemap@3.2.1': + resolution: {integrity: sha512-uxMfO8f7pALq0ADL6Lk68UV6dNYjJ2xGUzyjjVj60JLBs5a6smtlkBYv3tQ0DzoqwS7c9n4FUx5lgv0yPo/fgA==} + + '@astrojs/starlight@0.26.4': + resolution: {integrity: sha512-ks+GAYkYGZxuCjAJR88HFafY4/K73PtkbYniGaptmdB0yDJY/HwJ/s1vIuig3j63oq9otQfuZFByxWsb4x1urg==} + peerDependencies: + astro: ^4.8.6 + + '@astrojs/telemetry@3.1.0': + resolution: {integrity: sha512-/ca/+D8MIKEC8/A9cSaPUqQNZm+Es/ZinRv0ZAzvu2ios7POQSsVD+VOj7/hypWNsNM3T7RpfgNq7H2TU1KEHA==} + engines: {node: ^18.17.1 || ^20.3.0 || >=21.0.0} + + '@astrojs/yaml2ts@0.2.1': + resolution: {integrity: sha512-CBaNwDQJz20E5WxzQh4thLVfhB3JEEGz72wRA+oJp6fQR37QLAqXZJU0mHC+yqMOQ6oj0GfRPJrz6hjf+zm6zA==} '@babel/code-frame@7.25.7': resolution: {integrity: sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.25.4': - resolution: {integrity: sha512-+LGRog6RAsCJrrrg/IO6LGmpphNe5DiK30dGjCoxxeGv49B10/3XYGxPsAwrDlMFcFEvdAUavDT8r9k/hSyQqQ==} + '@babel/compat-data@7.25.8': + resolution: {integrity: sha512-ZsysZyXY4Tlx+Q53XdnOFmqwfB9QDTHYxaZYajWRoBLuLEAwI2UIbtxOjWh/cFaa9IKUlcB+DDuoskLuKu56JA==} engines: {node: '>=6.9.0'} - '@babel/compat-data@7.25.7': - resolution: {integrity: sha512-9ickoLz+hcXCeh7jrcin+/SLWm+GkxE2kTvoYyp38p4WkdFXfQJxDFGWp/YHjiKLPx06z2A7W8XKuqbReXDzsw==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.25.2': - resolution: {integrity: sha512-BBt3opiCOxUr9euZ5/ro/Xv8/V7yJ5bjYMqG/C1YAo8MIKAnumZalCN+msbci3Pigy4lIQfPUpfMM27HMGaYEA==} - engines: {node: '>=6.9.0'} - - '@babel/core@7.25.7': - resolution: {integrity: sha512-yJ474Zv3cwiSOO9nXJuqzvwEeM+chDuQ8GJirw+pZ91sCGCyOZ3dJkVE09fTV0VEVzXyLWhh3G/AolYTPX7Mow==} - engines: {node: '>=6.9.0'} - - '@babel/generator@7.25.6': - resolution: {integrity: sha512-VPC82gr1seXOpkjAAKoLhP50vx4vGNlF4msF64dSFq1P8RfB+QAuJWGHPXXPc8QyfVWwwB/TNNU4+ayZmHNbZw==} + '@babel/core@7.25.8': + resolution: {integrity: sha512-Oixnb+DzmRT30qu9d3tJSQkxuygWm32DFykT4bRoORPa9hZ/L4KhVB/XiRm6KG+roIEM7DBQlmg27kw2HZkdZg==} engines: {node: '>=6.9.0'} '@babel/generator@7.25.7': @@ -486,10 +555,6 @@ packages: resolution: {integrity: sha512-4xwU8StnqnlIhhioZf1tqnVWeQ9pvH/ujS8hRfw/WOza+/a+1qv69BWNy+oY231maTCWgKWhfBU7kDpsds6zAA==} engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.25.2': - resolution: {integrity: sha512-U2U5LsSaZ7TAt3cfaymQ8WHh0pxvdHoEk6HVpaexxixjyEquMh0L0YNJNM6CTGKMXV1iksi0iZkGw4AcFkPaaw==} - engines: {node: '>=6.9.0'} - '@babel/helper-compilation-targets@7.25.7': resolution: {integrity: sha512-DniTEax0sv6isaw6qSQSfV4gVRNtw2rte8HHM45t9ZR0xILaufBRNkpMifCRiAPyvL4ACD6v0gfCwCmtOQaV4A==} engines: {node: '>=6.9.0'} @@ -504,20 +569,10 @@ packages: resolution: {integrity: sha512-O31Ssjd5K6lPbTX9AAYpSKrZmLeagt9uwschJd+Ixo6QiRyfpvgtVQp8qrDR9UNFjZ8+DO34ZkdrN+BnPXemeA==} engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.24.7': - resolution: {integrity: sha512-8AyH3C+74cgCVVXow/myrynrAGv+nTVg5vKu2nZph9x7RcRwzmh0VFallJuFTZ9mx6u4eSdXZfcOzSqTUm0HCA==} - engines: {node: '>=6.9.0'} - '@babel/helper-module-imports@7.25.7': resolution: {integrity: sha512-o0xCgpNmRohmnoWKQ0Ij8IdddjyBFE4T2kagL/x6M3+4zUgc+4qTOUBoNe4XxDskt1HPKO007ZPiMgLDq2s7Kw==} engines: {node: '>=6.9.0'} - '@babel/helper-module-transforms@7.25.2': - resolution: {integrity: sha512-BjyRAbix6j/wv83ftcVJmBt72QtHI56C7JXZoG2xATiLpmoC7dpd8WnkikExHDVPpi/3qCmO6WY1EaXOluiecQ==} - engines: {node: '>=6.9.0'} - peerDependencies: - '@babel/core': ^7.0.0 - '@babel/helper-module-transforms@7.25.7': resolution: {integrity: sha512-k/6f8dKG3yDz/qCwSM+RKovjMix563SLxQFo0UhRNo239SP6n9u5/eLtKD6EAjwta2JHJ49CsD8pms2HdNiMMQ==} engines: {node: '>=6.9.0'} @@ -538,10 +593,6 @@ packages: peerDependencies: '@babel/core': ^7.0.0 - '@babel/helper-simple-access@7.24.7': - resolution: {integrity: sha512-zBAIvbCMh5Ts+b86r/CjU+4XGYIs+R1j951gxI3KmmxBMhCg4oQMsv6ZXQ64XOm/cvzfU1FmoCyt6+owc5QMYg==} - engines: {node: '>=6.9.0'} - '@babel/helper-simple-access@7.25.7': resolution: {integrity: sha512-FPGAkJmyoChQeM+ruBGIDyrT2tKfZJO8NcxdC+CWNJi7N8/rZpSxK7yvBJ5O/nF1gfu5KzN7VKG3YVSLFfRSxQ==} engines: {node: '>=6.9.0'} @@ -550,53 +601,28 @@ packages: resolution: {integrity: sha512-pPbNbchZBkPMD50K0p3JGcFMNLVUCuU/ABybm/PGNj4JiHrpmNyqqCphBk4i19xXtNV0JhldQJJtbSW5aUvbyA==} engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.24.8': - resolution: {integrity: sha512-pO9KhhRcuUyGnJWwyEgnRJTSIZHiT+vMD0kPeD+so0l7mxkMT19g3pjY9GTnHySck/hDzq+dtW/4VgnMkippsQ==} - engines: {node: '>=6.9.0'} - '@babel/helper-string-parser@7.25.7': resolution: {integrity: sha512-CbkjYdsJNHFk8uqpEkpCvRs3YRp9tY6FmFY7wLMSYuGYkrdUi7r2lc4/wqsvlHoMznX3WJ9IP8giGPq68T/Y6g==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.24.7': - resolution: {integrity: sha512-rR+PBcQ1SMQDDyF6X0wxtG8QyLCgUB0eRAGguqRLfkCA87l7yAP7ehq8SNj96OOGTO8OBV70KhuFYcIkHXOg0w==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-identifier@7.25.7': resolution: {integrity: sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==} engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.24.8': - resolution: {integrity: sha512-xb8t9tD1MHLungh/AIoWYN+gVHaB9kwlu8gffXGSt3FFEIT7RjS+xWbc2vUD1UTZdIpKj/ab3rdqJ7ufngyi2Q==} - engines: {node: '>=6.9.0'} - '@babel/helper-validator-option@7.25.7': resolution: {integrity: sha512-ytbPLsm+GjArDYXJ8Ydr1c/KJuutjF2besPNbIZnZ6MKUxi/uTA22t2ymmA4WFjZFpjiAMO0xuuJPqK2nvDVfQ==} engines: {node: '>=6.9.0'} - '@babel/helpers@7.25.6': - resolution: {integrity: sha512-Xg0tn4HcfTijTwfDwYlvVCl43V6h4KyVVX2aEm4qdO/PC6L2YvzLHFdmxhoeSA3eslcE6+ZVXHgWwopXYLNq4Q==} - engines: {node: '>=6.9.0'} - '@babel/helpers@7.25.7': resolution: {integrity: sha512-Sv6pASx7Esm38KQpF/U/OXLwPPrdGHNKoeblRxgZRLXnAtnkEe4ptJPDtAZM7fBLadbc1Q07kQpSiGQ0Jg6tRA==} engines: {node: '>=6.9.0'} - '@babel/highlight@7.24.7': - resolution: {integrity: sha512-EStJpq4OuY8xYfhGVXngigBJRWxftKX9ksiGDnmlY3o7B/V7KIAc9X4oiK87uPJSc/vs5L869bem5fhZa8caZw==} - engines: {node: '>=6.9.0'} - '@babel/highlight@7.25.7': resolution: {integrity: sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw==} engines: {node: '>=6.9.0'} - '@babel/parser@7.25.6': - resolution: {integrity: sha512-trGdfBdbD0l1ZPmcJ83eNxB9rbEax4ALFTF7fN386TMYbeCQbyme5cOEXQhbGXKebwGaB/J52w1mrklMcbgy6Q==} - engines: {node: '>=6.0.0'} - hasBin: true - - '@babel/parser@7.25.7': - resolution: {integrity: sha512-aZn7ETtQsjjGG5HruveUK06cU3Hljuhd9Iojm4M8WWv3wLE6OkE5PWbDUkItmMgegmccaITudyuW5RPYrYlgWw==} + '@babel/parser@7.25.8': + resolution: {integrity: sha512-HcttkxzdPucv3nNFmfOOMfFf64KgdJVqm1KaCm25dPGMLElo9nsLvXeJECQg8UzPuBGLyTSA0ZzqCtDSzKTEoQ==} engines: {node: '>=6.0.0'} hasBin: true @@ -635,42 +661,36 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-react-jsx@7.25.7': + resolution: {integrity: sha512-vILAg5nwGlR9EXE8JIOX4NHXd49lrYbN8hnjffDtoULwpL9hUx/N55nqh2qd0q6FyNDfjl9V79ecKGvFbcSA0Q==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0-0 + '@babel/plugin-transform-typescript@7.25.7': resolution: {integrity: sha512-VKlgy2vBzj8AmEzunocMun2fF06bsSWV+FvVXohtL6FGve/+L217qhHxRTVGHEDO/YR8IANcjzgJsd04J8ge5Q==} engines: {node: '>=6.9.0'} peerDependencies: '@babel/core': ^7.0.0-0 - '@babel/standalone@7.24.7': - resolution: {integrity: sha512-QRIRMJ2KTeN+vt4l9OjYlxDVXEpcor1Z6V7OeYzeBOw6Q8ew9oMTHjzTx8s6ClsZO7wVf6JgTRutihatN6K0yA==} + '@babel/runtime@7.25.7': + resolution: {integrity: sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==} engines: {node: '>=6.9.0'} - '@babel/standalone@7.25.7': - resolution: {integrity: sha512-7H+mK18Ew4C/pIIiZwF1eiVjUEh2Ju/BpwRZwcPeXltF/rIjHjFL0gol7PtGrHocmIq6P6ubJrylmmWQ3lGJPA==} - engines: {node: '>=6.9.0'} - - '@babel/template@7.25.0': - resolution: {integrity: sha512-aOOgh1/5XzKvg1jvVz7AVrx2piJ2XBi227DHmbY6y+bM9H2FlN+IfecYu4Xl0cNiiVejlsCri89LUsbj8vJD9Q==} + '@babel/standalone@7.25.8': + resolution: {integrity: sha512-UvRanvLCGPRscJ5Rw9o6vUBS5P+E+gkhl6eaokrIN+WM1kUkmj254VZhyihFdDZVDlI3cPcZoakbJJw24QPISw==} engines: {node: '>=6.9.0'} '@babel/template@7.25.7': resolution: {integrity: sha512-wRwtAgI3bAS+JGU2upWNL9lSlDcRCqD05BZ1n3X2ONLH1WilFP6O1otQjeMK/1g0pvYcXC7b/qVUB1keofjtZA==} engines: {node: '>=6.9.0'} - '@babel/traverse@7.25.6': - resolution: {integrity: sha512-9Vrcx5ZW6UwK5tvqsj0nGpp/XzqthkT0dqIc9g1AdtygFToNtTF67XzYS//dm+SAK9cp3B9R4ZO/46p63SCjlQ==} - engines: {node: '>=6.9.0'} - '@babel/traverse@7.25.7': resolution: {integrity: sha512-jatJPT1Zjqvh/1FyJs6qAHL+Dzb7sTb+xr7Q+gM1b+1oBsMsQQ4FkVKb6dFlJvLlVssqkRzV05Jzervt9yhnzg==} engines: {node: '>=6.9.0'} - '@babel/types@7.25.6': - resolution: {integrity: sha512-/l42B1qxpG6RdfYf343Uw1vmDjeNhneUXtzhojE7pDgfpEypmRhI6j1kr17XCVv4Cgl9HdAiQY2x0GwKm7rWCw==} - engines: {node: '>=6.9.0'} - - '@babel/types@7.25.7': - resolution: {integrity: sha512-vwIVdXG+j+FOpkwqHRcBgHLYNL7XMkufrlaFvL9o6Ai9sJn9+PdyIL5qa0XzTZw084c+u9LOls53eoZWP/W5WQ==} + '@babel/types@7.25.8': + resolution: {integrity: sha512-JWtuCu8VQsMladxVz/P4HzHUGCAwpuqacmowgXFs5XjxIgKuNjnLokQzuVjlTvIzODaDmpjT3oxcC48vyk9EWg==} engines: {node: '>=6.9.0'} '@braw/async-computed@5.0.2': @@ -682,19 +702,19 @@ packages: resolution: {integrity: sha512-YLPHc8yASwjNkmcDMQMY35yiWjoKAKnhUbPRszBRS0YgH+IXtsMp61j+yTcnCE3oO2DgP0U3iejLC8FTtKDC8Q==} engines: {node: '>=16.13'} - '@codemirror/autocomplete@6.17.0': - resolution: {integrity: sha512-fdfj6e6ZxZf8yrkMHUSJJir7OJkHkZKaOZGzLWIYp2PZ3jd+d+UjG8zVPqJF6d3bKxkhvXTPan/UZ1t7Bqm0gA==} + '@codemirror/autocomplete@6.18.1': + resolution: {integrity: sha512-iWHdj/B1ethnHRTwZj+C1obmmuCzquH29EbcKr0qIjA9NfDeBDJ7vs+WOHsFeLeflE4o+dHfYndJloMKHUkWUA==} peerDependencies: '@codemirror/language': ^6.0.0 '@codemirror/state': ^6.0.0 '@codemirror/view': ^6.0.0 '@lezer/common': ^1.0.0 - '@codemirror/commands@6.6.1': - resolution: {integrity: sha512-iBfKbyIoXS1FGdsKcZmnrxmbc8VcbMrSgD7AVrsnX+WyAYjmUDWvE93dt5D874qS4CCVu4O1JpbagHdXbbLiOw==} + '@codemirror/commands@6.7.0': + resolution: {integrity: sha512-+cduIZ2KbesDhbykV02K25A5xIVrquSPz4UxxYBemRlAT2aW8dhwUgLDwej7q/RJUHKk4nALYcR1puecDvbdqw==} - '@codemirror/lang-css@6.2.1': - resolution: {integrity: sha512-/UNWDNV5Viwi/1lpr/dIXJNWiwDxpw13I4pTUAsNxZdg6E0mI2kTQb0P2iHczg1Tu+H4EBgJR+hYhKiHKko7qg==} + '@codemirror/lang-css@6.3.0': + resolution: {integrity: sha512-CyR4rUNG9OYcXDZwMPvJdtb6PHbBDKUc/6Na2BIwZ6dKab1JQqKa4di+RNRY9Myn7JB81vayKwJeQ7jEdmNVDA==} '@codemirror/lang-html@6.4.9': resolution: {integrity: sha512-aQv37pIMSlueybId/2PVSP6NPnmurFDVmZwzc7jszd2KAF8qd4VBbvNYPXWQq90WIARjsdVkPbw29pszmHws3Q==} @@ -702,23 +722,51 @@ packages: '@codemirror/lang-javascript@6.2.2': resolution: {integrity: sha512-VGQfY+FCc285AhWuwjYxQyUQcYurWlxdKYT4bqwr3Twnd5wP5WSeu52t4tvvuWmljT4EmgEgZCqSieokhtY8hg==} - '@codemirror/lang-markdown@6.2.5': - resolution: {integrity: sha512-Hgke565YcO4fd9pe2uLYxnMufHO5rQwRr+AAhFq8ABuhkrjyX8R5p5s+hZUTdV60O0dMRjxKhBLxz8pu/MkUVA==} + '@codemirror/lang-markdown@6.3.0': + resolution: {integrity: sha512-lYrI8SdL/vhd0w0aHIEvIRLRecLF7MiiRfzXFZY94dFwHqC9HtgxgagJ8fyYNBldijGatf9wkms60d8SrAj6Nw==} - '@codemirror/language@6.10.2': - resolution: {integrity: sha512-kgbTYTo0Au6dCSc/TFy7fK3fpJmgHDv1sG1KNQKJXVi+xBTEeBPY/M30YXiU6mMXeH+YIDLsbrT4ZwNRdtF+SA==} + '@codemirror/language@6.10.3': + resolution: {integrity: sha512-kDqEU5sCP55Oabl6E7m5N+vZRoc0iWqgDVhEKifcHzPzjqCegcO4amfrYVL9PmPZpl4G0yjkpTpUO/Ui8CzO8A==} - '@codemirror/lint@6.8.1': - resolution: {integrity: sha512-IZ0Y7S4/bpaunwggW2jYqwLuHj0QtESf5xcROewY6+lDNwZ/NzvR4t+vpYgg9m7V8UXLPYqG+lu3DF470E5Oxg==} + '@codemirror/lint@6.8.2': + resolution: {integrity: sha512-PDFG5DjHxSEjOXk9TQYYVjZDqlZTFaDBfhQixHnQOEVDDNHUbEh/hstAjcQJaA6FQdZTD1hquXTK0rVBLADR1g==} '@codemirror/state@6.4.1': resolution: {integrity: sha512-QkEyUiLhsJoZkbumGZlswmAhA7CBU02Wrz7zvH4SrcifbsqwlXShVXg65f3v/ts57W3dqyamEriMhij1Z3Zz4A==} - '@codemirror/view@6.33.0': - resolution: {integrity: sha512-AroaR3BvnjRW8fiZBalAaK+ZzB5usGgI014YKElYZvQdNH5ZIidHlO+cyf/2rWzyBFRkvG6VhiXeAEbC53P2YQ==} + '@codemirror/view@6.34.1': + resolution: {integrity: sha512-t1zK/l9UiRqwUNPm+pdIT0qzJlzuVckbTEMVNFhfWkGiBQClstzg+78vedCvLSX0xJEZ6lwZbPpnljL7L6iwMQ==} - '@es-joy/jsdoccomment@0.48.0': - resolution: {integrity: sha512-G6QUWIcC+KvSwXNsJyDTHvqUdNoAVJPPgkc3+Uk4WBKqZvoXhlvazOgm9aL0HwihJLQf0l+tOE2UFzXBqCqgDw==} + '@ctrl/tinycolor@4.1.0': + resolution: {integrity: sha512-WyOx8cJQ+FQus4Mm4uPIZA64gbk3Wxh0so5Lcii0aJifqwoVOlfFtorjLE0Hen4OYyHZMXDWqMmaQemBhgxFRQ==} + engines: {node: '>=14'} + + '@emmetio/abbreviation@2.3.3': + resolution: {integrity: sha512-mgv58UrU3rh4YgbE/TzgLQwJ3pFsHHhCLqY20aJq+9comytTXUDNGG/SMtSeMJdkpxgXSXunBGLD8Boka3JyVA==} + + '@emmetio/css-abbreviation@2.1.8': + resolution: {integrity: sha512-s9yjhJ6saOO/uk1V74eifykk2CBYi01STTK3WlXWGOepyKa23ymJ053+DNQjpFcy1ingpaO7AxCcwLvHFY9tuw==} + + '@emmetio/css-parser@0.4.0': + resolution: {integrity: sha512-z7wkxRSZgrQHXVzObGkXG+Vmj3uRlpM11oCZ9pbaz0nFejvCDmAiNDpY75+wgXOcffKpj4rzGtwGaZxfJKsJxw==} + + '@emmetio/html-matcher@1.3.0': + resolution: {integrity: sha512-NTbsvppE5eVyBMuyGfVu2CRrLvo7J4YHb6t9sBFLyY03WYhXET37qA4zOYUjBWFCRHO7pS1B9khERtY0f5JXPQ==} + + '@emmetio/scanner@1.0.4': + resolution: {integrity: sha512-IqRuJtQff7YHHBk4G8YZ45uB9BaAGcwQeVzgj/zj8/UdOhtQpEIupUhSk8dys6spFIWVZVeK20CzGEnqR5SbqA==} + + '@emmetio/stream-reader-utils@0.1.0': + resolution: {integrity: sha512-ZsZ2I9Vzso3Ho/pjZFsmmZ++FWeEd/txqybHTm4OgaZzdS8V9V/YYWQwg5TC38Z7uLWUV1vavpLLbjJtKubR1A==} + + '@emmetio/stream-reader@2.2.0': + resolution: {integrity: sha512-fXVXEyFA5Yv3M3n8sUGT7+fvecGrZP4k6FnWWMSZVQf69kAq0LLpaBQLGcPR30m3zMmKYhECP4k/ZkzvhEW5kw==} + + '@emnapi/runtime@1.3.1': + resolution: {integrity: sha512-kEBmG8KyqtxJZv+ygbEim+KCGtIq1fC22Ms3S4ziXmYKm8uyoLX0MHONVKwp+9opg390VaKRNt4a7A9NwmpNhw==} + + '@es-joy/jsdoccomment@0.49.0': + resolution: {integrity: sha512-xjZTSFgECpb9Ohuk5yMX5RhUEbfeQcuOp8IF60e+wyzWEF0M5xeSgqsfLtvPEX8BIyOX9saZqzuGPmZ8oWc+5Q==} engines: {node: '>=16'} '@esbuild/aix-ppc64@0.20.2': @@ -1147,18 +1195,27 @@ packages: peerDependencies: eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 - '@eslint-community/regexpp@4.11.0': - resolution: {integrity: sha512-G/M/tIiMrTAxEWRfLfQJMmGNX28IxBg4PBz8XqQhqUHLFI6TL2htpIB1iQCj144V5ee/JaKyT9/WZ0MGZWfA7A==} + '@eslint-community/regexpp@4.11.1': + resolution: {integrity: sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} - '@eslint/compat@1.1.1': - resolution: {integrity: sha512-lpHyRyplhGPL5mGEh6M9O5nnKk0Gz4bFI+Zu6tKlPpDUN7XshWvH9C/px4UVm87IAANE0W81CEsNGbS1KlzXpA==} + '@eslint/compat@1.2.0': + resolution: {integrity: sha512-CkPWddN7J9JPrQedEr2X7AjK9y1jaMJtxZ4A/+jTMFA2+n5BWhcKHW/EbJyARqg2zzQfgtWUtVmG3hrG6+nGpg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + peerDependencies: + eslint: ^9.10.0 + peerDependenciesMeta: + eslint: + optional: true '@eslint/config-array@0.18.0': resolution: {integrity: sha512-fTxvnS1sRMu3+JjXwJG0j/i4RT9u4qJ+lqS/yCGap4lH4zZGzQ7tu+xZqQmcMZq5OBZDL4QRxQzRjkWcGt8IVw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/core@0.6.0': + resolution: {integrity: sha512-8I2Q8ykA4J0x0o7cg67FPVnehcqWTBehu/lmY+bolPFHGjh49YzGBMXTvpqVgEbBdvNCSxj6iFgiIyHzf03lzg==} + engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@eslint/eslintrc@2.1.4': resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} @@ -1167,22 +1224,34 @@ packages: resolution: {integrity: sha512-4Bfj15dVJdoy3RfZmmo86RK1Fwzn6SstsvK9JS+BaVKqC6QQQQyXekNaC+g+LKNgkQ+2VhGAzm6hO40AhMR3zQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/js@8.57.0': - resolution: {integrity: sha512-Ys+3g2TaW7gADOJzPt83SJtCDhMjndcDMFVQ/Tj9iA1BfJzFKD9mAUXT3OenpuPHbI6P/myECxRJrofUsDx/5g==} + '@eslint/js@8.57.1': + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - '@eslint/js@9.10.0': - resolution: {integrity: sha512-fuXtbiP5GWIn8Fz+LWoOMVf/Jxm+aajZYkhi6CuEm4SxymFM+eUWzbO9qXT+L0iCkL5+KGYMCSGxo686H19S1g==} + '@eslint/js@9.12.0': + resolution: {integrity: sha512-eohesHH8WFRUprDNyEREgqP6beG6htMeUYeCpkEgBCieCMme5r9zFWjzAJp//9S+Kub4rqE+jXe9Cp1a7IYIIA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@eslint/object-schema@2.1.4': resolution: {integrity: sha512-BsWiH1yFGjXXS2yvrf5LyuoSIIbPrGUWob917o+BTKuZ7qJdxX8aJLRxs1fS9n6r7vESrq1OUqb68dANcFXuQQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - '@eslint/plugin-kit@0.1.0': - resolution: {integrity: sha512-autAXT203ixhqei9xt+qkYOvY8l6LAFIdT2UXc/RPNeUVfqRF1BV94GTJyVPFKT8nFM6MyVJhjLj9E8JWvf5zQ==} + '@eslint/plugin-kit@0.2.0': + resolution: {integrity: sha512-vH9PiIMMwvhCx31Af3HiGzsVNULDbyVkHXwlemn/B0TFj/00ho3y55efXrUZTfQipxoHC5u4xq6zblww1zm1Ig==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@expressive-code/core@0.35.6': + resolution: {integrity: sha512-xGqCkmfkgT7lr/rvmfnYdDSeTdCSp1otAHgoFS6wNEeO7wGDPpxdosVqYiIcQ8CfWUABh/pGqWG90q+MV3824A==} + + '@expressive-code/plugin-frames@0.35.6': + resolution: {integrity: sha512-CqjSWjDJ3wabMJZfL9ZAzH5UAGKg7KWsf1TBzr4xvUbZvWoBtLA/TboBML0U1Ls8h/4TRCIvR4VEb8dv5+QG3w==} + + '@expressive-code/plugin-shiki@0.35.6': + resolution: {integrity: sha512-xm+hzi9BsmhkDUGuyAWIydOAWer7Cs9cj8FM0t4HXaQ+qCubprT6wJZSKUxuvFJIUsIOqk1xXFaJzGJGnWtKMg==} + + '@expressive-code/plugin-text-markers@0.35.6': + resolution: {integrity: sha512-/k9eWVZSCs+uEKHR++22Uu6eIbHWEciVHbIuD8frT8DlqTtHYaaiwHPncO6KFWnGDz5i/gL7oyl6XmOi/E6GVg==} + '@fastify/busboy@2.1.1': resolution: {integrity: sha512-vBZP4NlzfOlerQTnba4aqZoMhE/a9HY7HRqoOPaETQcSQuWEIyZMHGfVu6w9wGtGK5fED5qRs2DteVCjOH60sA==} engines: {node: '>=14'} @@ -1190,8 +1259,8 @@ packages: '@floating-ui/core@0.3.1': resolution: {integrity: sha512-ensKY7Ub59u16qsVIFEo2hwTCqZ/r9oZZFh51ivcLGHfUwTn8l1Xzng8RJUe91H/UP8PeqeBronAGx0qmzwk2g==} - '@floating-ui/core@1.6.7': - resolution: {integrity: sha512-yDzVT/Lm101nQ5TCVeK65LtdN7Tj4Qpr9RTXJ2vPFLqtLxwOrpoxAHAJI8J3yYWUc40J0BDBheaitK5SJmno2g==} + '@floating-ui/core@1.6.8': + resolution: {integrity: sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==} '@floating-ui/dom@0.1.10': resolution: {integrity: sha512-4kAVoogvQm2N0XE0G6APQJuCNuErjOfPW8Ux7DFxh8+AfugWflwVJ5LDlHOwrwut7z/30NUvdtHzQ3zSip4EzQ==} @@ -1199,11 +1268,11 @@ packages: '@floating-ui/dom@1.1.1': resolution: {integrity: sha512-TpIO93+DIujg3g7SykEAGZMDtbJRrmnYRCNYSjJlvIbGhBjRSNTLVbNeDQBrzy9qDgUbiWdc7KA0uZHZ2tJmiw==} - '@floating-ui/utils@0.2.7': - resolution: {integrity: sha512-X8R8Oj771YRl/w+c1HqAC1szL8zWQRwFvgDwT129k9ACdBoud/+/rX9V0qiMl6LWUdP9voC2nDVZYPMQQsb6eA==} + '@floating-ui/utils@0.2.8': + resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} - '@formatjs/cli-lib@6.4.2': - resolution: {integrity: sha512-Khj1fVZgubtI6FNGmmQGiEg6Kfc2zBZhsJjkCmoofubbDq2IV4yV89uVpdNyroG8mzHUkXlM8yUh0cWzR4Z/Bg==} + '@formatjs/cli-lib@6.4.5': + resolution: {integrity: sha512-KMbTFh0Fb7WWhsV4QqUisQNDRe7gSZ6P7cD+3agkqFAZuZPzb1fXiy5kvDrtdT+Ar4zpZKXTY8M2Don9g/KJ1A==} engines: {node: '>= 16'} peerDependencies: '@glimmer/env': ^0.1.7 @@ -1232,8 +1301,8 @@ packages: vue: optional: true - '@formatjs/cli@6.2.12': - resolution: {integrity: sha512-bt1NEgkeYN8N9zWcpsPu3fZ57vv+biA+NtIQBlyOZnCp1bcvh+vNTXvmwF4C5qxqDtCylpOIb3yi3Ktgp4v0JQ==} + '@formatjs/cli@6.2.15': + resolution: {integrity: sha512-s31YblAseSVqgFvY2EoIZaaEycifR/CadvMj1WcNvFvHK+2Xn02OuSX1jiKM/Nx29hX2x8k0raFJ6PtnXZgjtQ==} engines: {node: '>= 16'} hasBin: true peerDependencies: @@ -1266,23 +1335,23 @@ packages: '@formatjs/ecma402-abstract@1.18.3': resolution: {integrity: sha512-J961RbhyjHWeCIv+iOceNxpoZ/qomJOs5lH+rUJCeKNa59gME4KC0LJVMeWODjHsnv/hTH8Hvd6sevzcAzjuaQ==} - '@formatjs/ecma402-abstract@2.0.0': - resolution: {integrity: sha512-rRqXOqdFmk7RYvj4khklyqzcfQl9vEL/usogncBHRZfZBDOwMGuSRNFl02fu5KGHXdbinju+YXyuR+Nk8xlr/g==} + '@formatjs/ecma402-abstract@2.2.0': + resolution: {integrity: sha512-IpM+ev1E4QLtstniOE29W1rqH9eTdx5hQdNL8pzrflMj/gogfaoONZqL83LUeQScHAvyMbpqP5C9MzNf+fFwhQ==} - '@formatjs/fast-memoize@2.2.0': - resolution: {integrity: sha512-hnk/nY8FyrL5YxwP9e4r9dqeM6cAbo8PeU9UjyXojZMNvVad2Z06FAVHyR3Ecw6fza+0GH7vdJgiKIVXTMbSBA==} + '@formatjs/fast-memoize@2.2.1': + resolution: {integrity: sha512-XS2RcOSyWxmUB7BUjj3mlPH0exsUzlf6QfhhijgI941WaJhVxXQ6mEWkdUFIdnKi3TuTYxRdelsgv3mjieIGIA==} - '@formatjs/icu-messageformat-parser@2.7.8': - resolution: {integrity: sha512-nBZJYmhpcSX0WeJ5SDYUkZ42AgR3xiyhNCsQweFx3cz/ULJjym8bHAzWKvG5e2+1XO98dBYC0fWeeAECAVSwLA==} + '@formatjs/icu-messageformat-parser@2.7.10': + resolution: {integrity: sha512-wlQfqCZ7PURkUNL2+8VTEFavPovtADU/isSKLFvDbdFmV7QPZIYqFMkhklaDYgMyLSBJa/h2MVQ2aFvoEJhxgg==} - '@formatjs/icu-skeleton-parser@1.8.2': - resolution: {integrity: sha512-k4ERKgw7aKGWJZgTarIcNEmvyTVD9FYh0mTrrBMHZ1b8hUu6iOJ4SzsZlo3UNAvHYa+PnvntIwRPt1/vy4nA9Q==} + '@formatjs/icu-skeleton-parser@1.8.4': + resolution: {integrity: sha512-LMQ1+Wk1QSzU4zpd5aSu7+w5oeYhupRwZnMQckLPRYhSjf2/8JWQ882BauY9NyHxs5igpuQIXZDgfkaH3PoATg==} - '@formatjs/intl-displaynames@6.6.8': - resolution: {integrity: sha512-Lgx6n5KxN16B3Pb05z3NLEBQkGoXnGjkTBNCZI+Cn17YjHJ3fhCeEJJUqRlIZmJdmaXQhjcQVDp6WIiNeRYT5g==} + '@formatjs/intl-displaynames@6.6.10': + resolution: {integrity: sha512-tUz5qT61og1WwMM0K1/p46J69gLl1YJbty8xhtbigDA9LhbBmW2ShDg4ld+aqJTwCq4WK3Sj0VlFCKvFYeY3rQ==} - '@formatjs/intl-listformat@7.5.7': - resolution: {integrity: sha512-MG2TSChQJQT9f7Rlv+eXwUFiG24mKSzmF144PLb8m8OixyXqn4+YWU+5wZracZGCgVTVmx8viCf7IH3QXoiB2g==} + '@formatjs/intl-listformat@7.5.9': + resolution: {integrity: sha512-HqtGkxUh2Uz0oGVTxHAvPZ3EGxc8+ol5+Bx7S9xB97d4PEJJd9oOgHrerIghHA0gtIjsNKBFUae3P0My+F6YUA==} '@formatjs/intl-localematcher@0.4.2': resolution: {integrity: sha512-BGdtJFmaNJy5An/Zan4OId/yR9Ih1OojFjcduX/xOvq798OgWSyDtd6Qd5jqJXwJs1ipe4Fxu9+cshic5Ox2tA==} @@ -1290,32 +1359,35 @@ packages: '@formatjs/intl-localematcher@0.5.4': resolution: {integrity: sha512-zTwEpWOzZ2CiKcB93BLngUX59hQkuZjT2+SAQEscSm52peDW/getsawMcWF1rGRpMCX6D7nSJA3CzJ8gn13N/g==} - '@formatjs/intl@2.10.4': - resolution: {integrity: sha512-56483O+HVcL0c7VucAS2tyH020mt9XTozZO67cwtGg0a7KWDukS/FzW3OnvaHmTHDuYsoPIzO+ZHVfU6fT/bJw==} + '@formatjs/intl-localematcher@0.5.5': + resolution: {integrity: sha512-t5tOGMgZ/i5+ALl2/offNqAQq/lfUnKLEw0mXQI4N4bqpedhrSE+fyKLpwnd22sK0dif6AV+ufQcTsKShB9J1g==} + + '@formatjs/intl@2.10.8': + resolution: {integrity: sha512-eY8r8RMmrRTTkLdbNBOZLFGXN3OnrEmInaNt8s4msIVfo+xuLqAqvB3W1jevj0I9QjU6ueIP7tEk+1rj6Xbv5A==} peerDependencies: typescript: ^4.7 || 5 peerDependenciesMeta: typescript: optional: true - '@formatjs/intl@2.10.5': - resolution: {integrity: sha512-f9qPNNgLrh2KvoFvHGIfcPTmNGbyy7lyyV4/P6JioDqtTE7Akdmgt+ZzVndr+yMLZnssUShyTMXxM/6aV9eVuQ==} - peerDependencies: - typescript: ^4.7 || 5 - peerDependenciesMeta: - typescript: - optional: true - - '@formatjs/ts-transformer@3.13.14': - resolution: {integrity: sha512-TP/R54lxQ9Drzzimxrrt6yBT/xBofTgYl5wSTpyKe3Aq9vIBVcFmS6EOqycj0X34KGu3EpDPGO0ng8ZQZGLIFg==} + '@formatjs/ts-transformer@3.13.16': + resolution: {integrity: sha512-ZIV7KB2EQ5w9k7yrwSsdGdoOgqlXNd2sfG317pbJPHDgIo04sxoRzZPayCiNo7VWaRyqkVYUpME94rd54FDvuw==} peerDependencies: ts-jest: '>=27' peerDependenciesMeta: ts-jest: optional: true - '@humanwhocodes/config-array@0.11.14': - resolution: {integrity: sha512-3T8LkOmg45BV5FICb15QQMsyUSWrQ8AygVfC7ZG32zOalnqrilm018ZVCw0eapXux8FtA33q8PSRSstjee3jSg==} + '@humanfs/core@0.19.0': + resolution: {integrity: sha512-2cbWIHbZVEweE853g8jymffCA+NCMiuqeECeBBLm8dg2oFdjuGJhgN4UAbI+6v0CKbbhvtXA4qV8YR5Ji86nmw==} + engines: {node: '>=18.18.0'} + + '@humanfs/node@0.16.5': + resolution: {integrity: sha512-KSPA4umqSG4LHYRodq31VDwKAvaTF4xmVlzM8Aeh4PlU1JQ3IG0wiA8C25d3RQ9nJyM3mBHyI53K06VVL/oFFg==} + engines: {node: '>=18.18.0'} + + '@humanwhocodes/config-array@0.13.0': + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} deprecated: Use @eslint/config-array instead @@ -1323,14 +1395,123 @@ packages: resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} engines: {node: '>=12.22'} + '@humanwhocodes/momoa@2.0.4': + resolution: {integrity: sha512-RE815I4arJFtt+FVeU1Tgp9/Xvecacji8w/V6XtXsWWH/wz/eNkNbhb+ny/+PlVZjV0rxQpRSQKNKE3lcktHEA==} + engines: {node: '>=10.10.0'} + '@humanwhocodes/object-schema@2.0.3': resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} deprecated: Use @eslint/object-schema instead - '@humanwhocodes/retry@0.3.0': - resolution: {integrity: sha512-d2CGZR2o7fS6sWB7DG/3a95bGKQyHMACZ5aW8qGkkqQpUoZV6C0X7Pc7l4ZNMZkfNBf4VWNe9E1jRsf0G146Ew==} + '@humanwhocodes/retry@0.3.1': + resolution: {integrity: sha512-JBxkERygn7Bv/GbN5Rv8Ul6LVknS+5Bp6RgDC/O8gEBU/yeH5Ui5C/OlWrTb6qct7LjjfT6Re2NxB0ln0yYybA==} engines: {node: '>=18.18'} + '@img/sharp-darwin-arm64@0.33.5': + resolution: {integrity: sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [darwin] + + '@img/sharp-darwin-x64@0.33.5': + resolution: {integrity: sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-darwin-arm64@1.0.4': + resolution: {integrity: sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==} + cpu: [arm64] + os: [darwin] + + '@img/sharp-libvips-darwin-x64@1.0.4': + resolution: {integrity: sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==} + cpu: [x64] + os: [darwin] + + '@img/sharp-libvips-linux-arm64@1.0.4': + resolution: {integrity: sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linux-arm@1.0.5': + resolution: {integrity: sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==} + cpu: [arm] + os: [linux] + + '@img/sharp-libvips-linux-s390x@1.0.4': + resolution: {integrity: sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==} + cpu: [s390x] + os: [linux] + + '@img/sharp-libvips-linux-x64@1.0.4': + resolution: {integrity: sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==} + cpu: [x64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + resolution: {integrity: sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==} + cpu: [arm64] + os: [linux] + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + resolution: {integrity: sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==} + cpu: [x64] + os: [linux] + + '@img/sharp-linux-arm64@0.33.5': + resolution: {integrity: sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linux-arm@0.33.5': + resolution: {integrity: sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm] + os: [linux] + + '@img/sharp-linux-s390x@0.33.5': + resolution: {integrity: sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [s390x] + os: [linux] + + '@img/sharp-linux-x64@0.33.5': + resolution: {integrity: sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-linuxmusl-arm64@0.33.5': + resolution: {integrity: sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [arm64] + os: [linux] + + '@img/sharp-linuxmusl-x64@0.33.5': + resolution: {integrity: sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [linux] + + '@img/sharp-wasm32@0.33.5': + resolution: {integrity: sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [wasm32] + + '@img/sharp-win32-ia32@0.33.5': + resolution: {integrity: sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [ia32] + os: [win32] + + '@img/sharp-win32-x64@0.33.5': + resolution: {integrity: sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + cpu: [x64] + os: [win32] + '@ioredis/commands@1.2.0': resolution: {integrity: sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==} @@ -1353,41 +1534,41 @@ packages: '@jridgewell/source-map@0.3.6': resolution: {integrity: sha512-1ZJTZebgqllO79ue2bm3rIGud/bOe0pP5BjSRCRxxYkEZS8STV7zN84UBbiYu7jy+eCKSnVIUgoWWE/tt+shMQ==} - '@jridgewell/sourcemap-codec@1.4.15': - resolution: {integrity: sha512-eF2rxCRulEKXHTRiDrDy6erMYWqNw4LPdQ8UQA4huuxaQsVeRPFl2oM8oDGxMFhJUWZf9McpLtJasDDZb/Bpeg==} - '@jridgewell/sourcemap-codec@1.5.0': resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} '@jridgewell/trace-mapping@0.3.25': resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + '@jsdevtools/ono@7.1.3': + resolution: {integrity: sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==} + '@kwsites/file-exists@1.1.1': resolution: {integrity: sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==} '@kwsites/promise-deferred@1.1.1': resolution: {integrity: sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==} - '@lezer/common@1.2.1': - resolution: {integrity: sha512-yemX0ZD2xS/73llMZIK6KplkjIjf2EvAHcinDi/TfJ9hS25G0388+ClHt6/3but0oOxinTcQHJLDXh6w1crzFQ==} + '@lezer/common@1.2.2': + resolution: {integrity: sha512-Z+R3hN6kXbgBWAuejUNPihylAL1Z5CaFqnIe0nTX8Ej+XlIy3EGtXxn6WtLMO+os2hRkQvm2yvaGMYliUzlJaw==} - '@lezer/css@1.1.8': - resolution: {integrity: sha512-7JhxupKuMBaWQKjQoLtzhGj83DdnZY9MckEOG5+/iLKNK2ZJqKc6hf6uc0HjwCX7Qlok44jBNqZhHKDhEhZYLA==} + '@lezer/css@1.1.9': + resolution: {integrity: sha512-TYwgljcDv+YrV0MZFFvYFQHCfGgbPMR6nuqLabBdmZoFH3EP1gvw8t0vae326Ne3PszQkbXfVBjCnf3ZVCr0bA==} - '@lezer/highlight@1.2.0': - resolution: {integrity: sha512-WrS5Mw51sGrpqjlh3d4/fOwpEV2Hd3YOkp9DBt4k8XZQcoTHZFB7sx030A6OcahF4J1nDQAa3jXlTVVYH50IFA==} + '@lezer/highlight@1.2.1': + resolution: {integrity: sha512-Z5duk4RN/3zuVO7Jq0pGLJ3qynpxUVsh7IbUbGj88+uV2ApSAn6kWg2au3iJb+0Zi7kKtqffIESgNcRXWZWmSA==} '@lezer/html@1.3.10': resolution: {integrity: sha512-dqpT8nISx/p9Do3AchvYGV3qYc4/rKr3IBZxlHmpIKam56P47RSHkSF5f13Vu9hebS1jM0HmtJIwLbWz1VIY6w==} - '@lezer/javascript@1.4.17': - resolution: {integrity: sha512-bYW4ctpyGK+JMumDApeUzuIezX01H76R1foD6LcRX224FWfyYit/HYxiPGDjXXe/wQWASjCvVGoukTH68+0HIA==} + '@lezer/javascript@1.4.19': + resolution: {integrity: sha512-j44kbR1QL26l6dMunZ1uhKBFteVGLVCBGNUD2sUaMnic+rbTviVuoK0CD1l9FTW31EueWvFFswCKMH7Z+M3JRA==} - '@lezer/lr@1.4.1': - resolution: {integrity: sha512-CHsKq8DMKBf9b3yXPDIU4DbH+ZJd/sJdYOW2llbW/HudP5u0VS6Bfq1hLYfgU7uAYGFIyGGQIsSOXGPEErZiJw==} + '@lezer/lr@1.4.2': + resolution: {integrity: sha512-pu0K1jCIdnQ12aWNaAVU5bzi7Bd1w54J3ECgANPmYLtQKP0HBj2cE/5coBD66MT10xbtIuUr7tg0Shbsvk0mDA==} - '@lezer/markdown@1.3.0': - resolution: {integrity: sha512-ErbEQ15eowmJUyT095e9NJc3BI9yZ894fjSDtHftD0InkfUBGgnKSU6dvan9jqsZuNHg2+ag/1oyDRxNsENupQ==} + '@lezer/markdown@1.3.1': + resolution: {integrity: sha512-DGlzU/i8DC8k0uz1F+jeePrkATl0jWakauTzftMQOcbaMkHbNSRki/4E2tOzJWsVpoKYhe7iTJ03aepdwVUXUA==} '@ltd/j-toml@1.38.0': resolution: {integrity: sha512-lYtBcmvHustHQtg4X7TXUu1Xa/tbLC3p2wLvgQI+fWVySguVZJF60Snxijw5EiohumxZbR10kWYFFebh1zotiw==} @@ -1396,6 +1577,9 @@ packages: resolution: {integrity: sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==} hasBin: true + '@mdx-js/mdx@3.1.0': + resolution: {integrity: sha512-/QxEhPAvGwbQmy1Px8F899L5Uc2KZ6JtXwlCgJmjSTBedwOZkByYcBG4GceIGPXRDsmfxhHazuS+hlOShRLeDw==} + '@netlify/functions@2.8.2': resolution: {integrity: sha512-DeoAQh8LuNPvBE4qsKlezjKj0PyXDryOFJfJKo3Z1qZLKzQ21sT314KQKPVjfvw6knqijj+IO+0kHXy/TJiqNA==} engines: {node: '>=14.0.0'} @@ -1420,20 +1604,24 @@ packages: resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} engines: {node: '>= 8'} + '@nolyfill/is-core-module@1.0.39': + resolution: {integrity: sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==} + engines: {node: '>=12.4.0'} + '@nuxt/devalue@2.0.2': resolution: {integrity: sha512-GBzP8zOc7CGWyFQS6dv1lQz8VVpz5C2yRszbXufwG/9zhStTIH50EtD87NmWbTMwXDvZLNg8GIpb1UFdH93JCA==} - '@nuxt/devtools-kit@1.5.2': - resolution: {integrity: sha512-IMbwflL/JLuK1JcM5yWKa+T5JGjwnCACZJw218/8bUTt/uTVgtkMueE+1/p9rhCWxvGQiT3xnCIXKhEg7xP58Q==} + '@nuxt/devtools-kit@1.6.0': + resolution: {integrity: sha512-kJ8mVKwTSN3tdEVNy7mxKCiQk9wsG5t3oOrRMWk6IEbTSov+5sOULqQSM/+OWxWsEDmDfA7QlS5sM3Ti9uMRqQ==} peerDependencies: vite: '*' - '@nuxt/devtools-wizard@1.5.2': - resolution: {integrity: sha512-wZhouI3drb7HL7KYezYb9ksK0EeSVbHDPPKdLQePVrr+7SphThqiHoWmovBB3e/D4jtO3VC07+ILZcXUnat6HQ==} + '@nuxt/devtools-wizard@1.6.0': + resolution: {integrity: sha512-n+mzz5NwnKZim0tq1oBi+x1nNXb21fp7QeBl7bYKyDT1eJ0XCxFkVTr/kB/ddkkLYZ+o8TykpeNPa74cN+xAyQ==} hasBin: true - '@nuxt/devtools@1.5.2': - resolution: {integrity: sha512-E0bqGjAEpzVu7K8soiiDOqjAQ1FaRZPqSSU0OidmRL0HNM9kIaBNr78R494OLSop0Hh0d2Uha7Yt9IEADHtgyw==} + '@nuxt/devtools@1.6.0': + resolution: {integrity: sha512-xNorMapzpM8HaW7NnAsEEO38OrmrYBzGvkkqfBU5nNh5XEymmIfCbQc7IA/GIOH9pXOV4gRutCjHCWXHYbOl3A==} hasBin: true peerDependencies: vite: '*' @@ -1448,18 +1636,10 @@ packages: peerDependencies: eslint: ^8.57.0 || ^9.0.0 - '@nuxt/kit@3.13.1': - resolution: {integrity: sha512-FkUL349lp/3nVfTIyws4UDJ3d2jyv5Pk1DC1HQUCOkSloYYMdbRcQAUcb4fe2TCLNWvHM+FhU8jnzGTzjALZYA==} - engines: {node: ^14.18.0 || >=16.10.0} - '@nuxt/kit@3.13.2': resolution: {integrity: sha512-KvRw21zU//wdz25IeE1E5m/aFSzhJloBRAQtv+evcFeZvuroIxpIQuUqhbzuwznaUwpiWbmwlcsp5uOWmi4vwA==} engines: {node: ^14.18.0 || >=16.10.0} - '@nuxt/schema@3.13.1': - resolution: {integrity: sha512-ishbhzVGspjshG9AG0hYnKYY6LWXzCtua7OXV7C/DQ2yA7rRcy1xHpzKZUDbIRyxCHHCAcBd8jfHEUmEuhEPrA==} - engines: {node: ^14.18.0 || >=16.10.0} - '@nuxt/schema@3.13.2': resolution: {integrity: sha512-CCZgpm+MkqtOMDEgF9SWgGPBXlQ01hV/6+2reDEpJuqFPGzV8HYKPBcIFvn7/z5ahtgutHLzjP71Na+hYcqSpw==} engines: {node: ^14.18.0 || >=16.10.0} @@ -1487,6 +1667,37 @@ packages: '@nuxtjs/turnstile@0.8.0': resolution: {integrity: sha512-m4u/fVKVKF1fz1nH8LYYXY51jGzd1ROfujh0cfiGAYtQtn9D+V3nKdHFG9iHmRtfZxqzbL6z32V0k+6YYD6iig==} + '@oslojs/encoding@1.1.0': + resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==} + + '@pagefind/darwin-arm64@1.1.1': + resolution: {integrity: sha512-tZ9tysUmQpFs2EqWG2+E1gc+opDAhSyZSsgKmFzhnWfkK02YHZhvL5XJXEZDqYy3s1FAKhwjTg8XDxneuBlDZQ==} + cpu: [arm64] + os: [darwin] + + '@pagefind/darwin-x64@1.1.1': + resolution: {integrity: sha512-ChohLQ39dLwaxQv0jIQB/SavP3TM5K5ENfDTqIdzLkmfs3+JlzSDyQKcJFjTHYcCzQOZVeieeGq8PdqvLJxJxQ==} + cpu: [x64] + os: [darwin] + + '@pagefind/default-ui@1.1.1': + resolution: {integrity: sha512-ZM0zDatWDnac/VGHhQCiM7UgA4ca8jpjA+VfuTJyHJBaxGqZMQnm4WoTz9E0KFcue1Bh9kxpu7uWFZfwpZZk0A==} + + '@pagefind/linux-arm64@1.1.1': + resolution: {integrity: sha512-H5P6wDoCoAbdsWp0Zx0DxnLUrwTGWGLu/VI1rcN2CyFdY2EGSvPQsbGBMrseKRNuIrJDFtxHHHyjZ7UbzaM9EA==} + cpu: [arm64] + os: [linux] + + '@pagefind/linux-x64@1.1.1': + resolution: {integrity: sha512-yJs7tTYbL2MI3HT+ngs9E1BfUbY9M4/YzA0yEM5xBo4Xl8Yu8Qg2xZTOQ1/F6gwvMrjCUFo8EoACs6LRDhtMrQ==} + cpu: [x64] + os: [linux] + + '@pagefind/windows-x64@1.1.1': + resolution: {integrity: sha512-b7/qPqgIl+lMzkQ8fJt51SfguB396xbIIR+VZ3YrL2tLuyifDJ1wL5mEm+ddmHxJ2Fki340paPcDan9en5OmAw==} + cpu: [x64] + os: [win32] + '@parcel/watcher-android-arm64@2.4.1': resolution: {integrity: sha512-LOi/WTbbh3aTn2RYddrO8pnapixAziFl6SMxHM69r3tvdSm94JtCenaKgk1GRg5FJ5wpMCpHeW+7yqPlvZv7kg==} engines: {node: '>= 10.0.0'} @@ -1580,6 +1791,21 @@ packages: '@polka/url@1.0.0-next.28': resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} + '@readme/better-ajv-errors@1.6.0': + resolution: {integrity: sha512-9gO9rld84Jgu13kcbKRU+WHseNhaVt76wYMeRDGsUGYxwJtI3RmEJ9LY9dZCYQGI8eUZLuxb5qDja0nqklpFjQ==} + engines: {node: '>=14'} + peerDependencies: + ajv: 4.11.8 - 8 + + '@readme/json-schema-ref-parser@1.2.0': + resolution: {integrity: sha512-Bt3QVovFSua4QmHa65EHUmh2xS0XJ3rgTEUPH998f4OW4VVJke3BuS16f+kM0ZLOGdvIrzrPRqwihuv5BAjtrA==} + + '@readme/openapi-parser@2.5.0': + resolution: {integrity: sha512-IbymbOqRuUzoIgxfAAR7XJt2FWl6n2yqN09fF5adacGm7W03siA3bj1Emql0X9D2T+RpBYz3x9zDsMhuoMP62A==} + engines: {node: '>=14'} + peerDependencies: + openapi-types: '>=7' + '@rollup/plugin-alias@5.1.1': resolution: {integrity: sha512-PR9zDb+rOzkRb2VD+EuKB7UC41vU5DIwZ5qqCpk0KJudcWAyi8rvYOhS7+L5aZCspw1stTViLgN5v6FF1p5cgQ==} engines: {node: '>=14.0.0'} @@ -1647,15 +1873,6 @@ packages: resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==} engines: {node: '>= 8.0.0'} - '@rollup/pluginutils@5.1.0': - resolution: {integrity: sha512-XTIWOPPcpvyKI6L1NHo0lFlCyznUEyPmPY1mc3KpPVDYulHSTvyeLNVW00QTLIAFNhR3kYnJTQHeGqU4M3n09g==} - engines: {node: '>=14.0.0'} - peerDependencies: - rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 - peerDependenciesMeta: - rollup: - optional: true - '@rollup/pluginutils@5.1.2': resolution: {integrity: sha512-/FIdS3PyZ39bjZlwqFnWqCOVnW7o963LtKMwQOD0NhQqw22gSr2YY1afu3FxRip4ZCZNsD5jq6Aaz6QV3D/Njw==} engines: {node: '>=14.0.0'} @@ -1745,59 +1962,74 @@ packages: cpu: [x64] os: [win32] - '@sentry-internal/browser-utils@8.30.0': - resolution: {integrity: sha512-pwX+awNWaxSOAsBLVLqc1+Hw+Fm1Nci9mbKFA6Ed5YzCG049PnBVQwugpmx2dcyyCqJpORhcIqb9jHdCkYmCiA==} + '@rtsao/scc@1.1.0': + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + + '@sentry-internal/browser-utils@8.34.0': + resolution: {integrity: sha512-4AcYOzPzD1tL5eSRQ/GpKv5enquZf4dMVUez99/Bh3va8qiJrNP55AcM7UzZ7WZLTqKygIYruJTU5Zu2SpEAPQ==} engines: {node: '>=14.18'} - '@sentry-internal/feedback@8.30.0': - resolution: {integrity: sha512-ParFRxQY6helxkwUDmro77Wc5uSIC6rZos88jYMrYwFmoTJaNWf4lDzPyECfdSiSYyzSMZk4dorSUN85Ul7DCg==} + '@sentry-internal/feedback@8.34.0': + resolution: {integrity: sha512-aYSM2KPUs0FLPxxbJCFSwCYG70VMzlT04xepD1Y/tTlPPOja/02tSv2tyOdZbv8Uw7xslZs3/8Lhj74oYcTBxw==} engines: {node: '>=14.18'} - '@sentry-internal/replay-canvas@8.30.0': - resolution: {integrity: sha512-y/QqcvchhtMlVA6eOZicIfTxtZarazQZJuFW0018ynPxBTiuuWSxMCLqduulXUYsFejfD8/eKHb3BpCIFdDYjg==} + '@sentry-internal/replay-canvas@8.34.0': + resolution: {integrity: sha512-x8KhZcCDpbKHqFOykYXiamX6x0LRxv6N1OJHoH+XCrMtiDBZr4Yo30d/MaS6rjmKGMtSRij30v+Uq+YWIgxUrg==} engines: {node: '>=14.18'} - '@sentry-internal/replay@8.30.0': - resolution: {integrity: sha512-/KFre+BrovPCiovgAu5N1ErJtkDVzkJA5hV3Jw011AlxRWxrmPwu6+9sV9/rn3tqYAGyq6IggYqeIOHhLh1Ihg==} + '@sentry-internal/replay@8.34.0': + resolution: {integrity: sha512-EoMh9NYljNewZK1quY23YILgtNdGgrkzJ9TPsj6jXUG0LZ0Q7N7eFWd0xOEDBvFxrmI3cSXF1i4d1sBb+eyKRw==} engines: {node: '>=14.18'} - '@sentry/browser@8.30.0': - resolution: {integrity: sha512-M+tKqawH9S3CqlAIcqdZcHbcsNQkEa9MrPqPCYvXco3C4LRpNizJP2XwBiGQY2yK+fOSvbaWpPtlI938/wuRZQ==} + '@sentry/browser@8.34.0': + resolution: {integrity: sha512-3HHG2NXxzHq1lVmDy2uRjYjGNf9NsJsTPlOC70vbQdOb+S49EdH/XMPy+J3ruIoyv6Cu0LwvA6bMOM6rHZOgNQ==} engines: {node: '>=14.18'} - '@sentry/core@8.30.0': - resolution: {integrity: sha512-CJ/FuWLw0QEKGKXGL/nm9eaOdajEcmPekLuHAuOCxID7N07R9l9laz3vFbAkUZ97GGDv3sYrJZgywfY3Moropg==} + '@sentry/core@8.34.0': + resolution: {integrity: sha512-adrXCTK/zsg5pJ67lgtZqdqHvyx6etMjQW3P82NgWdj83c8fb+zH+K79Z47pD4zQjX0ou2Ws5nwwi4wJbz4bfA==} engines: {node: '>=14.18'} - '@sentry/types@8.30.0': - resolution: {integrity: sha512-kgWW2BCjBmVlSQRG32GonHEVyeDbys74xf9mLPvynwHTgw3+NUlNAlEdu05xnb2ow4bCTHfbkS5G1zRgyv5k4Q==} + '@sentry/types@8.34.0': + resolution: {integrity: sha512-zLRc60CzohGCo6zNsNeQ9JF3SiEeRE4aDCP9fDDdIVCOKovS+mn1rtSip0qd0Vp2fidOu0+2yY0ALCz1A3PJSQ==} engines: {node: '>=14.18'} - '@sentry/utils@8.30.0': - resolution: {integrity: sha512-wZxU2HWlzsnu8214Xy7S7cRIuD6h8Z5DnnkojJfX0i0NLooepZQk2824el1Q13AakLb7/S8CHSHXOMnCtoSduw==} + '@sentry/utils@8.34.0': + resolution: {integrity: sha512-W1KoRlFUjprlh3t86DZPFxLfM6mzjRzshVfMY7vRlJFymBelJsnJ3A1lPeBZM9nCraOSiw6GtOWu6k5BAkiGIg==} engines: {node: '>=14.18'} - '@sentry/vue@8.30.0': - resolution: {integrity: sha512-0mGQXZGYgpVhmcaYmMLN4oUhaRcQ2ZoXFkoYR58wL6QAuvLR/80Ey5K7FuoLeGIaPHESm1uCqEqgv8y5a5Nx+w==} + '@sentry/vue@8.34.0': + resolution: {integrity: sha512-yHXgMxq5dC4InxMvIhSsMP3bYd6SY4EEPWgVxxHksJG10g7Rw3kY9VM/LoiYXhKIgGs420qvdZesnvhEGEZpCg==} engines: {node: '>=14.18'} peerDependencies: vue: 2.x || 3.x + '@shikijs/core@1.22.0': + resolution: {integrity: sha512-S8sMe4q71TJAW+qG93s5VaiihujRK6rqDFqBnxqvga/3LvqHEnxqBIOPkt//IdXVtHkQWKu4nOQNk0uBGicU7Q==} + + '@shikijs/engine-javascript@1.22.0': + resolution: {integrity: sha512-AeEtF4Gcck2dwBqCFUKYfsCq0s+eEbCEbkUuFou53NZ0sTGnJnJ/05KHQFZxpii5HMXbocV9URYVowOP2wH5kw==} + + '@shikijs/engine-oniguruma@1.22.0': + resolution: {integrity: sha512-5iBVjhu/DYs1HB0BKsRRFipRrD7rqjxlWTj4F2Pf+nQSPqc3kcyqFFeZXnBMzDf0HdqaFVvhDRAGiYNvyLP+Mw==} + + '@shikijs/types@1.22.0': + resolution: {integrity: sha512-Fw/Nr7FGFhlQqHfxzZY8Cwtwk5E9nKDUgeLjZgt3UuhcM3yJR9xj3ZGNravZZok8XmEZMiYkSMTPlPkULB8nww==} + + '@shikijs/vscode-textmate@9.3.0': + resolution: {integrity: sha512-jn7/7ky30idSkd/O5yDBfAnVt+JJpepofP/POZ1iMOxK59cOfqIgg/Dj0eFsjOTMw+4ycJN0uhZH/Eb0bs/EUA==} + '@sindresorhus/merge-streams@2.3.0': resolution: {integrity: sha512-LtoMMhxAlorcGhmFYI+LhPgbPZCkgP6ra1YL604EeF6U98pLlQ3iWIGMdWSC+vWmPBWBNgmDBAhnAobLROJmwg==} engines: {node: '>=18'} - '@stylistic/eslint-plugin@2.8.0': - resolution: {integrity: sha512-Ufvk7hP+bf+pD35R/QfunF793XlSRIC7USr3/EdgduK9j13i2JjmsM0LUz3/foS+jDYp2fzyWZA9N44CPur0Ow==} + '@stylistic/eslint-plugin@2.9.0': + resolution: {integrity: sha512-OrDyFAYjBT61122MIY1a3SfEgy3YCMgt2vL4eoPmvTwDBwyQhAXurxNQznlRD/jESNfYWfID8Ej+31LljvF7Xg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: '>=8.40.0' - '@tauri-apps/api@2.0.0-rc.3': - resolution: {integrity: sha512-k1erUfnoOFJwL5VNFZz0BQZ2agNstG7CNOjwpdWMl1vOaVuSn4DhJtXB0Deh9lZaaDlfrykKOyZs9c3XXpMi5Q==} - - '@tauri-apps/api@2.0.0-rc.4': - resolution: {integrity: sha512-UNiIhhKG08j4ooss2oEEVexffmWkgkYlC2M3GcX3VPtNsqFgVNL8Mcw/4Y7rO9M9S+ffAMnLOF5ypzyuyb8tyg==} + '@tauri-apps/api@2.0.2': + resolution: {integrity: sha512-3wSwmG+1kr6WrgAFKK5ijkNFPp8TT3FLj3YHUb5EwMO+3FxX4uWlfSWkeeBy+Kc1RsKzugtYLuuya+98Flj+3w==} '@tauri-apps/cli-darwin-arm64@2.0.0-rc.16': resolution: {integrity: sha512-lISZU4gG0c9PbY7h/j/gW7nJLxZEygNBrYEET6zN8R99Znf5rSO+CfjenaMcJUUj6yTAd8gzdakRpLqNSAWegA==} @@ -1864,30 +2096,48 @@ packages: engines: {node: '>= 10'} hasBin: true - '@tauri-apps/plugin-dialog@2.0.0-rc.1': - resolution: {integrity: sha512-H28gh6BfZtjflHQ+HrmWwunDriBI3AQLAKnMs50GA6zeNUULqbQr7VXbAAKeJL/0CmWcecID4PKXVoSlaWRhEg==} + '@tauri-apps/plugin-dialog@2.0.0': + resolution: {integrity: sha512-ApNkejXP2jpPBSifznPPcHTXxu9/YaRW+eJ+8+nYwqp0lLUtebFHG4QhxitM43wwReHE81WAV1DQ/b+2VBftOA==} - '@tauri-apps/plugin-os@2.0.0-rc.1': - resolution: {integrity: sha512-PV8zlSTmYfiN2xzILUmlDSEycS7UYbH2yXk/ZqF+qQU6/s+OVQvmSth4EhllFjcpvPbtqELvpzfjw+2qEouchA==} + '@tauri-apps/plugin-os@2.0.0': + resolution: {integrity: sha512-M7hG/nNyQYTJxVG/UhTKhp9mpXriwWzrs9mqDreB8mIgqA3ek5nHLdwRZJWhkKjZrnDT4v9CpA9BhYeplTlAiA==} - '@tauri-apps/plugin-shell@2.0.0-rc.1': - resolution: {integrity: sha512-JtNROc0rqEwN/g93ig5pK4cl1vUo2yn+osCpY9de64cy/d9hRzof7AuYOgvt/Xcd5VPQmlgo2AGvUh5sQRSR1A==} + '@tauri-apps/plugin-shell@2.0.0': + resolution: {integrity: sha512-OpW2+ycgJLrEoZityWeWYk+6ZWP9VyiAfbO+N/O8VfLkqyOym8kXh7odKDfINx9RAotkSGBtQM4abyKfJDkcUg==} - '@tauri-apps/plugin-updater@2.0.0-rc.2': - resolution: {integrity: sha512-Ngvpa/km/00KASvOsFqRQbVf/6BaAX/gYwQs9eFxjygDpxxlZkZDVP1Fg0urW8s5dY7ELD6UAFB/ZI/g8D0QvQ==} + '@tauri-apps/plugin-updater@2.0.0': + resolution: {integrity: sha512-N0cl71g7RPr7zK2Fe5aoIwzw14NcdLcz7XMGFWZVjprsqgDRWoxbnUkknyCQMZthjhGkppCd/wN2MIsUz+eAhQ==} - '@tauri-apps/plugin-window-state@2.0.0-rc.1': - resolution: {integrity: sha512-fQG6G6G+b3mx2QE6dAFxl3iyKvz35DpGggIczKn+qRc4Mdjsb9y42iJMUpMpZAC2q9j8h3LfknguCacifVP5lA==} + '@tauri-apps/plugin-window-state@2.0.0': + resolution: {integrity: sha512-O82iRlrh1BLgBI8CTc+NMTPxQhQo8II5admKq9mLvH45Us5i4Zcr74At6eM46nOflFd7R8bZsVNGy+PxOEqUmQ==} '@trysound/sax@0.2.0': resolution: {integrity: sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==} engines: {node: '>=10.13.0'} - '@types/eslint@9.6.1': - resolution: {integrity: sha512-FXx2pKgId/WyYo2jXw63kk7/+TY7u7AziEJxJAnSFzHlqTAS3Ync6SvgYAN/k4/PQpnnVuzoMuVnByKK2qp0ag==} + '@types/acorn@4.0.6': + resolution: {integrity: sha512-veQTnWP+1D/xbxVrPC3zHnCZRjSrKfhbMUlEA43iMZLu7EsnTtkJklIuwrCPbOi8YkvDQAiW05VQQFvvz9oieQ==} - '@types/estree@1.0.5': - resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==} + '@types/babel__core@7.20.5': + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + + '@types/babel__generator@7.6.8': + resolution: {integrity: sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==} + + '@types/babel__template@7.4.4': + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + + '@types/babel__traverse@7.20.6': + resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} + + '@types/cookie@0.6.0': + resolution: {integrity: sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==} + + '@types/debug@4.1.12': + resolution: {integrity: sha512-vIChWdVG3LG1SMxEvI/AK+FWJthlrqlTu7fbrlywTkkaONwk/UAGaULXRlf8vkzFBLVm0zkMdCquhL5aOjhXPQ==} + + '@types/estree-jsx@1.0.5': + resolution: {integrity: sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==} '@types/estree@1.0.6': resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} @@ -1895,6 +2145,9 @@ packages: '@types/fs-extra@9.0.13': resolution: {integrity: sha512-nEnwB++1u5lVDM2UI4c1+5R+FYaKfaAzS4OococimjVm3nQw3TuzH5UNsocrcTBbhnerblyHj4A49qXbIiZdpA==} + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + '@types/http-proxy@1.17.15': resolution: {integrity: sha512-25g5atgiVNTIv0LBDTg1H74Hvayx0ajtJPLLcYE3whFv75J0pWNtOBzaXJQgDTmrX1bx5U9YC2w/n65BN1HwRQ==} @@ -1913,17 +2166,29 @@ packages: '@types/markdown-it@14.1.2': resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + '@types/mdurl@2.0.0': resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + '@types/mdx@2.0.13': + resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} + + '@types/ms@0.7.34': + resolution: {integrity: sha512-nG96G3Wp6acyAgJqGasjODb+acrI7KltPiRxzHPXnP3NgI28bpQDRv53olbqGXbfcgF5aiiHmO3xpwEpS5Ld9g==} + + '@types/nlcst@2.0.3': + resolution: {integrity: sha512-vSYNSDe6Ix3q+6Z7ri9lyWqgGhJTmzRjZRqyq15N0Z/1/UnVsno9G/N40NBijoYx2seFDIl0+B2mgAb9mezUCA==} + '@types/node@17.0.45': resolution: {integrity: sha512-w+tIMs3rq2afQdsPJlODhoUEKzFP1ayaoyl1CcnwtIlsVe7K7bA1NGm4s3PraqTLlXnbIN84zuBlxBWo1u9BLw==} - '@types/node@20.16.5': - resolution: {integrity: sha512-VwYCweNo3ERajwy0IUlqqcyZ8/A7Zwa9ZP3MnENWcB11AejO+tLy3pu850goUW2FC/IJMdZUfKpX/yxL1gymCA==} + '@types/node@18.19.55': + resolution: {integrity: sha512-zzw5Vw52205Zr/nmErSEkN5FLqXPuKX/k5d1D7RKHATGqU7y6YfX9QxZraUzUrFGqH6XzOzG196BC35ltJC4Cw==} - '@types/node@22.7.4': - resolution: {integrity: sha512-y+NPi1rFzDs1NdQHHToqeiX2TIS79SWEAw9GYhkkx8bD0ChpfqC+n2j5OXOCpzfojBEBt6DnEnnG9MY0zk1XLg==} + '@types/node@20.16.11': + resolution: {integrity: sha512-y+cTCACu92FyA5fgQSAI8A1H429g7aSK2HsO7K4XYUWc4dY5IUz55JSDIYT6/VsOLfGy8vmvQYC2hfb0iF16Uw==} '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} @@ -1931,9 +2196,18 @@ packages: '@types/resolve@1.20.2': resolution: {integrity: sha512-60BCwRFOZCQhDncwQdxxeOEEkbc5dIMccYLwbxsS4TUNeVECQ/pBJ0j09mrHOl/JJvpRPGwO9SvE4nR2Nb/a4Q==} + '@types/sax@1.2.7': + resolution: {integrity: sha512-rO73L89PJxeYM3s3pPPjiPgVVcymqU490g0YO5n5By0k2Erzj6tay/4lr1CHAAU4JyOWd1rpQ8bCf6cZfHU96A==} + '@types/semver@7.5.8': resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} + '@types/unist@2.0.11': + resolution: {integrity: sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + '@typescript-eslint/eslint-plugin@6.21.0': resolution: {integrity: sha512-oy9+hTPCUFpngkEZUSzbf9MxI65wbKFoQYsgPdILTfbUldp5ovUuphZVe4i30emU9M/kP+T64Di0mxl7dSw3MA==} engines: {node: ^16.0.0 || >=18.0.0} @@ -1945,8 +2219,8 @@ packages: typescript: optional: true - '@typescript-eslint/eslint-plugin@7.16.1': - resolution: {integrity: sha512-SxdPak/5bO0EnGktV05+Hq8oatjAYVY3Zh2bye9pGZy6+jwyR3LG3YKkV4YatlsgqXP28BTeVm9pqwJM96vf2A==} + '@typescript-eslint/eslint-plugin@7.18.0': + resolution: {integrity: sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==} engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: '@typescript-eslint/parser': ^7.0.0 @@ -1956,8 +2230,8 @@ packages: typescript: optional: true - '@typescript-eslint/eslint-plugin@8.5.0': - resolution: {integrity: sha512-lHS5hvz33iUFQKuPFGheAB84LwcJ60G8vKnEhnfcK1l8kGVLro2SFYW6K0/tj8FUhRJ0VHyg1oAfg50QGbPPHw==} + '@typescript-eslint/eslint-plugin@8.9.0': + resolution: {integrity: sha512-Y1n621OCy4m7/vTXNlCbMVp87zSd7NH0L9cXD8aIpOaNlzeWxIK4+Q19A68gSmTNRZn92UjocVUWDthGxtqHFg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: '@typescript-eslint/parser': ^8.0.0 || ^8.0.0-alpha.0 @@ -1977,8 +2251,8 @@ packages: typescript: optional: true - '@typescript-eslint/parser@7.16.1': - resolution: {integrity: sha512-u+1Qx86jfGQ5i4JjK33/FnawZRpsLxRnKzGE6EABZ40KxVT/vWsiZFEBBHjFOljmmV3MBYOHEKi0Jm9hbAOClA==} + '@typescript-eslint/parser@7.18.0': + resolution: {integrity: sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==} engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: eslint: ^8.56.0 @@ -1987,8 +2261,8 @@ packages: typescript: optional: true - '@typescript-eslint/parser@8.5.0': - resolution: {integrity: sha512-gF77eNv0Xz2UJg/NbpWJ0kqAm35UMsvZf1GHj8D9MRFTj/V3tAciIWXfmPLsAAF/vUlpWPvUDyH1jjsr0cMVWw==} + '@typescript-eslint/parser@8.9.0': + resolution: {integrity: sha512-U+BLn2rqTTHnc4FL3FJjxaXptTxmf9sNftJK62XLz4+GxG3hLHm/SUNaaXP5Y4uTiuYoL5YLy4JBCJe3+t8awQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -2001,12 +2275,12 @@ packages: resolution: {integrity: sha512-OwLUIWZJry80O99zvqXVEioyniJMa+d2GrqpUTqi5/v5D5rOrppJVBPa0yKCblcigC0/aYAzxxqQ1B+DS2RYsg==} engines: {node: ^16.0.0 || >=18.0.0} - '@typescript-eslint/scope-manager@7.16.1': - resolution: {integrity: sha512-nYpyv6ALte18gbMz323RM+vpFpTjfNdyakbf3nsLvF43uF9KeNC289SUEW3QLZ1xPtyINJ1dIsZOuWuSRIWygw==} + '@typescript-eslint/scope-manager@7.18.0': + resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==} engines: {node: ^18.18.0 || >=20.0.0} - '@typescript-eslint/scope-manager@8.5.0': - resolution: {integrity: sha512-06JOQ9Qgj33yvBEx6tpC8ecP9o860rsR22hWMEd12WcTRrfaFgHr2RB/CA/B+7BMhHkXT4chg2MyboGdFGawYg==} + '@typescript-eslint/scope-manager@8.9.0': + resolution: {integrity: sha512-bZu9bUud9ym1cabmOYH9S6TnbWRzpklVmwqICeOulTCZ9ue2/pczWzQvt/cGj2r2o1RdKoZbuEMalJJSYw3pHQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/type-utils@6.21.0': @@ -2019,8 +2293,8 @@ packages: typescript: optional: true - '@typescript-eslint/type-utils@7.16.1': - resolution: {integrity: sha512-rbu/H2MWXN4SkjIIyWcmYBjlp55VT+1G3duFOIukTNFxr9PI35pLc2ydwAfejCEitCv4uztA07q0QWanOHC7dA==} + '@typescript-eslint/type-utils@7.18.0': + resolution: {integrity: sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==} engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: eslint: ^8.56.0 @@ -2029,8 +2303,8 @@ packages: typescript: optional: true - '@typescript-eslint/type-utils@8.5.0': - resolution: {integrity: sha512-N1K8Ix+lUM+cIDhL2uekVn/ZD7TZW+9/rwz8DclQpcQ9rk4sIL5CAlBC0CugWKREmDjBzI/kQqU4wkg46jWLYA==} + '@typescript-eslint/type-utils@8.9.0': + resolution: {integrity: sha512-JD+/pCqlKqAk5961vxCluK+clkppHY07IbV3vett97KOV+8C6l+CPEPwpUuiMwgbOz/qrN3Ke4zzjqbT+ls+1Q==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '*' @@ -2042,12 +2316,12 @@ packages: resolution: {integrity: sha512-1kFmZ1rOm5epu9NZEZm1kckCDGj5UJEf7P1kliH4LKu/RkwpsfqqGmY2OOcUs18lSlQBKLDYBOGxRVtrMN5lpg==} engines: {node: ^16.0.0 || >=18.0.0} - '@typescript-eslint/types@7.16.1': - resolution: {integrity: sha512-AQn9XqCzUXd4bAVEsAXM/Izk11Wx2u4H3BAfQVhSfzfDOm/wAON9nP7J5rpkCxts7E5TELmN845xTUCQrD1xIQ==} + '@typescript-eslint/types@7.18.0': + resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==} engines: {node: ^18.18.0 || >=20.0.0} - '@typescript-eslint/types@8.5.0': - resolution: {integrity: sha512-qjkormnQS5wF9pjSi6q60bKUHH44j2APxfh9TQRXK8wbYVeDYYdYJGIROL87LGZZ2gz3Rbmjc736qyL8deVtdw==} + '@typescript-eslint/types@8.9.0': + resolution: {integrity: sha512-SjgkvdYyt1FAPhU9c6FiYCXrldwYYlIQLkuc+LfAhCna6ggp96ACncdtlbn8FmnG72tUkXclrDExOpEYf1nfJQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@typescript-eslint/typescript-estree@6.21.0': @@ -2059,8 +2333,8 @@ packages: typescript: optional: true - '@typescript-eslint/typescript-estree@7.16.1': - resolution: {integrity: sha512-0vFPk8tMjj6apaAZ1HlwM8w7jbghC8jc1aRNJG5vN8Ym5miyhTQGMqU++kuBFDNKe9NcPeZ6x0zfSzV8xC1UlQ==} + '@typescript-eslint/typescript-estree@7.18.0': + resolution: {integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==} engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: typescript: '*' @@ -2068,8 +2342,8 @@ packages: typescript: optional: true - '@typescript-eslint/typescript-estree@8.5.0': - resolution: {integrity: sha512-vEG2Sf9P8BPQ+d0pxdfndw3xIXaoSjliG0/Ejk7UggByZPKXmJmw3GW5jV2gHNQNawBUyfahoSiCFVov0Ruf7Q==} + '@typescript-eslint/typescript-estree@8.9.0': + resolution: {integrity: sha512-9iJYTgKLDG6+iqegehc5+EqE6sqaee7kb8vWpmHZ86EqwDjmlqNNHeqDVqb9duh+BY6WCNHfIGvuVU3Tf9Db0g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: typescript: '*' @@ -2083,14 +2357,14 @@ packages: peerDependencies: eslint: ^7.0.0 || ^8.0.0 - '@typescript-eslint/utils@7.16.1': - resolution: {integrity: sha512-WrFM8nzCowV0he0RlkotGDujx78xudsxnGMBHI88l5J8wEhED6yBwaSLP99ygfrzAjsQvcYQ94quDwI0d7E1fA==} + '@typescript-eslint/utils@7.18.0': + resolution: {integrity: sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==} engines: {node: ^18.18.0 || >=20.0.0} peerDependencies: eslint: ^8.56.0 - '@typescript-eslint/utils@8.5.0': - resolution: {integrity: sha512-6yyGYVL0e+VzGYp60wvkBHiqDWOpT63pdMV2CVG4LVDd5uR6q1qQN/7LafBZtAtNIn/mqXjsSeS5ggv/P0iECw==} + '@typescript-eslint/utils@8.9.0': + resolution: {integrity: sha512-PKgMmaSo/Yg/F7kIZvrgrWa1+Vwn036CdNUvYFEkYbPwOH4i8xvkaRlu148W3vtheWK9ckKRIz7PBP5oUlkrvQ==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 @@ -2099,31 +2373,31 @@ packages: resolution: {integrity: sha512-JJtkDduxLi9bivAB+cYOVMtbkqdPOhZ+ZI5LC47MIRrDV4Yn2o+ZnW10Nkmr28xRpSpdJ6Sm42Hjf2+REYXm0A==} engines: {node: ^16.0.0 || >=18.0.0} - '@typescript-eslint/visitor-keys@7.16.1': - resolution: {integrity: sha512-Qlzzx4sE4u3FsHTPQAAQFJFNOuqtuY0LFrZHwQ8IHK705XxBiWOFkfKRWu6niB7hwfgnwIpO4jTC75ozW1PHWg==} + '@typescript-eslint/visitor-keys@7.18.0': + resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} engines: {node: ^18.18.0 || >=20.0.0} - '@typescript-eslint/visitor-keys@8.5.0': - resolution: {integrity: sha512-yTPqMnbAZJNy2Xq2XU8AdtOW9tJIr+UQb64aXB9f3B1498Zx9JorVgFJcZpEc9UBuCCrdzKID2RGAMkYcDtZOw==} + '@typescript-eslint/visitor-keys@8.9.0': + resolution: {integrity: sha512-Ht4y38ubk4L5/U8xKUBfKNYGmvKvA1CANoxiTRMM+tOLk3lbF3DvzZCxJCRSE+2GdCMSh6zq9VZJc3asc1XuAA==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} '@ungap/structured-clone@1.2.0': resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} - '@unhead/dom@1.11.7': - resolution: {integrity: sha512-Nj2ulnbY5lvIcxqXwdO5YfdvLm8EYLjcaOje2b2aQnfyPAyOIVeR8iB79DDKk/uZZAPEwkdhSnUdEh9Ny0b3lw==} + '@unhead/dom@1.11.9': + resolution: {integrity: sha512-AOoCt05sLbkmp7ipCAs2JQdV0auLc5lCkLbCZj19kuPmWcFOoHNByQAG/AFKuSvi297OYp8abKGCStIgyz2x4A==} - '@unhead/schema@1.11.7': - resolution: {integrity: sha512-j9uN7T63aUXrZ6yx2CfjVT7xZHjn0PZO7TPMaWqMFjneIH/NONKvDVCMEqDlXeqdSIERIYtk/xTHgCUMer5eyw==} + '@unhead/schema@1.11.9': + resolution: {integrity: sha512-0V37bxG4sQuiLw3M5DMD+b99ndOOngecMlekQ122TDvBb24W8rWwkHhXvAu5eFg6bQXPdQF1A0U0PuRMcCj/ZA==} - '@unhead/shared@1.11.7': - resolution: {integrity: sha512-5v3PmV1LMyikGyQi/URYS5ilH8dg1Iomtja7iFWke990O8RBDEzAdagJqcsUE/fw+o7cXRSOamyx5wCf5Q1TrA==} + '@unhead/shared@1.11.9': + resolution: {integrity: sha512-Df6Td9d87NM5EWf4ylAN98zwf50DwfMg3xoy6ofz3Qg1jSXewEIMD1w1C0/Q6KdpLo01TuoQ0RfpSyVtxt7oEA==} - '@unhead/ssr@1.11.7': - resolution: {integrity: sha512-qI1zNFY8fU5S9EhroxlXSA5Q/XKbWAKXrVVNG+6bIh/IRrMOMJrPk4d1GmphF4gmNri3ARqly+OWx4VVaj0scA==} + '@unhead/ssr@1.11.9': + resolution: {integrity: sha512-iccbARNjORR//EfBlRYgM9cah4ragNaoZsDjjBpgRJoqqBtmlZdDjuEq7NAPO62SUPWce1i+2y8ntxFu+L74Sg==} - '@unhead/vue@1.11.7': - resolution: {integrity: sha512-SLr0eQfznVp63iKi47L4s5Yz+oiQjDA82VBP4jlXi7dM9fSIn1ul1aKvBqle/ZxI2cqY8zVGz60EjhjWeu754A==} + '@unhead/vue@1.11.9': + resolution: {integrity: sha512-vdl3H1bwJNindhRplMun7zhtNFggP8QqpPwc1e7kd2a0ORp776+QpFXKdYHFSlX+eAMmDVv8LQ+VL0N++pXxNg==} peerDependencies: vue: '>=2.7 || >=3' @@ -2194,17 +2468,34 @@ packages: vite: ^5.0.0 vue: ^3.2.25 - '@volar/language-core@2.4.4': - resolution: {integrity: sha512-kO9k4kTLfxpg+6lq7/KAIv3m2d62IHuCL6GbVgYZTpfKvIGoAIlDxK7pFcB/eczN2+ydg/vnyaeZ6SGyZrJw2w==} + '@volar/kit@2.4.6': + resolution: {integrity: sha512-OaMtpmLns6IYD1nOSd0NdG/F5KzJ7Jr4B7TLeb4byPzu+ExuuRVeO56Dn1C7Frnw6bGudUQd90cpQAmxdB+RlQ==} + peerDependencies: + typescript: '*' - '@volar/source-map@2.4.4': - resolution: {integrity: sha512-xG3PZqOP2haG8XG4Pg3PD1UGDAdqZg24Ru8c/qYjYAnmcj6GBR64mstx+bZux5QOyRaJK+/lNM/RnpvBD3489g==} + '@volar/language-core@2.4.6': + resolution: {integrity: sha512-FxUfxaB8sCqvY46YjyAAV6c3mMIq/NWQMVvJ+uS4yxr1KzOvyg61gAuOnNvgCvO4TZ7HcLExBEsWcDu4+K4E8A==} - '@volar/typescript@2.4.4': - resolution: {integrity: sha512-QQMQRVj0fVHJ3XdRKiS1LclhG0VBXdFYlyuHRQF/xLk2PuJuHNWP26MDZNvEVCvnyUQuUQhIAfylwY5TGPgc6w==} + '@volar/language-server@2.4.6': + resolution: {integrity: sha512-ARIbMXapEUPj9UFbZqWqw/iZ+ZuxUcY+vY212+2uutZVo/jrdzhLPu2TfZd9oB9akX8XXuslinT3051DyHLLRA==} - '@vue-macros/common@1.14.0': - resolution: {integrity: sha512-xwQhDoEXRNXobNQmdqOD20yUGdVLVLZe4zhDlT9q/E+z+mvT3wukaAoJG80XRnv/BcgOOCVpxqpkQZ3sNTgjWA==} + '@volar/language-service@2.4.6': + resolution: {integrity: sha512-wNeEVBgBKgpP1MfMYPrgTf1K8nhOGEh3ac0+9n6ECyk2N03+j0pWCpQ2i99mRWT/POvo1PgizDmYFH8S67bZOA==} + + '@volar/source-map@2.4.6': + resolution: {integrity: sha512-Nsh7UW2ruK+uURIPzjJgF0YRGP5CX9nQHypA2OMqdM2FKy7rh+uv3XgPnWPw30JADbKvZ5HuBzG4gSbVDYVtiw==} + + '@volar/typescript@2.4.6': + resolution: {integrity: sha512-NMIrA7y5OOqddL9VtngPWYmdQU03htNKFtAYidbYfWA0TOhyGVd9tfcP4TsLWQ+RBWDZCbBqsr8xzU0ZOxYTCQ==} + + '@vscode/emmet-helper@2.9.3': + resolution: {integrity: sha512-rB39LHWWPQYYlYfpv9qCoZOVioPCftKXXqrsyqN1mTWZM6dTnONT63Db+03vgrBbHzJN45IrgS/AGxw9iiqfEw==} + + '@vscode/l10n@0.0.18': + resolution: {integrity: sha512-KYSIHVmslkaCDyw013pphY+d7x1qV8IZupYfeIfzNA+nsaWHbn5uPuQRvdRFsa9zFzGeudPuoGoZ1Op4jrJXIQ==} + + '@vue-macros/common@1.15.0': + resolution: {integrity: sha512-yg5VqW7+HRfJGimdKvFYzx8zorHUYo0hzPwuraoC1DWa7HHazbTMoVsHDvk3JHa1SGfSL87fRnzmlvgjEHhszA==} engines: {node: '>=16.14.0'} peerDependencies: vue: ^2.7.0 || ^3.2.25 @@ -2228,36 +2519,21 @@ packages: peerDependencies: '@babel/core': ^7.0.0-0 - '@vue/compiler-core@3.5.11': - resolution: {integrity: sha512-PwAdxs7/9Hc3ieBO12tXzmTD+Ln4qhT/56S+8DvrrZ4kLDn4Z/AMUr8tXJD0axiJBS0RKIoNaR0yMuQB9v9Udg==} + '@vue/compiler-core@3.5.12': + resolution: {integrity: sha512-ISyBTRMmMYagUxhcpyEH0hpXRd/KqDU4ymofPgl2XAkY9ZhQ+h0ovEZJIiPop13UmR/54oA2cgMDjgroRelaEw==} - '@vue/compiler-core@3.5.4': - resolution: {integrity: sha512-oNwn+BAt3n9dK9uAYvI+XGlutwuTq/wfj4xCBaZCqwwVIGtD7D6ViihEbyYZrDHIHTDE3Q6oL3/hqmAyFEy9DQ==} + '@vue/compiler-dom@3.5.12': + resolution: {integrity: sha512-9G6PbJ03uwxLHKQ3P42cMTi85lDRvGLB2rSGOiQqtXELat6uI4n8cNz9yjfVHRPIu+MsK6TE418Giruvgptckg==} - '@vue/compiler-dom@3.5.11': - resolution: {integrity: sha512-pyGf8zdbDDRkBrEzf8p7BQlMKNNF5Fk/Cf/fQ6PiUz9at4OaUfyXW0dGJTo2Vl1f5U9jSLCNf0EZJEogLXoeew==} + '@vue/compiler-sfc@3.5.12': + resolution: {integrity: sha512-2k973OGo2JuAa5+ZlekuQJtitI5CgLMOwgl94BzMCsKZCX/xiqzJYzapl4opFogKHqwJk34vfsaKpfEhd1k5nw==} - '@vue/compiler-dom@3.5.4': - resolution: {integrity: sha512-yP9RRs4BDLOLfldn6ah+AGCNovGjMbL9uHvhDHf5wan4dAHLnFGOkqtfE7PPe4HTXIqE7l/NILdYw53bo1C8jw==} - - '@vue/compiler-sfc@3.5.11': - resolution: {integrity: sha512-gsbBtT4N9ANXXepprle+X9YLg2htQk1sqH/qGJ/EApl+dgpUBdTv3yP7YlR535uHZY3n6XaR0/bKo0BgwwDniw==} - - '@vue/compiler-sfc@3.5.4': - resolution: {integrity: sha512-P+yiPhL+NYH7m0ZgCq7AQR2q7OIE+mpAEgtkqEeH9oHSdIRvUO+4X6MPvblJIWcoe4YC5a2Gdf/RsoyP8FFiPQ==} - - '@vue/compiler-ssr@3.5.11': - resolution: {integrity: sha512-P4+GPjOuC2aFTk1Z4WANvEhyOykcvEd5bIj2KVNGKGfM745LaXGr++5njpdBTzVz5pZifdlR1kpYSJJpIlSePA==} - - '@vue/compiler-ssr@3.5.4': - resolution: {integrity: sha512-acESdTXsxPnYr2C4Blv0ggx5zIFMgOzZmYU2UgvIff9POdRGbRNBHRyzHAnizcItvpgerSKQbllUc9USp3V7eg==} + '@vue/compiler-ssr@3.5.12': + resolution: {integrity: sha512-eLwc7v6bfGBSM7wZOGPmRavSWzNFF6+PdRhE+VFJhNCgHiF8AM7ccoqcv5kBXA2eWUfigD7byekvf/JsOfKvPA==} '@vue/compiler-vue2@2.7.16': resolution: {integrity: sha512-qYC3Psj9S/mfu9uVi5WvNZIzq+xnXMhOwbTFKKDD7b1lhpnn71jXSFdTQ+WsIEk0ONCd7VV2IMm7ONl6tbQ86A==} - '@vue/devtools-api@6.6.3': - resolution: {integrity: sha512-0MiMsFma/HqA6g3KLKn+AGpL1kgKhFWszC9U29NfpWK5LE7bjeXxySWJrOJ77hBz+TBrBQ7o4QJqbPbqbs8rJw==} - '@vue/devtools-api@6.6.4': resolution: {integrity: sha512-sGhTPMuXqZ1rVOk32RylztWkfXTRhuS7vgAKv0zjqk8gbsHkJ7xfFf+jbySxt7tWObEJwyKaHMikV/WGDiQm8g==} @@ -2291,94 +2567,26 @@ packages: typescript: optional: true - '@vue/reactivity@3.5.11': - resolution: {integrity: sha512-Nqo5VZEn8MJWlCce8XoyVqHZbd5P2NH+yuAaFzuNSR96I+y1cnuUiq7xfSG+kyvLSiWmaHTKP1r3OZY4mMD50w==} + '@vue/reactivity@3.5.12': + resolution: {integrity: sha512-UzaN3Da7xnJXdz4Okb/BGbAaomRHc3RdoWqTzlvd9+WBR5m3J39J1fGcHes7U3za0ruYn/iYy/a1euhMEHvTAg==} - '@vue/reactivity@3.5.4': - resolution: {integrity: sha512-HKKbEuP7tYSGCq4e4nK6ZW6l5hyG66OUetefBp4budUyjvAYsnQDf+bgFzg2RAgnH0CInyqXwD9y47jwJEHrQw==} + '@vue/runtime-core@3.5.12': + resolution: {integrity: sha512-hrMUYV6tpocr3TL3Ad8DqxOdpDe4zuQY4HPY3X/VRh+L2myQO8MFXPAMarIOSGNu0bFAjh1yBkMPXZBqCk62Uw==} - '@vue/runtime-core@3.5.11': - resolution: {integrity: sha512-7PsxFGqwfDhfhh0OcDWBG1DaIQIVOLgkwA5q6MtkPiDFjp5gohVnJEahSktwSFLq7R5PtxDKy6WKURVN1UDbzA==} + '@vue/runtime-dom@3.5.12': + resolution: {integrity: sha512-q8VFxR9A2MRfBr6/55Q3umyoN7ya836FzRXajPB6/Vvuv0zOPL+qltd9rIMzG/DbRLAIlREmnLsplEF/kotXKA==} - '@vue/runtime-core@3.5.4': - resolution: {integrity: sha512-f3ek2sTA0AFu0n+w+kCtz567Euqqa3eHewvo4klwS7mWfSj/A+UmYTwsnUFo35KeyAFY60JgrCGvEBsu1n/3LA==} - - '@vue/runtime-dom@3.5.11': - resolution: {integrity: sha512-GNghjecT6IrGf0UhuYmpgaOlN7kxzQBhxWEn08c/SQDxv1yy4IXI1bn81JgEpQ4IXjRxWtPyI8x0/7TF5rPfYQ==} - - '@vue/runtime-dom@3.5.4': - resolution: {integrity: sha512-ofyc0w6rbD5KtjhP1i9hGOKdxGpvmuB1jprP7Djlj0X7R5J/oLwuNuE98GJ8WW31Hu2VxQHtk/LYTAlW8xrJdw==} - - '@vue/server-renderer@3.5.11': - resolution: {integrity: sha512-cVOwYBxR7Wb1B1FoxYvtjJD8X/9E5nlH4VSkJy2uMA1MzYNdzAAB//l8nrmN9py/4aP+3NjWukf9PZ3TeWULaA==} + '@vue/server-renderer@3.5.12': + resolution: {integrity: sha512-I3QoeDDeEPZm8yR28JtY+rk880Oqmj43hreIBVTicisFTx/Dl7JpG72g/X7YF8hnQD3IFhkky5i2bPonwrTVPg==} peerDependencies: - vue: 3.5.11 + vue: 3.5.12 - '@vue/server-renderer@3.5.4': - resolution: {integrity: sha512-FbjV6DJLgKRetMYFBA1UXCroCiED/Ckr53/ba9wivyd7D/Xw9fpo0T6zXzCnxQwyvkyrL7y6plgYhWhNjGxY5g==} - peerDependencies: - vue: 3.5.4 - - '@vue/shared@3.5.11': - resolution: {integrity: sha512-W8GgysJVnFo81FthhzurdRAWP/byq3q2qIw70e0JWblzVhjgOMiC2GyovXrZTFQJnFVryYaKGP3Tc9vYzYm6PQ==} - - '@vue/shared@3.5.4': - resolution: {integrity: sha512-L2MCDD8l7yC62Te5UUyPVpmexhL9ipVnYRw9CsWfm/BGRL5FwDX4a25bcJ/OJSD3+Hx+k/a8LDKcG2AFdJV3BA==} + '@vue/shared@3.5.12': + resolution: {integrity: sha512-L2RPSAwUFbgZH20etwrXyVyCBu9OxRSi8T/38QsvnkJyvq2LufW2lDCOzm7t/U9C1mkhJGWYfCuFBCmIuNivrg==} '@vue/tsconfig@0.5.1': resolution: {integrity: sha512-VcZK7MvpjuTPx2w6blwnwZAu5/LgBUtejFOi3pPGQFXQN5Ela03FUtd2Qtg4yWGGissVL0dr6Ro1LfOFh+PCuQ==} - '@webassemblyjs/ast@1.12.1': - resolution: {integrity: sha512-EKfMUOPRRUTy5UII4qJDGPpqfwjOmZ5jeGFwid9mnoqIFK+e0vqoi1qH56JpmZSzEL53jKnNzScdmftJyG5xWg==} - - '@webassemblyjs/floating-point-hex-parser@1.11.6': - resolution: {integrity: sha512-ejAj9hfRJ2XMsNHk/v6Fu2dGS+i4UaXBXGemOfQ/JfQ6mdQg/WXtwleQRLLS4OvfDhv8rYnVwH27YJLMyYsxhw==} - - '@webassemblyjs/helper-api-error@1.11.6': - resolution: {integrity: sha512-o0YkoP4pVu4rN8aTJgAyj9hC2Sv5UlkzCHhxqWj8butaLvnpdc2jOwh4ewE6CX0txSfLn/UYaV/pheS2Txg//Q==} - - '@webassemblyjs/helper-buffer@1.12.1': - resolution: {integrity: sha512-nzJwQw99DNDKr9BVCOZcLuJJUlqkJh+kVzVl6Fmq/tI5ZtEyWT1KZMyOXltXLZJmDtvLCDgwsyrkohEtopTXCw==} - - '@webassemblyjs/helper-numbers@1.11.6': - resolution: {integrity: sha512-vUIhZ8LZoIWHBohiEObxVm6hwP034jwmc9kuq5GdHZH0wiLVLIPcMCdpJzG4C11cHoQ25TFIQj9kaVADVX7N3g==} - - '@webassemblyjs/helper-wasm-bytecode@1.11.6': - resolution: {integrity: sha512-sFFHKwcmBprO9e7Icf0+gddyWYDViL8bpPjJJl0WHxCdETktXdmtWLGVzoHbqUcY4Be1LkNfwTmXOJUFZYSJdA==} - - '@webassemblyjs/helper-wasm-section@1.12.1': - resolution: {integrity: sha512-Jif4vfB6FJlUlSbgEMHUyk1j234GTNG9dBJ4XJdOySoj518Xj0oGsNi59cUQF4RRMS9ouBUxDDdyBVfPTypa5g==} - - '@webassemblyjs/ieee754@1.11.6': - resolution: {integrity: sha512-LM4p2csPNvbij6U1f19v6WR56QZ8JcHg3QIJTlSwzFcmx6WSORicYj6I63f9yU1kEUtrpG+kjkiIAkevHpDXrg==} - - '@webassemblyjs/leb128@1.11.6': - resolution: {integrity: sha512-m7a0FhE67DQXgouf1tbN5XQcdWoNgaAuoULHIfGFIEVKA6tu/edls6XnIlkmS6FrXAquJRPni3ZZKjw6FSPjPQ==} - - '@webassemblyjs/utf8@1.11.6': - resolution: {integrity: sha512-vtXf2wTQ3+up9Zsg8sa2yWiQpzSsMyXj0qViVP6xKGCUT8p8YJ6HqI7l5eCnWx1T/FYdsv07HQs2wTFbbof/RA==} - - '@webassemblyjs/wasm-edit@1.12.1': - resolution: {integrity: sha512-1DuwbVvADvS5mGnXbE+c9NfA8QRcZ6iKquqjjmR10k6o+zzsRVesil54DKexiowcFCPdr/Q0qaMgB01+SQ1u6g==} - - '@webassemblyjs/wasm-gen@1.12.1': - resolution: {integrity: sha512-TDq4Ojh9fcohAw6OIMXqiIcTq5KUXTGRkVxbSo1hQnSy6lAM5GSdfwWeSxpAo0YzgsgF182E/U0mDNhuA0tW7w==} - - '@webassemblyjs/wasm-opt@1.12.1': - resolution: {integrity: sha512-Jg99j/2gG2iaz3hijw857AVYekZe2SAskcqlWIZXjji5WStnOpVoat3gQfT/Q5tb2djnCjBtMocY/Su1GfxPBg==} - - '@webassemblyjs/wasm-parser@1.12.1': - resolution: {integrity: sha512-xikIi7c2FHXysxXe3COrVUPSheuBtpcfhbpFj4gmu7KRLYOzANztwUU0IbsqvMqzuNK2+glRGWCEqZo1WCLyAQ==} - - '@webassemblyjs/wast-printer@1.12.1': - resolution: {integrity: sha512-+X4WAlOisVWQMikjbcvY2e0rwPsKQ9F688lksZhBcPycBBuii3O7m8FACbDMWDojpAqvjIncrG8J0XHKyQfVeA==} - - '@xtuc/ieee754@1.2.0': - resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==} - - '@xtuc/long@4.2.2': - resolution: {integrity: sha512-NuHqBY1PB/D8xU6s/thBgOAiAP7HOYDQ32+BFZILJ8ivkUkAHQnWfn6WhL79Owj1qmUnoN/YPhktdIoucipkAQ==} - '@yr/monotone-cubic-spline@1.0.3': resolution: {integrity: sha512-FQXkOta0XBSUPHndIKON2Y9JeQz5ZeMqLYZVVK93FliNBFm7LNMIZmY6FrMEB9XPcDbE2bekMbZD6kzDkxwYjA==} @@ -2404,18 +2612,32 @@ packages: engines: {node: '>=0.4.0'} hasBin: true + acorn@8.13.0: + resolution: {integrity: sha512-8zSiw54Oxrdym50NlZ9sUusyO1Z1ZchgRLWRaK6c86XJFClyCgFKetdowBg5bKxyp/u+CDBJG4Mpp0m3HLZl9w==} + engines: {node: '>=0.4.0'} + hasBin: true + agent-base@6.0.2: resolution: {integrity: sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==} engines: {node: '>= 6.0.0'} - ajv-keywords@3.5.2: - resolution: {integrity: sha512-5p6WTN0DdTGVQk6VjcEju19IgaHudalcfabD7yhDGeA6bcQnmL+CpveLJq/3hvfwd1aof6L386Ougkx6RfyMIQ==} + ajv-draft-04@1.0.0: + resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==} peerDependencies: - ajv: ^6.9.1 + ajv: ^8.5.0 + peerDependenciesMeta: + ajv: + optional: true ajv@6.12.6: resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + ajv@8.17.1: + resolution: {integrity: sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==} + + ansi-align@3.0.1: + resolution: {integrity: sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==} + ansi-colors@4.1.3: resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} engines: {node: '>=6'} @@ -2451,8 +2673,8 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} - apexcharts@3.53.0: - resolution: {integrity: sha512-QESZHZY3w9LPQ64PGh1gEdfjYjJ5Jp+Dfy0D/CLjsLOPTpXzdxwlNMqRj+vPbTcP0nAHgjWv1maDqcEq6u5olw==} + apexcharts@3.54.1: + resolution: {integrity: sha512-E4et0h/J1U3r3EwS/WlqJCQIbepKbp6wGUmaAwJOMjHUP4Ci0gxanLa7FR3okx6p9coi4st6J853/Cb1NP0vpA==} aproba@2.0.0: resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==} @@ -2477,9 +2699,16 @@ packages: arg@5.0.2: resolution: {integrity: sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==} + argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + argparse@2.0.1: resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + array-buffer-byte-length@1.0.1: resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==} engines: {node: '>= 0.4'} @@ -2492,6 +2721,9 @@ packages: resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==} engines: {node: '>= 0.4'} + array-iterate@2.0.1: + resolution: {integrity: sha512-I1jXZMjAgCMmxT4qxXfPXa6SthSoE8h6gkSI9BGGNv8mP8G/v0blc+qFnZu6K42vTOiuME596QaLO0TP3Lk0xg==} + array-union@2.1.0: resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} engines: {node: '>=8'} @@ -2512,8 +2744,8 @@ packages: resolution: {integrity: sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==} engines: {node: '>= 0.4'} - ast-kit@1.2.1: - resolution: {integrity: sha512-h31wotR7rkFLrlmGPn0kGqOZ/n5EQFvp7dBs400chpHDhHc8BK3gpvyHDluRujuGgeoTAv3dSIMz9BI3JxAWyQ==} + ast-kit@1.3.0: + resolution: {integrity: sha512-ORycPY6qYSrAGMnSk1tlqy/Y0rFGk/WIYP/H6io0A+jXK2Jp3Il7h8vjfwaLvZUwanjiLwBeE5h3A9M+eQqeNw==} engines: {node: '>=16.14.0'} ast-walker-scope@0.6.2: @@ -2524,6 +2756,16 @@ packages: resolution: {integrity: sha512-LElXdjswlqjWrPpJFg1Fx4wpkOCxj1TDHlSV4PlaRxHGWko024xICaa97ZkMfs6DRKlCguiAI+rbXv5GWwXIkg==} hasBin: true + astro-expressive-code@0.35.6: + resolution: {integrity: sha512-1U4KrvFuodaCV3z4I1bIR16SdhQlPkolGsYTtiANxPZUVv/KitGSCTjzksrkPonn1XuwVqvnwmUUVzTLWngnBA==} + peerDependencies: + astro: ^4.0.0-beta || ^3.3.0 + + astro@4.16.6: + resolution: {integrity: sha512-LMMbjr+4aN26MOyJzTdjM+Y+srpAIkx7IX9IcdF3eHQLGr8PgkioZp+VQExRfioDIyA2HY6ottVg3QccTzJqYA==} + engines: {node: ^18.17.1 || ^20.3.0 || >=21.0.0, npm: '>=9.6.5', pnpm: '>=7.1.0'} + hasBin: true + async-sema@3.1.1: resolution: {integrity: sha512-tLRNUXati5MFePdAk8dw7Qt7DpxPB60ofAgn8WRhW6a2rcimZnYBP9oxHiv0OHy+Wz7kPMG+t4LGdt31+4EmGg==} @@ -2541,18 +2783,46 @@ packages: resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} engines: {node: '>= 0.4'} + axobject-query@4.1.0: + resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==} + engines: {node: '>= 0.4'} + b4a@1.6.7: resolution: {integrity: sha512-OnAYlL5b7LEkALw87fUVafQw5rVR9RjwGd4KUwNQ6DrrNmaVaUCgLipfVlzrPQ4tWOR9P0IXGNOx50jYCCdSJg==} + bail@2.0.2: + resolution: {integrity: sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==} + balanced-match@1.0.2: resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} bare-events@2.5.0: resolution: {integrity: sha512-/E8dDe9dsbLyh2qrZ64PEPadOQ0F4gbl1sUJOrmph7xOiIxfY8vwab/4bFLh4Y88/Hk/ujKcrQKc+ps0mv873A==} + bare-fs@2.3.5: + resolution: {integrity: sha512-SlE9eTxifPDJrT6YgemQ1WGFleevzwY+XAP1Xqgl56HtcrisC2CHCZ2tq6dBpcH2TnNxwUEUGhweo+lrQtYuiw==} + + bare-os@2.4.4: + resolution: {integrity: sha512-z3UiI2yi1mK0sXeRdc4O1Kk8aOa/e+FNWZcTiPB/dfTWyLypuE99LibgRaQki914Jq//yAWylcAt+mknKdixRQ==} + + bare-path@2.1.3: + resolution: {integrity: sha512-lh/eITfU8hrj9Ru5quUp0Io1kJWIk1bTjzo7JH1P5dWmQ2EL4hFUlfI8FonAhSlgIfhn63p84CDY/x+PisgcXA==} + + bare-stream@2.3.1: + resolution: {integrity: sha512-Vm8kAeOcfzHPTH8sq0tHBnUqYrkXdroaBVVylqFT4cF5wnMfKEIxxy2jIGu2zKVNl9P8MAP9XBWwXJ9N2+jfEw==} + + base-64@1.0.0: + resolution: {integrity: sha512-kwDPIFCGx0NZHog36dj+tHiwP4QMzsZ3AgMViUBKI0+V5n4U0ufTCUMhnQ04diaRI8EX/QcPfql7zlhZ7j4zgg==} + base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + bcp-47-match@2.0.3: + resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==} + + bcp-47@2.1.0: + resolution: {integrity: sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==} + binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} engines: {node: '>=8'} @@ -2560,12 +2830,19 @@ packages: bindings@1.5.0: resolution: {integrity: sha512-p2q/t/mhvuOj/UeLlV6566GD/guowlr0hHxClI0W9m7MWYkL1F0hLo+0Aexs9HSPCtR1SXQ0TD3MMKrXZajbiQ==} - birpc@0.2.17: - resolution: {integrity: sha512-+hkTxhot+dWsLpp3gia5AkVHIsKlZybNT5gIYiDlNzJrmYPcTM9k5/w2uaj3IPpd7LlEYpmCj4Jj1nC41VhDFg==} + birpc@0.2.19: + resolution: {integrity: sha512-5WeXXAvTmitV1RqJFppT5QtUiz2p1mRSYU000Jkft5ZUCLJIk4uQriYNO50HknxKwM6jd8utNc66K1qGIwwWBQ==} + + bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} boolbase@1.0.0: resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + boxen@8.0.1: + resolution: {integrity: sha512-F3PH5k5juxom4xktynS7MoFY+NUWH5LC4CnH11YB8NPew+HLpmBLCybSAEyb2F+4pRXhuhWqFesoQd6DAyc2hw==} + engines: {node: '>=18'} + brace-expansion@1.1.11: resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} @@ -2576,11 +2853,6 @@ packages: resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} engines: {node: '>=8'} - browserslist@4.23.3: - resolution: {integrity: sha512-btwCFJVjI4YWDNfau8RhZ+B1Q/VLoUITrm3RlP6y1tYGWIOa+InuYiRGXUBXo8nA1qKmHMyLB/iVQg5TT4eFoA==} - engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} - hasBin: true - browserslist@4.24.0: resolution: {integrity: sha512-Rmb62sR1Zpjql25eSanFGEhAxcFwfA1K0GuQcLoaJBAcENegrQut3hYdhXFF1obQfiDyqIW/cLM5HSJ/9k884A==} engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} @@ -2593,6 +2865,9 @@ packages: buffer-from@1.1.2: resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==} + buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} @@ -2623,6 +2898,9 @@ packages: resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} engines: {node: '>= 0.4'} + call-me-maybe@1.0.2: + resolution: {integrity: sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==} + callsites@3.1.0: resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} engines: {node: '>=6'} @@ -2631,14 +2909,18 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} + camelcase@8.0.0: + resolution: {integrity: sha512-8WB3Jcas3swSvjIeA2yvCJ+Miyz5l1ZmB6HFb9R1317dt9LCQoswg/BGrmAmkWVEszSrrg4RwmO46qIm2OEnSA==} + engines: {node: '>=16'} + caniuse-api@3.0.0: resolution: {integrity: sha512-bsTwuIg/BZZK/vreVTYYbSWoe2F+71P7K5QGEX+pT250DZbfU1MQ5prOKpPR+LL6uWKK3KMwMCAS74QB3Um1uw==} - caniuse-lite@1.0.30001660: - resolution: {integrity: sha512-GacvNTTuATm26qC74pt+ad1fW15mlQ/zuTzzY1ZoIzECTP8HURDfF43kNxPgf7H1jmelCBQTTbBNxdSXOA7Bqg==} + caniuse-lite@1.0.30001668: + resolution: {integrity: sha512-nWLrdxqCdblixUO+27JtGJJE/txpJlyUy5YN1u53wLZkP0emYCo5zgS6QYft7VUYR42LGgi/S5hdLZTrnyIddw==} - caniuse-lite@1.0.30001666: - resolution: {integrity: sha512-gD14ICmoV5ZZM1OdzPWmpx+q4GyefaK06zi8hmfHV5xe4/2nOQX3+Dw5o+fSqOws2xVwL9j+anOPFwHzdEdV4g==} + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} chalk@2.4.2: resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} @@ -2652,18 +2934,33 @@ packages: resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + character-entities@2.0.2: + resolution: {integrity: sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==} + + character-reference-invalid@2.0.1: + resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chokidar@3.6.0: resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==} engines: {node: '>= 8.10.0'} + chokidar@4.0.1: + resolution: {integrity: sha512-n8enUVCED/KVRQlab1hr3MVpcVMvxtZjmEa956u+4YijlmQED223XMSYj2tLuKvr4jcCTzNNMpQDUer72MMmzA==} + engines: {node: '>= 14.16.0'} + + chownr@1.1.4: + resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==} + chownr@2.0.0: resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==} engines: {node: '>=10'} - chrome-trace-event@1.0.4: - resolution: {integrity: sha512-rNjApaLzuwaOTjCiT8lSDdGN1APCiqkChLMJxJPWLunPAt5fy8xgU9/jNOchV84wfIxrA0lRQB7oCT8jrn/wrQ==} - engines: {node: '>=6.0'} - ci-info@3.9.0: resolution: {integrity: sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==} engines: {node: '>=8'} @@ -2682,6 +2979,18 @@ packages: clear@0.1.0: resolution: {integrity: sha512-qMjRnoL+JDPJHeLePZJuao6+8orzHMGP04A8CdwCNsKhRbOnKRjefxONR7bwILT3MHecxKBjHkKL/tkZ8r4Uzw==} + cli-boxes@3.0.0: + resolution: {integrity: sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==} + engines: {node: '>=10'} + + cli-cursor@5.0.0: + resolution: {integrity: sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==} + engines: {node: '>=18'} + + cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + clipboardy@4.0.0: resolution: {integrity: sha512-5mOlNS0mhX0707P2I0aZ2V/cmHUEO/fL7VFLqszkhUsxt7RwnmrInf/eEQKlf5GzvYeHIjT+Ov1HRfNmymlG0w==} engines: {node: '>=18'} @@ -2690,10 +2999,21 @@ packages: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} + clsx@2.1.1: + resolution: {integrity: sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==} + engines: {node: '>=6'} + cluster-key-slot@1.1.2: resolution: {integrity: sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==} engines: {node: '>=0.10.0'} + code-error-fragment@0.0.230: + resolution: {integrity: sha512-cadkfKp6932H8UkhzE/gcUqhRMNf8jHzkAN7+5Myabswaghu4xABTgPHDCjW+dBAJxj/SpkTYokpzDqY4pCzQw==} + engines: {node: '>= 4'} + + collapse-white-space@2.1.0: + resolution: {integrity: sha512-loKTxY1zCOuG4j9f6EPnuyyYkf58RnhhWTvRoZEokgB+WbdXehfjFviyOVYkqzEWz1Q5kRiZdBYS5SwxbQYwzw==} + color-convert@1.9.3: resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} @@ -2707,13 +3027,23 @@ packages: color-name@1.1.4: resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + color-string@1.9.1: + resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==} + color-support@1.1.3: resolution: {integrity: sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==} hasBin: true + color@4.2.3: + resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==} + engines: {node: '>=12.5.0'} + colord@2.9.3: resolution: {integrity: sha512-jeC1axXpnb0/2nn/Y1LPuLdgXBLH7aDcHu4KEKfqw3CUhX7ZpfBSlPKyqXE6btIgEzfWtrX3/tyBCaCvXvMkOw==} + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + commander@2.20.3: resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} @@ -2733,6 +3063,9 @@ packages: resolution: {integrity: sha512-buhp5kePrmda3vhc5B9t7pUQXAb2Tnd0qgpkIhPhkHXxJpiPJ11H0ZEU0oBpJ2QztSbzG/ZxMj/CHsYJqRHmyg==} engines: {node: '>= 12.0.0'} + common-ancestor-path@1.0.1: + resolution: {integrity: sha512-L3sHRo1pXXEqX8VU28kfgUY+YGsk09hPqZiZmLacNib6XNTCM8ubYeT7ryXQw8asB1sKgcU5lkB7ONug08aB8w==} + commondir@1.0.1: resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} @@ -2749,8 +3082,8 @@ packages: concat-map@0.0.1: resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} - confbox@0.1.7: - resolution: {integrity: sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==} + confbox@0.1.8: + resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} consola@3.2.3: resolution: {integrity: sha512-I5qxpzLv+sJhTVEoLYNcTW+bThDCPsit0vLNKShZx6rLtpilNpmmeTPaeqJb9ZE9dV3DGaeby6Vuhrw38WjeyQ==} @@ -2765,6 +3098,10 @@ packages: cookie-es@1.2.2: resolution: {integrity: sha512-+W7VmiVINB+ywl1HGXJXmrqkOhpKrIiVZV6tQuV54ZyQC7MMuBt81Vc336GMLoHBq5hV/F9eXgt5Mnx0Rha5Fg==} + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + copy-anything@3.0.5: resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==} engines: {node: '>=12.13'} @@ -2822,6 +3159,9 @@ packages: css-select@5.1.0: resolution: {integrity: sha512-nwoRF1rvRRnnCqqY7updORDsuqKzqYJ28+oSMaJMMgOauh3fvwHqMS7EZpIPqK8GL+g9mKxF1vP/ZjSeNjEVHg==} + css-selector-parser@3.0.5: + resolution: {integrity: sha512-3itoDFbKUNx1eKmVpYMFyqKX04Ww9osZ+dLgrk6GEv6KMVeXUhUnp4I5X+evw+u3ZxVU6RFXSSRxlTeMh8bA+g==} + css-tree@2.2.1: resolution: {integrity: sha512-OA0mILzGc1kCOCSJerOeqDxDQ4HOh+G8NbOJFOTgOCzpw7fCBubk0fEyxp8AgOL/jvLgYA/uV0cMbe43ElF1JA==} engines: {node: ^10 || ^12.20.0 || ^14.13.0 || >=15.0.0, npm: '>=7.0.0'} @@ -2919,15 +3259,6 @@ packages: supports-color: optional: true - debug@4.3.5: - resolution: {integrity: sha512-pt0bNEmneDIvdL1Xsd9oDQ/wrQRkXDT4AUWlNZNPKvW5x/jyO9VFXkJUP07vQ2upmw5PlaITaPKc31jK13V+jg==} - engines: {node: '>=6.0'} - peerDependencies: - supports-color: '*' - peerDependenciesMeta: - supports-color: - optional: true - debug@4.3.7: resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} engines: {node: '>=6.0'} @@ -2937,6 +3268,17 @@ packages: supports-color: optional: true + decode-named-character-reference@1.0.2: + resolution: {integrity: sha512-O8x12RzrUF8xyVcY0KJowWsmaJxQbmy0/EtnNtHRpsOcT7dFk5W598coHqBVpmWo1oQQfsCqfCmkZN5DJrZVdg==} + + decompress-response@6.0.0: + resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==} + engines: {node: '>=10'} + + deep-extend@0.6.0: + resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==} + engines: {node: '>=4.0.0'} + deep-is@0.1.4: resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} @@ -2982,6 +3324,10 @@ packages: resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} engines: {node: '>= 0.8'} + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + destr@2.0.3: resolution: {integrity: sha512-2N3BOUU4gYMpTP24s5rF5iP7BDr7uNTCs4ozw3kf/eKfvWSIu93GEBi5m427YoyJoeOzQ5smuu4nNAPGb8idSQ==} @@ -2998,12 +3344,23 @@ packages: resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} engines: {node: '>=8'} + deterministic-object-hash@2.0.2: + resolution: {integrity: sha512-KxektNH63SrbfUyDiwXqRb1rLwKt33AmMv+5Nhsw1kqZ13SJBRTgZHtGbE+hH3a1mVW1cz+4pqSWVPAtLVXTzQ==} + engines: {node: '>=18'} + devalue@5.1.1: resolution: {integrity: sha512-maua5KUiapvEwiEAe+XnlZ3Rh0GD+qI1J/nb9vrJc3muPXvcF/8gXYTWF76+5DAqHyDUtOIImEuo0YKE9mshVw==} + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + diff@5.2.0: + resolution: {integrity: sha512-uIFDxqpRZGZ6ThOk84hEfqWoHx2devRFvpTZcTHur85vImfaxUbTW9Ryh4CpCuDnToOP1CEtXKIgytHBPVff5A==} + engines: {node: '>=0.3.1'} + diff@7.0.0: resolution: {integrity: sha512-PJWHUb1RFevKCwaFA9RlG5tCd+FO5iRh9A8HEtkmBH2Li03iJriB6m6JIN4rGz3K3JLawI7/veA1xzRKP6ISBw==} engines: {node: '>=0.3.1'} @@ -3012,6 +3369,10 @@ packages: resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} engines: {node: '>=8'} + direction@2.0.1: + resolution: {integrity: sha512-9S6m9Sukh1cZNknO1CWAr2QAWsbKLafQiyM5gZ7VgXHeuaoUwffKN4q6NC4A/Mf9iiPlOXQEKW/Mv/mh9/3YFA==} + hasBin: true + dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} @@ -3048,6 +3409,10 @@ packages: resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} engines: {node: '>=12'} + dset@3.1.4: + resolution: {integrity: sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA==} + engines: {node: '>=4'} + duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} @@ -3057,11 +3422,14 @@ packages: ee-first@1.1.1: resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} - electron-to-chromium@1.5.19: - resolution: {integrity: sha512-kpLJJi3zxTR1U828P+LIUDZ5ohixyo68/IcYOHLqnbTPr/wdgn4i1ECvmALN9E16JPA6cvCG5UG79gVwVdEK5w==} + electron-to-chromium@1.5.38: + resolution: {integrity: sha512-VbeVexmZ1IFh+5EfrYz1I0HTzHVIlJa112UEWhciPyeOcKJGeTv6N8WnG4wsQB81DGCaVEGhpSb6o6a8WYFXXg==} - electron-to-chromium@1.5.32: - resolution: {integrity: sha512-M+7ph0VGBQqqpTT2YrabjNKSQ2fEl9PVx6AK3N558gDH9NO8O6XN9SXXFWRo9u9PbEg/bWq+tjXQr+eXmxubCw==} + emmet@2.4.11: + resolution: {integrity: sha512-23QPJB3moh/U9sT4rQzGgeyyGIrcM+GH5uVYg2C6wZIxAIJq7Ng3QLT79tl8FUwDXhyq9SusfknOrofAKqvgyQ==} + + emoji-regex@10.4.0: + resolution: {integrity: sha512-EC+0oUMY1Rqm4O6LLrgjtYDvcVYTy7chDnM4Q7030tP4Kwj3u/pR6gP9ygnp2CJMK5Gq+9Q2oqmrFJAz01DXjw==} emoji-regex@8.0.0: resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} @@ -3077,9 +3445,8 @@ packages: resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} engines: {node: '>= 0.8'} - enhanced-resolve@5.17.0: - resolution: {integrity: sha512-dwDPwZL0dmye8Txp2gzFmA6sxALaSvdRDjPH0viLcKrtlOL3tw62nWWweVD1SdILDTJrbrL6tdWVN58Wo6U3eA==} - engines: {node: '>=10.13.0'} + end-of-stream@1.4.4: + resolution: {integrity: sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==} enhanced-resolve@5.17.1: resolution: {integrity: sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==} @@ -3132,6 +3499,12 @@ packages: resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} engines: {node: '>= 0.4'} + esast-util-from-estree@2.0.0: + resolution: {integrity: sha512-4CyanoAudUSBAn5K13H4JhsMH6L9ZP7XbLVe/dKybkxMO7eDyLsT8UHl9TRNrU2Gr9nz+FovfSIjuXWJ81uVwQ==} + + esast-util-from-js@2.0.1: + resolution: {integrity: sha512-8Ja+rNJ0Lt56Pcf3TAmpBZjmx8ZcK5Ts4cAzIOjsjevg9oSXJnl6SUQ2EevU8tv3h6ZLWmoKL5H4fgWvdvfETw==} + esbuild@0.20.2: resolution: {integrity: sha512-WdOOppmUNU+IbZ0PaDiTst80zjnrOkyJNHoKupIcVyU8Lvla3Ugx94VzkQ32Ijqd7UhHJy75gNWDMUekcrSJ6g==} engines: {node: '>=12'} @@ -3186,8 +3559,8 @@ packages: eslint-plugin-n: '^15.0.0 || ^16.0.0 ' eslint-plugin-promise: ^6.0.0 - eslint-config-turbo@2.1.1: - resolution: {integrity: sha512-JJF8SZErmgKCGkt124WUmTt0sQ5YLvPo2YxDsfzn9avGJC7/BQIa+3FZoDb3zeYYsZx91pZ6htQAJaKK8NQQAg==} + eslint-config-turbo@2.1.3: + resolution: {integrity: sha512-smdkhd01V/e/I4EjJxaZA1kxZ1vdFCHpyryolxLtRBP0bZTrHDYh1H6NAyZ3Fy1jkhsQzXw+L+6m17ygROvNFw==} peerDependencies: eslint: '>6.6.0' @@ -3197,15 +3570,21 @@ packages: eslint-import-resolver-node@0.3.9: resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} - eslint-import-resolver-typescript@3.6.1: - resolution: {integrity: sha512-xgdptdoi5W3niYeuQxKmzVDTATvLYqhpwmykwsh7f6HIOStGWEIL9iqZgQDF9u9OEzrRwR8no5q2VT+bjAujTg==} + eslint-import-resolver-typescript@3.6.3: + resolution: {integrity: sha512-ud9aw4szY9cCT1EWWdGv1L1XR6hh2PaRWif0j2QjQ0pgTY/69iw+W0Z4qZv5wHahOl8isEr+k/JnyAqNQkLkIA==} engines: {node: ^14.18.0 || >=16.0.0} peerDependencies: eslint: '*' eslint-plugin-import: '*' + eslint-plugin-import-x: '*' + peerDependenciesMeta: + eslint-plugin-import: + optional: true + eslint-plugin-import-x: + optional: true - eslint-module-utils@2.8.1: - resolution: {integrity: sha512-rXDXR3h7cs7dy9RNpUlQf80nX31XWJEyGq1tRMo+6GsO5VmTe4UTwtmonAD4ZkAsrfMVDA2wlGJ3790Ys+D49Q==} + eslint-module-utils@2.12.0: + resolution: {integrity: sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==} engines: {node: '>=4'} peerDependencies: '@typescript-eslint/parser': '*' @@ -3237,24 +3616,24 @@ packages: peerDependencies: eslint: '>=4.19.1' - eslint-plugin-import-x@4.2.1: - resolution: {integrity: sha512-WWi2GedccIJa0zXxx3WDnTgouGQTtdYK1nhXMwywbqqAgB0Ov+p1pYBsWh3VaB0bvBOwLse6OfVII7jZD9xo5Q==} + eslint-plugin-import-x@4.3.1: + resolution: {integrity: sha512-5TriWkXulDl486XnYYRgsL+VQoS/7mhN/2ci02iLCuL7gdhbiWxnsuL/NTcaKY9fpMgsMFjWZBtIGW7pb+RX0g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} peerDependencies: eslint: ^8.57.0 || ^9.0.0 - eslint-plugin-import@2.29.1: - resolution: {integrity: sha512-BbPC0cuExzhiMo4Ff1BTVwHpjjv28C5R+btTOGaCRC7UEz801up0JadwkeSk5Ued6TG34uaczuVuH6qyy5YUxw==} + eslint-plugin-import@2.31.0: + resolution: {integrity: sha512-ixmkI62Rbc2/w8Vfxyh1jQRTdRTF52VxwRVHl/ykPAmqG+Nb7/kNn+byLP0LxPgI7zWA16Jt82SybJInmMia3A==} engines: {node: '>=4'} peerDependencies: '@typescript-eslint/parser': '*' - eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9 peerDependenciesMeta: '@typescript-eslint/parser': optional: true - eslint-plugin-jsdoc@50.2.2: - resolution: {integrity: sha512-i0ZMWA199DG7sjxlzXn5AeYZxpRfMJjDPUl7lL9eJJX8TPRoIaxJU4ys/joP5faM5AXE1eqW/dslCj3uj4Nqpg==} + eslint-plugin-jsdoc@50.4.1: + resolution: {integrity: sha512-OXIq+JJQPCLAKL473/esioFOwbXyRE5MAQ4HbZjcp3e+K3zdxt2uDpGs3FR+WezUXNStzEtTfgx15T+JFrVwBA==} engines: {node: '>=18'} peerDependencies: eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 @@ -3285,8 +3664,8 @@ packages: eslint-config-prettier: optional: true - eslint-plugin-promise@6.4.0: - resolution: {integrity: sha512-/KWWRaD3fGkVCZsdR0RU53PSthFmoHVhZl+y9+6DqeDLSikLdlUVpVEAmI6iCRR5QyOjBYBqHZV/bdv4DJ4Gtw==} + eslint-plugin-promise@6.6.0: + resolution: {integrity: sha512-57Zzfw8G6+Gq7axm2Pdo3gW/Rx3h9Yywgn61uE/3elTCOePEHVrn2i5CdfBwA1BLK0Q0WqctICIUSqXZW/VprQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^7.0.0 || ^8.0.0 || ^9.0.0 @@ -3297,8 +3676,8 @@ packages: peerDependencies: eslint: '>=8.44.0' - eslint-plugin-turbo@2.1.1: - resolution: {integrity: sha512-E/34kdQd0n3RP18+e0DSV0f3YTSCOojUh1p4X0Xrho2PBYmJ3umSnNo9FhkZt6UDACl+nBQcYTFkRHMz76lJdw==} + eslint-plugin-turbo@2.1.3: + resolution: {integrity: sha512-I9vPArzyOSYa6bm0iMCgD07MgdExc1VK2wGuVz21g4BUdj83w7mDKyCXR2rwOtCEW+wemFwgxanJ81imQZijNg==} peerDependencies: eslint: '>6.6.0' @@ -3320,28 +3699,18 @@ packages: peerDependencies: eslint: '>=8.56.0' - eslint-plugin-vue@9.27.0: - resolution: {integrity: sha512-5Dw3yxEyuBSXTzT5/Ge1X5kIkRTQ3nvBn/VwPwInNiZBSJOO/timWMUaflONnFBzU6NhB68lxnCda7ULV5N7LA==} + eslint-plugin-vue@9.29.0: + resolution: {integrity: sha512-hamyjrBhNH6Li6R1h1VF9KHfshJlKgKEg3ARbGTn72CMNDSMhWbgC7NdkRDEh25AFW+4SDATzyNM+3gWuZii8g==} engines: {node: ^14.17.0 || >=16.0.0} peerDependencies: eslint: ^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 - eslint-plugin-vue@9.28.0: - resolution: {integrity: sha512-ShrihdjIhOTxs+MfWun6oJWuk+g/LAhN+CiuOl/jjkG3l0F2AuK5NMTaWqyvBgkFtpYmyks6P4603mLmhNJW8g==} - engines: {node: ^14.17.0 || >=16.0.0} - peerDependencies: - eslint: ^6.2.0 || ^7.0.0 || ^8.0.0 || ^9.0.0 - - eslint-scope@5.1.1: - resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} - engines: {node: '>=8.0.0'} - eslint-scope@7.2.2: resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - eslint-scope@8.0.2: - resolution: {integrity: sha512-6E4xmrTw5wtxnLA5wYL3WDfhZ/1bUBGOXV0zQvVRDOtrR8D0p6W7fs3JweNYhwRYeGvd/1CKX2se0/2s7Q/nJA==} + eslint-scope@8.1.0: + resolution: {integrity: sha512-14dSvlhaVhKKsa9Fx1l8A17s7ah7Ef7wCakJ10LYk6+GYmP9yDti2oq2SEwcyndt6knfcZyhyxwY3i9yL78EQw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} eslint-utils@2.1.0: @@ -3366,17 +3735,18 @@ packages: resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} - eslint-visitor-keys@4.0.0: - resolution: {integrity: sha512-OtIRv/2GyiF6o/d8K7MYKKbXrOUBIK6SfkIRM4Z0dY3w+LiQ0vy3F57m0Z71bjbyeiWFiHJ8brqnmE6H6/jEuw==} + eslint-visitor-keys@4.1.0: + resolution: {integrity: sha512-Q7lok0mqMUSf5a/AdAZkA5a/gHcO6snwQClVNNvFKCAVlxXucdU8pKydU5ZVZjBx5xr37vGbFFWtLQYreLzrZg==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} - eslint@8.57.0: - resolution: {integrity: sha512-dZ6+mexnaTIbSBZWgou51U6OmzIhYM2VcNdtiTtI7qPNZm35Akpr0f6vtw3w1Kmn5PYo+tZVfh13WrhpS6oLqQ==} + eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options. hasBin: true - eslint@9.10.0: - resolution: {integrity: sha512-Y4D0IgtBZfOcOUAIQTSXBKoNGfY0REGqHJG6+Q81vNippW5YlKjHFj4soMxamKK1NXHUWuBZTLdU3Km+L/pcHw==} + eslint@9.12.0: + resolution: {integrity: sha512-UVIOlTEWxwIopRL1wgSQYdnVDcEvs2wyaO6DGo5mXqe3r16IoCNWkR29iHhyaP4cICWjbgbmFUGAhh0GJRuGZw==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} hasBin: true peerDependencies: @@ -3385,14 +3755,19 @@ packages: jiti: optional: true - espree@10.1.0: - resolution: {integrity: sha512-M1M6CpiE6ffoigIOWYO9UDP8TMUw9kqb21tf+08IgDYjCsOvCuDt4jQcZmoYxx+w7zlKw9/N0KXfto+I8/FrXA==} + espree@10.2.0: + resolution: {integrity: sha512-upbkBJbckcCNBDBDXEbuhjbP68n+scUd3k/U2EkyM9nw+I/jPiL4cLF/Al06CF96wRltFda16sxDFrxsI1v0/g==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} espree@9.6.1: resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + esquery@1.6.0: resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} engines: {node: '>=0.10'} @@ -3401,14 +3776,28 @@ packages: resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} engines: {node: '>=4.0'} - estraverse@4.3.0: - resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} - engines: {node: '>=4.0'} - estraverse@5.3.0: resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} engines: {node: '>=4.0'} + estree-util-attach-comments@3.0.0: + resolution: {integrity: sha512-cKUwm/HUcTDsYh/9FgnuFqpfquUbwIqwKM26BVCGDPVgvaCl/nDCCjUfiLlx6lsEZ3Z4RFxNbOQ60pkaEwFxGw==} + + estree-util-build-jsx@3.0.1: + resolution: {integrity: sha512-8U5eiL6BTrPxp/CHbs2yMgP8ftMhR5ww1eIKoWRMlqvltHF8fZn5LRDvTKuxD3DUn+shRbLGqXemcP51oFCsGQ==} + + estree-util-is-identifier-name@3.0.0: + resolution: {integrity: sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==} + + estree-util-scope@1.0.0: + resolution: {integrity: sha512-2CAASclonf+JFWBNJPndcOpA8EMJwa0Q8LUFJEKqXLW6+qBvbFZuF5gItbQOs/umBUkjviCSDCbBwU2cXbmrhQ==} + + estree-util-to-js@2.0.0: + resolution: {integrity: sha512-WDF+xj5rRWmD5tj6bIqRi6CkLIXbbNQUcxQHzGysQzvHmdYG2G7p/Tf0J0gpxGgkeMZNTIjT/AoSvC9Xehcgdg==} + + estree-util-visit@2.0.0: + resolution: {integrity: sha512-m5KgiH85xAhhW8Wta0vShLcUvOsh3LLPI2YVwcbio1l7E09NTLL1EyMZFM1OyWowoH0skScNbhOPl4kcBgzTww==} + estree-walker@2.0.2: resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} @@ -3427,6 +3816,9 @@ packages: resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} engines: {node: '>=6'} + eventemitter3@5.0.1: + resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==} + events@3.3.0: resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==} engines: {node: '>=0.8.x'} @@ -3439,6 +3831,20 @@ packages: resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} engines: {node: '>=16.17'} + expand-template@2.0.3: + resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==} + engines: {node: '>=6'} + + expressive-code@0.35.6: + resolution: {integrity: sha512-+mx+TPTbMqgo0mL92Xh9QgjW0kSQIsEivMgEcOnaqKqL7qCw8Vkqc5Rg/di7ZYw4aMUSr74VTc+w8GQWu05j1g==} + + extend-shallow@2.0.1: + resolution: {integrity: sha512-zCnTtlxNoAiDc3gqY2aYAWFx7XWWiasuF2K8Me5WbN8otHKTUKBwjPtNpRs/rbUZm7KxWAaNj7P1a/p52GbVug==} + engines: {node: '>=0.10.0'} + + extend@3.0.2: + resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} + externality@1.0.2: resolution: {integrity: sha512-LyExtJWKxtgVzmgtEHyQtLFpw1KFhQphF9nTG8TpAIVkiI/xQ3FJh75tRFLYl4hkn7BNIIdLJInuDAavX35pMw==} @@ -3464,6 +3870,9 @@ packages: fast-npm-meta@0.2.2: resolution: {integrity: sha512-E+fdxeaOQGo/CMWc9f4uHFfgUPJRAu7N3uB8GBvB3SDPAIWJK4GKyYhkAGFq+GYrcbKNfQIz5VVQyJnDuPPCrg==} + fast-uri@3.0.3: + resolution: {integrity: sha512-aLrHthzCjH5He4Z2H9YZ+v6Ujb9ocRuW6ZzkJQOrTxleEijANq4v1TsaPaVG1PZcuurEzrLcWRyYBYXD5cEiaw==} + fastq@1.17.1: resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} @@ -3505,6 +3914,9 @@ packages: resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} engines: {node: '>=10'} + find-yarn-workspace-root2@1.2.16: + resolution: {integrity: sha512-hr6hb1w8ePMpPVUK39S4RlwJzi+xPLuVuG8XlwXU3KD5Yn3qgBWVfy3AzNlDhWvE1EORCE65/Qm26rFQt3VLVA==} + flat-cache@3.2.0: resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} engines: {node: ^10.12.0 || >=12.0.0} @@ -3516,6 +3928,10 @@ packages: flatted@3.3.1: resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + flattie@1.1.1: + resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==} + engines: {node: '>=8'} + floating-vue@2.0.0-beta.20: resolution: {integrity: sha512-N68otcpp6WwcYC7zP8GeJqNZVdfvS7tEY88lwmuAHeqRgnfWx1Un8enzLxROyVnBDZ3TwUoUdj5IFg+bUT7JeA==} peerDependencies: @@ -3553,6 +3969,9 @@ packages: resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} engines: {node: '>= 0.6'} + fs-constants@1.0.0: + resolution: {integrity: sha512-y6OAwoSIf7FyjMIv94u+b5rdheZEjzR63GTyZJm5qh4Bi+2YgwLCcI/fPFZkL5PSixOt6ZNKm+w+Hfp/Bciwow==} + fs-extra@10.1.0: resolution: {integrity: sha512-oRXApq54ETRj4eMiFzGnHWGy+zo5raudjuxN0b8H7s/RU2oW0Wvsx9O0ACRN/kRq9E8Vu/ReskGB5o3ji+FzHQ==} engines: {node: '>=12'} @@ -3600,6 +4019,10 @@ packages: resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} engines: {node: 6.* || 8.* || >= 10.*} + get-east-asian-width@1.3.0: + resolution: {integrity: sha512-vpeMIQKxczTD/0s2CdEWHcb0eeJe6TFjxb+J5xgX7hScxqrGuyjmv4c1D4A/gelKfyox0gJJwIHF+fLjeaM8kQ==} + engines: {node: '>=18'} + get-intrinsic@1.2.4: resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} engines: {node: '>= 0.4'} @@ -3619,11 +4042,8 @@ packages: resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==} engines: {node: '>= 0.4'} - get-tsconfig@4.7.5: - resolution: {integrity: sha512-ZCuZCnlqNzjb4QprAzXKdpp/gh6KTxSJuw3IBsPnV/7fV4NxC9ckB+vPTt8w7fJA0TaSD7c55BR47JD6MEDyDw==} - - get-tsconfig@4.8.0: - resolution: {integrity: sha512-Pgba6TExTZ0FJAn1qkJAjIeKoDJ3CsI2ChuLohJnZl/tTU8MVrq3b+2t5UOPfRa4RMsorClBjJALkJUMjG1PAw==} + get-tsconfig@4.8.1: + resolution: {integrity: sha512-k9PN+cFBmaLWtVz29SkUoqU5O0slLuHJXt/2P+tMVFT+phsSGXGkp9t3rQIqdz0e+06EHNGs3oM6ZX1s2zHxRg==} giget@1.2.3: resolution: {integrity: sha512-8EHPljDvs7qKykr6uw8b+lqLiUc/vUg+KVTI0uND4s63TdsZM2Xus3mflvF0DDG9SiM4RlCkFGL+7aAjRmV7KA==} @@ -3639,6 +4059,12 @@ packages: git-url-parse@15.0.0: resolution: {integrity: sha512-5reeBufLi+i4QD3ZFftcJs9jC26aULFLBU23FeKM/b1rI0K6ofIeAblmDVO7Ht22zTDE9+CkJ3ZVb0CgJmz3UQ==} + github-from-package@0.0.0: + resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==} + + github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + glob-parent@5.1.2: resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} engines: {node: '>= 6'} @@ -3647,9 +4073,6 @@ packages: resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} engines: {node: '>=10.13.0'} - glob-to-regexp@0.4.1: - resolution: {integrity: sha512-lkX1HJXwyMcprw/5YUZc2s7DrpAiHB21/V+E1rHUrVNokkvB6bqMzT0VfV6/86ZNabt1k14YOIaT7nDvOX3Iiw==} - glob@10.4.5: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true @@ -3679,8 +4102,8 @@ packages: resolution: {integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==} engines: {node: '>=18'} - globals@15.9.0: - resolution: {integrity: sha512-SmSKyLLKFbSr6rptvP8izbyxJL4ILwqO9Jg23UA0sDlGlu58V59D1//I3vlc0KJphVdUR7vMjHIplYnzBxorQA==} + globals@15.11.0: + resolution: {integrity: sha512-yeyNSjdbyVaWurlwCpcA6XNBrHTMIeDdj0/hnvX/OLJ9ekOXYbLsLinH/MucQyGvNnXhidTdNhTtJaffL2sMfw==} engines: {node: '>=18'} globalthis@1.0.4: @@ -3701,9 +4124,16 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + grapheme-splitter@1.0.4: + resolution: {integrity: sha512-bzh50DW9kTPM00T8y4o8vQg89Di9oLJVLW/KaOGIXJWP/iqCN6WKYkbNOF04vFLJhwcpYUh9ydh/+5vpOqV4YQ==} + graphemer@1.4.0: resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + gray-matter@4.0.3: + resolution: {integrity: sha512-5v6yZd4JK3eMI3FqqCouswVqwugaA9r4dNZB1wwcmrD02QkV5H0y7XBQW8QwQqEaZY1pM9aqORSORhJRdNK44Q==} + engines: {node: '>=6.0'} + gzip-size@7.0.0: resolution: {integrity: sha512-O1Ld7Dr+nqPnmGpdhzLmMTQ4vAsD+rHwMm1NLUmoUFFymBOMKxCCrtDxqdBRYXdeEPEi3SyoR4TizJLQrnKBNA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -3747,6 +4177,69 @@ packages: resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} engines: {node: '>= 0.4'} + hast-util-embedded@3.0.0: + resolution: {integrity: sha512-naH8sld4Pe2ep03qqULEtvYr7EjrLK2QHY8KJR6RJkTUjPGObe1vnx585uzem2hGra+s1q08DZZpfgDVYRbaXA==} + + hast-util-format@1.1.0: + resolution: {integrity: sha512-yY1UDz6bC9rDvCWHpx12aIBGRG7krurX0p0Fm6pT547LwDIZZiNr8a+IHDogorAdreULSEzP82Nlv5SZkHZcjA==} + + hast-util-from-html@2.0.3: + resolution: {integrity: sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==} + + hast-util-from-parse5@8.0.1: + resolution: {integrity: sha512-Er/Iixbc7IEa7r/XLtuG52zoqn/b3Xng/w6aZQ0xGVxzhw5xUFxcRqdPzP6yFi/4HBYRaifaI5fQ1RH8n0ZeOQ==} + + hast-util-has-property@3.0.0: + resolution: {integrity: sha512-MNilsvEKLFpV604hwfhVStK0usFY/QmM5zX16bo7EjnAEGofr5YyI37kzopBlZJkHD4t887i+q/C8/tr5Q94cA==} + + hast-util-is-body-ok-link@3.0.1: + resolution: {integrity: sha512-0qpnzOBLztXHbHQenVB8uNuxTnm/QBFUOmdOSsEn7GnBtyY07+ENTWVFBAnXd/zEgd9/SUG3lRY7hSIBWRgGpQ==} + + hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + + hast-util-minify-whitespace@1.0.1: + resolution: {integrity: sha512-L96fPOVpnclQE0xzdWb/D12VT5FabA7SnZOUMtL1DbXmYiHJMXZvFkIZfiMmTCNJHUeO2K9UYNXoVyfz+QHuOw==} + + hast-util-parse-selector@4.0.0: + resolution: {integrity: sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==} + + hast-util-phrasing@3.0.1: + resolution: {integrity: sha512-6h60VfI3uBQUxHqTyMymMZnEbNl1XmEGtOxxKYL7stY2o601COo62AWAYBQR9lZbYXYSBoxag8UpPRXK+9fqSQ==} + + hast-util-raw@9.0.4: + resolution: {integrity: sha512-LHE65TD2YiNsHD3YuXcKPHXPLuYh/gjp12mOfU8jxSrm1f/yJpsb0F/KKljS6U9LJoP0Ux+tCe8iJ2AsPzTdgA==} + + hast-util-select@6.0.3: + resolution: {integrity: sha512-OVRQlQ1XuuLP8aFVLYmC2atrfWHS5UD3shonxpnyrjcCkwtvmt/+N6kYJdcY4mkMJhxp4kj2EFIxQ9kvkkt/eQ==} + + hast-util-to-estree@3.1.0: + resolution: {integrity: sha512-lfX5g6hqVh9kjS/B9E2gSkvHH4SZNiQFiqWS0x9fENzEl+8W12RqdRxX6d/Cwxi30tPQs3bIO+aolQJNp1bIyw==} + + hast-util-to-html@9.0.3: + resolution: {integrity: sha512-M17uBDzMJ9RPCqLMO92gNNUDuBSq10a25SDBI08iCCxmorf4Yy6sYHK57n9WAbRAAaU+DuR4W6GN9K4DFZesYg==} + + hast-util-to-jsx-runtime@2.3.2: + resolution: {integrity: sha512-1ngXYb+V9UT5h+PxNRa1O1FYguZK/XL+gkeqvp7EdHlB9oHUG0eYRo/vY5inBdcqo3RkPMC58/H94HvkbfGdyg==} + + hast-util-to-parse5@8.0.0: + resolution: {integrity: sha512-3KKrV5ZVI8if87DVSi1vDeByYrkGzg4mEfeu4alwgmmIeARiBLKCZS2uw5Gb6nU9x9Yufyj3iudm6i7nl52PFw==} + + hast-util-to-string@3.0.1: + resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} + + hast-util-to-text@4.0.2: + resolution: {integrity: sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hastscript@8.0.0: + resolution: {integrity: sha512-dMOtzCEd3ABUeSIISmrETiKuyydk1w0pa+gE/uormcTpSYuaNJPbX1NU3JLyscSLjwAQM8bWMhhIlnCqnRvDTw==} + + hastscript@9.0.0: + resolution: {integrity: sha512-jzaLBGavEDKHrc5EfFImKN7nZKKBdSLIdGvCwDZ9TfzbF2ffXiov8CKE445L2Z1Ek2t/m4SKQ2j6Ipv7NyUolw==} + he@1.2.0: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true @@ -3761,10 +4254,22 @@ packages: hosted-git-info@2.8.9: resolution: {integrity: sha512-mxIDAb9Lsm6DoOJ7xH+5+X4y1LU/4Hi50L9C5sIswK3JzULS4bwk1FvjdBgvYR4bzT4tuUQiC15FE2f5HbLvYw==} + html-escaper@3.0.3: + resolution: {integrity: sha512-RuMffC89BOWQoY0WKGpIhn5gX3iI54O6nRA0yC124NYVtzjmFWBIiFd8M0x+ZdX0P9R4lADg1mgP8C7PxGOWuQ==} + html-tags@3.3.1: resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} engines: {node: '>=8'} + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + html-whitespace-sensitive-tag-names@3.0.1: + resolution: {integrity: sha512-q+310vW8zmymYHALr1da4HyXUQ0zgiIwIicEfotYPWGN0OJVEN/58IJ3A4GBYcEq3LGAZqKb+ugvP0GNB9CEAA==} + + http-cache-semantics@4.1.1: + resolution: {integrity: sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==} + http-errors@2.0.0: resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} engines: {node: '>= 0.8'} @@ -3791,10 +4296,6 @@ packages: ieee754@1.2.1: resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} - ignore@5.3.1: - resolution: {integrity: sha512-5Fytz/IraMjqpwfd34ke28PTVMjZjJG2MPn5t7OE4eUCUNf8BAa7b5WUS9/Qvr6mwOQS7Mk6vdsMno5he+T8Xw==} - engines: {node: '>= 4'} - ignore@5.3.2: resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} engines: {node: '>= 4'} @@ -3805,8 +4306,8 @@ packages: immediate@3.0.6: resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} - immutable@4.3.6: - resolution: {integrity: sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==} + immutable@4.3.7: + resolution: {integrity: sha512-1hqclzwYwjRDFLjcFxOM5AYkkG0rpFPpr1RLPMEuGczoS7YA8gLhy8SWXYRAA/XwfEHpfo3cw5JGioS32fnMRw==} import-fresh@3.3.0: resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} @@ -3840,12 +4341,18 @@ packages: resolution: {integrity: sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==} engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0} + inline-style-parser@0.1.1: + resolution: {integrity: sha512-7NXolsK4CAS5+xvdj5OMMbI962hU/wvwoxk+LWR9Ek9bVtyuuYScDN6eS0rUm6TxApFpw7CX1o4uJzcd4AyD3Q==} + + inline-style-parser@0.2.4: + resolution: {integrity: sha512-0aO8FkhNZlj/ZIbNi7Lxxr12obT7cL1moPfE4tg1LkX7LlLfC6DeX4l2ZEud1ukP9jNQyNnfzQVqwbwmAATY4Q==} + internal-slot@1.0.7: resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} engines: {node: '>= 0.4'} - intl-messageformat@10.5.14: - resolution: {integrity: sha512-IjC6sI0X7YRjjyVH9aUgdftcmZK7WXdHeil4KwbjDnRWjnVitKpAx3rr6t6di1joFp5188VqKcobOPA6mCLG/w==} + intl-messageformat@10.7.0: + resolution: {integrity: sha512-2P06M9jFTqJnEQzE072VGPjbAx6ZG1YysgopAwc8ui0ajSjtwX1MeQ6bXFXIzKcNENJTizKkcJIcZ0zlpl1zSg==} ioredis@5.4.1: resolution: {integrity: sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==} @@ -3854,6 +4361,12 @@ packages: iron-webcrypto@1.2.1: resolution: {integrity: sha512-feOM6FaSr6rEABp/eDfVseKyTMDt+KGpeB35SkVn9Tyn0CqvVsY3EwI0v5i8nMHyJnzCIQf7nsy3p41TPkJZhg==} + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-array-buffer@3.0.4: resolution: {integrity: sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==} engines: {node: '>= 0.4'} @@ -3861,6 +4374,9 @@ packages: is-arrayish@0.2.1: resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + is-arrayish@0.3.2: + resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==} + is-bigint@1.0.4: resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==} @@ -3876,16 +4392,15 @@ packages: resolution: {integrity: sha512-BSLE3HnV2syZ0FK0iMA/yUGplUeMmNz4AW5fnTunbCIqZi4vG3WjJT9FHMy5D69xmAYBHXQhJdALdpwVxV501A==} engines: {node: '>=6'} + is-bun-module@1.2.1: + resolution: {integrity: sha512-AmidtEM6D6NmUiLOvvU7+IePxjEjOzra2h0pSrsfSAcXwl/83zLLXDByafUJy9k/rKK0pvXMLdwKwGHlX2Ke6Q==} + is-callable@1.2.7: resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} engines: {node: '>= 0.4'} - is-core-module@2.14.0: - resolution: {integrity: sha512-a5dFJih5ZLYlRtDc0dZWP7RiKr6xIKzmn/oAYCDvdLThadVgyJwlaoQPmRtMSpz+rk0OGAgIu+TcM9HUF0fk1A==} - engines: {node: '>= 0.4'} - - is-core-module@2.15.0: - resolution: {integrity: sha512-Dd+Lb2/zvk9SKy1TGCt1wFJFo/MWBPMX5x7KcvLajWTGuomczdQX61PvY5yK6SVACwpoexWo81IfFyoKY2QnTA==} + is-core-module@2.15.1: + resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==} engines: {node: '>= 0.4'} is-data-view@1.0.1: @@ -3896,6 +4411,9 @@ packages: resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} engines: {node: '>= 0.4'} + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-docker@2.2.1: resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} engines: {node: '>=8'} @@ -3906,6 +4424,10 @@ packages: engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} hasBin: true + is-extendable@0.1.1: + resolution: {integrity: sha512-5BMULNob1vgFX6EjQw5izWDxrecWK9AM72rugNr0TFldMOi0fj6Jk+zeKIt0xGj4cEfQIJth4w3OKWOJ4f+AFw==} + engines: {node: '>=0.10.0'} + is-extglob@2.1.1: resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} engines: {node: '>=0.10.0'} @@ -3918,6 +4440,9 @@ packages: resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} engines: {node: '>=0.10.0'} + is-hexadecimal@2.0.1: + resolution: {integrity: sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==} + is-inside-container@1.0.0: resolution: {integrity: sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==} engines: {node: '>=14.16'} @@ -3927,6 +4452,10 @@ packages: resolution: {integrity: sha512-K55T22lfpQ63N4KEN57jZUAaAYqYHEe8veb/TycJRk9DdSCLLcovXz/mL6mOnhQaZsQGwPhuFopdQIlqGSEjiQ==} engines: {node: '>=18'} + is-interactive@2.0.0: + resolution: {integrity: sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==} + engines: {node: '>=12'} + is-module@1.0.0: resolution: {integrity: sha512-51ypPSPCoTEIN9dy5Oy+h4pShgJmPCygKfyRCISBI+JoWT/2oJvK8QPxmwv7b/p239jXrm9M1mlQbyKJ5A152g==} @@ -3950,6 +4479,10 @@ packages: resolution: {integrity: sha512-lJJV/5dYS+RcL8uQdBDW9c9uWFLLBNRyFhnAKXw5tVqLlKZ4RMGZKv+YQ/IA3OhD+RpbJa1LLFM1FQPGyIXvOA==} engines: {node: '>=12'} + is-plain-obj@4.1.0: + resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} + engines: {node: '>=12'} + is-reference@1.2.1: resolution: {integrity: sha512-U82MsXXiFIrjCK4otLT+o2NA2Cd2g5MLoOVXUZjIOhLurrRxpEXzI8O0KZHr3IjLvlAH1kTPYSuqer5T9ZVBKQ==} @@ -3984,6 +4517,14 @@ packages: resolution: {integrity: sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==} engines: {node: '>= 0.4'} + is-unicode-supported@1.3.0: + resolution: {integrity: sha512-43r2mRvz+8JRIKnWJ+3j8JtjRKZ6GmjzfaE/qiBJnikNnYv/6bagRJ1kUhNk8R5EX/GkobD+r+sfxCPJsiKBLQ==} + engines: {node: '>=12'} + + is-unicode-supported@2.1.0: + resolution: {integrity: sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==} + engines: {node: '>=18'} + is-weakref@1.0.2: resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} @@ -4018,16 +4559,12 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} - jest-worker@27.5.1: - resolution: {integrity: sha512-7vuh85V5cdDofPyxn58nrPjBktZo0u9x1g8WtjQol+jZDaE+fhN+cIvTj11GndBnMnyfrUOG1sZQxCdjKh+DKg==} - engines: {node: '>= 10.13.0'} - jiti@1.21.6: resolution: {integrity: sha512-2yTgeWTWzMWkHu6Jp9NKgePDaYHbntiwvYuuJLbbN9vl7DC9DvXKOB2BC3ZZ92D3cvV/aflH0osDfwpHepQ53w==} hasBin: true - jiti@2.1.2: - resolution: {integrity: sha512-cYNjJus5X9J4jLzTaI8rYoIq1k6YySiA1lK4wxSnOrBRXkbVyreZfhoboJhsUmwgU82lpPjj1IoU7Ggrau8r3g==} + jiti@2.3.3: + resolution: {integrity: sha512-EX4oNDwcXSivPrw2qKH2LB5PoFxEvgtv2JgwW0bU858HoLQ+kutSvjLMUqBd0PeJYEinLWhoI9Ol0eYMqj/wNQ==} hasBin: true js-tokens@4.0.0: @@ -4036,6 +4573,10 @@ packages: js-tokens@9.0.0: resolution: {integrity: sha512-WriZw1luRMlmV3LGJaR6QOJjWwgLUTf89OwT2lUOyjX2dJGBwgmIkbcz+7WFZjrZM635JOIR517++e/67CP9dQ==} + js-yaml@3.14.1: + resolution: {integrity: sha512-okMH7OXXJ7YrN9Ok3/SXrnu4iX9yOk+25nqX4imS2npuvTYDmo/QEZoqwZkYaIDk3jVvBOTOIEgEhaLOynBS9g==} + hasBin: true + js-yaml@4.1.0: resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} hasBin: true @@ -4048,11 +4589,6 @@ packages: resolution: {integrity: sha512-uZz5UnB7u4T9LvwmFqXii7pZSouaRPorGs5who1Ip7VO0wxanFvBL7GkM6dTHlgX+jhBApRetaWpnDabOeTcnA==} hasBin: true - jsesc@2.5.2: - resolution: {integrity: sha512-OYu7XEzjkCQ3C5Ps3QIZsQfNpqoJyZZA99wd9aWd05NCtC5pWOkShK2mkL6HXQR6/Cy2lbNdPlZBpuQHXE63gA==} - engines: {node: '>=4'} - hasBin: true - jsesc@3.0.2: resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} engines: {node: '>=6'} @@ -4067,6 +4603,9 @@ packages: json-schema-traverse@0.4.1: resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} @@ -4074,6 +4613,10 @@ packages: resolution: {integrity: sha512-SU/971Kt5qVQfJpyDveVhQ/vya+5hvrjClFOcr8c0Fq5aODJjMwutrOfCU+eCnVD5gpx1Q3fEqkyom77zH1iIg==} engines: {node: '>= 0.4'} + json-to-ast@2.1.0: + resolution: {integrity: sha512-W9Lq347r8tA1DfMvAGn9QNcgYm4Wm7Yc+k8e6vezpMnRT+NHbtlxgNBXRVjXe9YM6eTn6+p/MKOlV/aABJcSnQ==} + engines: {node: '>= 4'} + json5@1.0.2: resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} hasBin: true @@ -4083,22 +4626,40 @@ packages: engines: {node: '>=6'} hasBin: true + jsonc-parser@2.3.1: + resolution: {integrity: sha512-H8jvkz1O50L3dMZCsLqiuB2tA7muqbSg1AtGEkN0leAqGjsUzDJir3Zwr02BhqdcITPg3ei3mZ+HjMocAknhhg==} + + jsonc-parser@3.3.1: + resolution: {integrity: sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ==} + jsonfile@6.1.0: resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} jsonify@0.0.1: resolution: {integrity: sha512-2/Ki0GcmuqSrgFyelQq9M05y7PS0mEwuIzrf3f1fPqkVDVRvZrPZtVSMHxdgo8Aq0sxAOb/cr2aqqA3LeWHVPg==} + jsonpointer@5.0.1: + resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==} + engines: {node: '>=0.10.0'} + jszip@3.10.1: resolution: {integrity: sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==} keyv@4.5.4: resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + kind-of@6.0.3: + resolution: {integrity: sha512-dcS1ul+9tmeD95T+x28/ehLgd9mENa3LsvDTtzm3vyBEO7RPptvAD+t44WVXaUjTBRcrpFeFlC8WCruUR456hw==} + engines: {node: '>=0.10.0'} + kleur@3.0.3: resolution: {integrity: sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w==} engines: {node: '>=6'} + kleur@4.1.5: + resolution: {integrity: sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==} + engines: {node: '>=6'} + klona@2.0.6: resolution: {integrity: sha512-dhG34DXATL5hSxJbIexCft8FChFXtmskoZYnoPWjXQuebWYCNkVeV3KkGegCK9CP1oswI/vQibS2GY7Em/sJJA==} engines: {node: '>= 8'} @@ -4116,6 +4677,10 @@ packages: resolution: {integrity: sha512-b94GiNHQNy6JNTrt5w6zNyffMrNkXZb3KTkCZJb2V1xaEGCk093vkZ2jk3tpaeP33/OiXC+WvK9AxUebnf5nbw==} engines: {node: '>= 0.6.3'} + leven@3.1.0: + resolution: {integrity: sha512-qsda+H8jTaUaN/x5vzW2rzc+8Rw4TAQ/4KjB46IwK5VH+IlVeeeje/EoZRpiXvIqjFgK84QffqPztGI3VBLG1A==} + engines: {node: '>=6'} + levn@0.4.1: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} @@ -4144,9 +4709,9 @@ packages: resolution: {integrity: sha512-I8oW2+QL5KJo8zXNWX046M134WchxsXC7SawLPvRQpogCbkyQIaFxPE89A2HiwR7vAK2Dm2ERBAmyjTYGYEpBg==} hasBin: true - loader-runner@4.3.0: - resolution: {integrity: sha512-3R/1M+yS3j5ou80Me59j7F9IMs4PXs3VqRrm0TU3AbKPxlmpoY1TNscJV/oGJXo8qCatFGTfDbY6W6ipGOYXfg==} - engines: {node: '>=6.11.5'} + load-yaml-file@0.2.0: + resolution: {integrity: sha512-OfCBkGEw4nN6JLtgRidPX6QxjBQGQf72q3si2uvqyFEMbycSFFHwAZeXx6cJgFM9wmLrf9zBwCP3Ivqa+LLZPw==} + engines: {node: '>=6'} local-pkg@0.4.3: resolution: {integrity: sha512-SFppqq5p42fe2qcZQqqEOiVRXl+WCP1MdT6k7BDEW1j++sp5fIY+/fdRQitvKgB5BrBcmrs5m/L0v2FrU5MY1g==} @@ -4182,6 +4747,13 @@ packages: lodash@4.17.21: resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + log-symbols@6.0.0: + resolution: {integrity: sha512-i24m8rpwhmPIS4zscNzK6MSEhk0DUWa/8iYQWxhffV8jkI4Phvs3F+quL5xvS0gdQR0FyTCMMH33Y78dDTzzIw==} + engines: {node: '>=18'} + + longest-streak@3.1.0: + resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==} + loud-rejection@2.2.0: resolution: {integrity: sha512-S0FayMXku80toa5sZ6Ro4C+s+EtFDCsyJNG/AzFMfX3AxD5Si4dZsgzm/kKnbOxHl5Cv8jBlno8+3XYIh2pNjQ==} engines: {node: '>=8'} @@ -4196,8 +4768,8 @@ packages: resolution: {integrity: sha512-oN3Bcd7ZVt+0VGEs7402qR/tjgjbM7kPlH/z7ufJnzTLVBzXJITRHOJiwMmmYMgZfdoWQsfQcY+iKlxiBppnMA==} engines: {node: '>=16.14.0'} - magic-string@0.30.11: - resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} + magic-string@0.30.12: + resolution: {integrity: sha512-Ea8I3sQMVXr8JhN4z+H/d8zwo+tYDgHE9+5G4Wnrwhs0gaK9fXTKx0Tw5Xwsd/bCPTTZNRAdpyzvoeORe9LYpw==} magicast@0.3.5: resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} @@ -4206,6 +4778,10 @@ packages: resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} engines: {node: '>=8'} + markdown-extensions@2.0.0: + resolution: {integrity: sha512-o5vL7aDWatOTX8LzaS1WMoaoxIiLRQJuIKKe2wAw6IeULDHaqbiqiggmx+pKvZDb1Sj+pE46Sn1T7lCqfFtg1Q==} + engines: {node: '>=16'} + markdown-it@13.0.2: resolution: {integrity: sha512-FtwnEuuK+2yVU7goGn/MJ0WBZMM9ZPgU9spqlFs7/A/pDIUNSOQZhUgOqYCficIuR2QaFnrt8LHqBWsbTAoI5w==} hasBin: true @@ -4214,6 +4790,63 @@ packages: resolution: {integrity: sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==} hasBin: true + markdown-table@3.0.3: + resolution: {integrity: sha512-Z1NL3Tb1M9wH4XESsCDEksWoKTdlUafKc4pt0GRwjUyXaCFZ+dc3g2erqB6zm3szA2IUSi7VnPI+o/9jnxh9hw==} + + mdast-util-definitions@6.0.0: + resolution: {integrity: sha512-scTllyX6pnYNZH/AIp/0ePz6s4cZtARxImwoPJ7kS42n+MnVsI4XbnG6d4ibehRIldYMWM2LD7ImQblVhUejVQ==} + + mdast-util-directive@3.0.0: + resolution: {integrity: sha512-JUpYOqKI4mM3sZcNxmF/ox04XYFFkNwr0CFlrQIkCwbvH0xzMCqkMqAde9wRd80VAhaUrwFwKm2nxretdT1h7Q==} + + mdast-util-find-and-replace@3.0.1: + resolution: {integrity: sha512-SG21kZHGC3XRTSUhtofZkBzZTJNM5ecCi0SK2IMKmSXR8vO3peL+kb1O0z7Zl83jKtutG4k5Wv/W7V3/YHvzPA==} + + mdast-util-from-markdown@2.0.1: + resolution: {integrity: sha512-aJEUyzZ6TzlsX2s5B4Of7lN7EQtAxvtradMMglCQDyaTFgse6CmtmdJ15ElnVRlCg1vpNyVtbem0PWzlNieZsA==} + + mdast-util-gfm-autolink-literal@2.0.1: + resolution: {integrity: sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==} + + mdast-util-gfm-footnote@2.0.0: + resolution: {integrity: sha512-5jOT2boTSVkMnQ7LTrd6n/18kqwjmuYqo7JUPe+tRCY6O7dAuTFMtTPauYYrMPpox9hlN0uOx/FL8XvEfG9/mQ==} + + mdast-util-gfm-strikethrough@2.0.0: + resolution: {integrity: sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==} + + mdast-util-gfm-table@2.0.0: + resolution: {integrity: sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==} + + mdast-util-gfm-task-list-item@2.0.0: + resolution: {integrity: sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==} + + mdast-util-gfm@3.0.0: + resolution: {integrity: sha512-dgQEX5Amaq+DuUqf26jJqSK9qgixgd6rYDHAv4aTBuA92cTknZlKpPfa86Z/s8Dj8xsAQpFfBmPUHWJBWqS4Bw==} + + mdast-util-mdx-expression@2.0.1: + resolution: {integrity: sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==} + + mdast-util-mdx-jsx@3.1.3: + resolution: {integrity: sha512-bfOjvNt+1AcbPLTFMFWY149nJz0OjmewJs3LQQ5pIyVGxP4CdOqNVJL6kTaM5c68p8q82Xv3nCyFfUnuEcH3UQ==} + + mdast-util-mdx@3.0.0: + resolution: {integrity: sha512-JfbYLAW7XnYTTbUsmpu0kdBUVe+yKVJZBItEjwyYJiDJuZ9w4eeaqks4HQO+R7objWgS2ymV60GYpI14Ug554w==} + + mdast-util-mdxjs-esm@2.0.1: + resolution: {integrity: sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==} + + mdast-util-phrasing@4.1.0: + resolution: {integrity: sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==} + + mdast-util-to-hast@13.2.0: + resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} + + mdast-util-to-markdown@2.1.0: + resolution: {integrity: sha512-SR2VnIEdVNCJbP6y7kVTJgPLifdr8WEU440fQec7qHoHOUz/oJ2jmNRqdDQ3rbiStOXb2mCDGTuwsK5OPUgYlQ==} + + mdast-util-to-string@4.0.0: + resolution: {integrity: sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==} + mdn-data@2.0.28: resolution: {integrity: sha512-aylIc7Z9y4yzHYAJNuESG3hfhC+0Ibp/MAMiaOZgNv4pmEdFyfZhhhny4MNiAfWdBQ1RQ2mfDWmM1x8SvGyp8g==} @@ -4233,22 +4866,118 @@ packages: resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} engines: {node: '>= 8'} - micromatch@4.0.7: - resolution: {integrity: sha512-LPP/3KorzCwBxfeUuZmaR6bG2kdeHSbe0P2tY3FLRU4vYrjYz5hI4QZwV0njUx3jeuKe67YukQ1LSPZBKDqO/Q==} - engines: {node: '>=8.6'} + micromark-core-commonmark@2.0.1: + resolution: {integrity: sha512-CUQyKr1e///ZODyD1U3xit6zXwy1a8q2a1S1HKtIlmgvurrEpaw/Y9y6KSIbF8P59cn/NjzHyO+Q2fAyYLQrAA==} + + micromark-extension-directive@3.0.2: + resolution: {integrity: sha512-wjcXHgk+PPdmvR58Le9d7zQYWy+vKEU9Se44p2CrCDPiLr2FMyiT4Fyb5UFKFC66wGB3kPlgD7q3TnoqPS7SZA==} + + micromark-extension-gfm-autolink-literal@2.1.0: + resolution: {integrity: sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==} + + micromark-extension-gfm-footnote@2.1.0: + resolution: {integrity: sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==} + + micromark-extension-gfm-strikethrough@2.1.0: + resolution: {integrity: sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==} + + micromark-extension-gfm-table@2.1.0: + resolution: {integrity: sha512-Ub2ncQv+fwD70/l4ou27b4YzfNaCJOvyX4HxXU15m7mpYY+rjuWzsLIPZHJL253Z643RpbcP1oeIJlQ/SKW67g==} + + micromark-extension-gfm-tagfilter@2.0.0: + resolution: {integrity: sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==} + + micromark-extension-gfm-task-list-item@2.1.0: + resolution: {integrity: sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==} + + micromark-extension-gfm@3.0.0: + resolution: {integrity: sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==} + + micromark-extension-mdx-expression@3.0.0: + resolution: {integrity: sha512-sI0nwhUDz97xyzqJAbHQhp5TfaxEvZZZ2JDqUo+7NvyIYG6BZ5CPPqj2ogUoPJlmXHBnyZUzISg9+oUmU6tUjQ==} + + micromark-extension-mdx-jsx@3.0.1: + resolution: {integrity: sha512-vNuFb9czP8QCtAQcEJn0UJQJZA8Dk6DXKBqx+bg/w0WGuSxDxNr7hErW89tHUY31dUW4NqEOWwmEUNhjTFmHkg==} + + micromark-extension-mdx-md@2.0.0: + resolution: {integrity: sha512-EpAiszsB3blw4Rpba7xTOUptcFeBFi+6PY8VnJ2hhimH+vCQDirWgsMpz7w1XcZE7LVrSAUGb9VJpG9ghlYvYQ==} + + micromark-extension-mdxjs-esm@3.0.0: + resolution: {integrity: sha512-DJFl4ZqkErRpq/dAPyeWp15tGrcrrJho1hKK5uBS70BCtfrIFg81sqcTVu3Ta+KD1Tk5vAtBNElWxtAa+m8K9A==} + + micromark-extension-mdxjs@3.0.0: + resolution: {integrity: sha512-A873fJfhnJ2siZyUrJ31l34Uqwy4xIFmvPY1oj+Ean5PHcPBYzEsvqvWGaWcfEIr11O5Dlw3p2y0tZWpKHDejQ==} + + micromark-factory-destination@2.0.0: + resolution: {integrity: sha512-j9DGrQLm/Uhl2tCzcbLhy5kXsgkHUrjJHg4fFAeoMRwJmJerT9aw4FEhIbZStWN8A3qMwOp1uzHr4UL8AInxtA==} + + micromark-factory-label@2.0.0: + resolution: {integrity: sha512-RR3i96ohZGde//4WSe/dJsxOX6vxIg9TimLAS3i4EhBAFx8Sm5SmqVfR8E87DPSR31nEAjZfbt91OMZWcNgdZw==} + + micromark-factory-mdx-expression@2.0.2: + resolution: {integrity: sha512-5E5I2pFzJyg2CtemqAbcyCktpHXuJbABnsb32wX2U8IQKhhVFBqkcZR5LRm1WVoFqa4kTueZK4abep7wdo9nrw==} + + micromark-factory-space@2.0.0: + resolution: {integrity: sha512-TKr+LIDX2pkBJXFLzpyPyljzYK3MtmllMUMODTQJIUfDGncESaqB90db9IAUcz4AZAJFdd8U9zOp9ty1458rxg==} + + micromark-factory-title@2.0.0: + resolution: {integrity: sha512-jY8CSxmpWLOxS+t8W+FG3Xigc0RDQA9bKMY/EwILvsesiRniiVMejYTE4wumNc2f4UbAa4WsHqe3J1QS1sli+A==} + + micromark-factory-whitespace@2.0.0: + resolution: {integrity: sha512-28kbwaBjc5yAI1XadbdPYHX/eDnqaUFVikLwrO7FDnKG7lpgxnvk/XGRhX/PN0mOZ+dBSZ+LgunHS+6tYQAzhA==} + + micromark-util-character@2.1.0: + resolution: {integrity: sha512-KvOVV+X1yLBfs9dCBSopq/+G1PcgT3lAK07mC4BzXi5E7ahzMAF8oIupDDJ6mievI6F+lAATkbQQlQixJfT3aQ==} + + micromark-util-chunked@2.0.0: + resolution: {integrity: sha512-anK8SWmNphkXdaKgz5hJvGa7l00qmcaUQoMYsBwDlSKFKjc6gjGXPDw3FNL3Nbwq5L8gE+RCbGqTw49FK5Qyvg==} + + micromark-util-classify-character@2.0.0: + resolution: {integrity: sha512-S0ze2R9GH+fu41FA7pbSqNWObo/kzwf8rN/+IGlW/4tC6oACOs8B++bh+i9bVyNnwCcuksbFwsBme5OCKXCwIw==} + + micromark-util-combine-extensions@2.0.0: + resolution: {integrity: sha512-vZZio48k7ON0fVS3CUgFatWHoKbbLTK/rT7pzpJ4Bjp5JjkZeasRfrS9wsBdDJK2cJLHMckXZdzPSSr1B8a4oQ==} + + micromark-util-decode-numeric-character-reference@2.0.1: + resolution: {integrity: sha512-bmkNc7z8Wn6kgjZmVHOX3SowGmVdhYS7yBpMnuMnPzDq/6xwVA604DuOXMZTO1lvq01g+Adfa0pE2UKGlxL1XQ==} + + micromark-util-decode-string@2.0.0: + resolution: {integrity: sha512-r4Sc6leeUTn3P6gk20aFMj2ntPwn6qpDZqWvYmAG6NgvFTIlj4WtrAudLi65qYoaGdXYViXYw2pkmn7QnIFasA==} + + micromark-util-encode@2.0.0: + resolution: {integrity: sha512-pS+ROfCXAGLWCOc8egcBvT0kf27GoWMqtdarNfDcjb6YLuV5cM3ioG45Ys2qOVqeqSbjaKg72vU+Wby3eddPsA==} + + micromark-util-events-to-acorn@2.0.2: + resolution: {integrity: sha512-Fk+xmBrOv9QZnEDguL9OI9/NQQp6Hz4FuQ4YmCb/5V7+9eAh1s6AYSvL20kHkD67YIg7EpE54TiSlcsf3vyZgA==} + + micromark-util-html-tag-name@2.0.0: + resolution: {integrity: sha512-xNn4Pqkj2puRhKdKTm8t1YHC/BAjx6CEwRFXntTaRf/x16aqka6ouVoutm+QdkISTlT7e2zU7U4ZdlDLJd2Mcw==} + + micromark-util-normalize-identifier@2.0.0: + resolution: {integrity: sha512-2xhYT0sfo85FMrUPtHcPo2rrp1lwbDEEzpx7jiH2xXJLqBuy4H0GgXk5ToU8IEwoROtXuL8ND0ttVa4rNqYK3w==} + + micromark-util-resolve-all@2.0.0: + resolution: {integrity: sha512-6KU6qO7DZ7GJkaCgwBNtplXCvGkJToU86ybBAUdavvgsCiG8lSSvYxr9MhwmQ+udpzywHsl4RpGJsYWG1pDOcA==} + + micromark-util-sanitize-uri@2.0.0: + resolution: {integrity: sha512-WhYv5UEcZrbAtlsnPuChHUAsu/iBPOVaEVsntLBIdpibO0ddy8OzavZz3iL2xVvBZOpolujSliP65Kq0/7KIYw==} + + micromark-util-subtokenize@2.0.1: + resolution: {integrity: sha512-jZNtiFl/1aY73yS3UGQkutD0UbhTt68qnRpw2Pifmz5wV9h8gOVsN70v+Lq/f1rKaU/W8pxRe8y8Q9FX1AOe1Q==} + + micromark-util-symbol@2.0.0: + resolution: {integrity: sha512-8JZt9ElZ5kyTnO94muPxIGS8oyElRJaiJO8EzV6ZSyGQ1Is8xwl4Q45qU5UOg+bGH4AikWziz0iN4sFLWs8PGw==} + + micromark-util-types@2.0.0: + resolution: {integrity: sha512-oNh6S2WMHWRZrmutsRmDDfkzKtxF+bc2VxLC9dvtrDIRFln627VsFP6fLMgTryGDljgLPjkrzQSDcPrjPyDJ5w==} + + micromark@4.0.0: + resolution: {integrity: sha512-o/sd0nMof8kYff+TqcDx3VSrgBTcZpSvYcAHIfHhv5VAuNmisCxjhx6YmxS8PFEpb9z5WKWKPdzf0jM23ro3RQ==} micromatch@4.0.8: resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} engines: {node: '>=8.6'} - mime-db@1.52.0: - resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} - engines: {node: '>= 0.6'} - - mime-types@2.1.35: - resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} - engines: {node: '>= 0.6'} - mime@1.6.0: resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} engines: {node: '>=4'} @@ -4268,6 +4997,14 @@ packages: resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} engines: {node: '>=12'} + mimic-function@5.0.1: + resolution: {integrity: sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==} + engines: {node: '>=18'} + + mimic-response@3.1.0: + resolution: {integrity: sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==} + engines: {node: '>=10'} + min-indent@1.0.1: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} @@ -4312,13 +5049,16 @@ packages: mitt@3.0.1: resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + mkdirp-classic@0.5.3: + resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==} + mkdirp@1.0.4: resolution: {integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==} engines: {node: '>=10'} hasBin: true - mlly@1.7.1: - resolution: {integrity: sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA==} + mlly@1.7.2: + resolution: {integrity: sha512-tN3dvVHYVz4DhSXinXIk7u9syPYaJvio118uomkovAtWBT+RdbP6Lfh/5Lvo519YMmwBafwlh20IPTXIStscpA==} mri@1.2.0: resolution: {integrity: sha512-tzzskb3bG8LvYGFF/mDTpq3jpI6Q9wc3LEmBaghu+DdCssd1FakN7Bc0hVNmEyGq1bq3RgfkCb3cmQLpNPOroA==} @@ -4331,9 +5071,6 @@ packages: ms@2.0.0: resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} - ms@2.1.2: - resolution: {integrity: sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==} - ms@2.1.3: resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} @@ -4356,11 +5093,15 @@ packages: nanotar@0.1.1: resolution: {integrity: sha512-AiJsGsSF3O0havL1BydvI4+wR76sKT+okKRwWIaK96cZUnXqH0uNBOsHlbwZq3+m2BR1VKqHDVudl3gO4mYjpQ==} + napi-build-utils@1.0.2: + resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} - neo-async@2.6.2: - resolution: {integrity: sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==} + neotraverse@0.6.18: + resolution: {integrity: sha512-Z4SmBUweYa09+o6pG+eASabEpP6QkQ70yHj351pQoEXIs8uHbaU2DWVmzBANKgflPa47A50PtB2+NgRpQvr7vA==} + engines: {node: '>= 10'} nitropack@2.9.7: resolution: {integrity: sha512-aKXvtNrWkOCMsQbsk4A0qQdBjrJ1ZcvwlTQevI/LAgLWLYc5L7Q/YiYxGLal4ITyNSlzir1Cm1D2ZxnYhmpMEw==} @@ -4372,6 +5113,16 @@ packages: xml2js: optional: true + nlcst-to-string@4.0.0: + resolution: {integrity: sha512-YKLBCcUYKAg0FNlOBT6aI91qFmSiFKiluk655WzPF+DDMA02qIyy8uiRqI8QXtcFpEvll12LpL5MXqEmAZ+dcA==} + + node-abi@3.71.0: + resolution: {integrity: sha512-SZ40vRiy/+wRTf21hxkkEjPJZpARzUMVcJoQse2EF8qkUWbbO2z7vd5oA/H6bVH6SZQ5STGcu0KRDS7biNRfxw==} + engines: {node: '>=10'} + + node-addon-api@6.1.0: + resolution: {integrity: sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA==} + node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} @@ -4484,11 +5235,8 @@ packages: resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==} engines: {node: '>= 0.4'} - ofetch@1.3.4: - resolution: {integrity: sha512-KLIET85ik3vhEfS+3fDlc/BAZiAp+43QEC/yCo5zkNoY2YaKvNkOaFr/6wCFgFH1kuYQM5pMNi0Tg8koiIemtw==} - - ofetch@1.4.0: - resolution: {integrity: sha512-MuHgsEhU6zGeX+EMh+8mSMrYTnsqJQQrpM00Q6QHMKNqQ0bKy0B43tk8tL1wg+CnsSTy1kg4Ir2T5Ig6rD+dfQ==} + ofetch@1.4.1: + resolution: {integrity: sha512-QZj2DfGplQAr2oj9KzceK9Hwz6Whxazmn85yYeVuS3u9XTMOGMRx0kO95MQ+vLsj/S/NwBDMMLU5hpxvI6Tklw==} ohash@1.1.4: resolution: {integrity: sha512-FlDryZAahJmEF3VR3w1KogSEdWX3WhA5GPakFx4J81kEAiHyLMpdLLElS8n8dfNadMgAne/MywcvmogzscVt4g==} @@ -4504,6 +5252,13 @@ packages: resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} engines: {node: '>=12'} + onetime@7.0.0: + resolution: {integrity: sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==} + engines: {node: '>=18'} + + oniguruma-to-js@0.4.3: + resolution: {integrity: sha512-X0jWUcAlxORhOqqBREgPMgnshB7ZGYszBNspP+tS9hPD3l13CdaXcHbgImoHUHlrvGx/7AvFEkTRhAGYh+jzjQ==} + open@10.1.0: resolution: {integrity: sha512-mnkeQ1qP5Ue2wd+aivTD3NHd/lZ96Lu0jgf0pwktLPtx6cTZiH7tyeGRRHs0zX0rbrahXPnXlUnbeXyaBBuIaw==} engines: {node: '>=18'} @@ -4512,6 +5267,9 @@ packages: resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} engines: {node: '>=12'} + openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + openapi-typescript@6.7.6: resolution: {integrity: sha512-c/hfooPx+RBIOPM09GSxABOZhYPblDoyaGhqBkD/59vtpN21jEuWKDlM0KYTvqJVlSYjKs0tBcIdeXKChlSPtw==} hasBin: true @@ -4520,6 +5278,10 @@ packages: resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} engines: {node: '>= 0.8.0'} + ora@8.1.0: + resolution: {integrity: sha512-GQEkNkH/GHOhPFXcqZs3IDahXEQcQxsSjEkK4KvEEST4t7eNzoMjxTzef+EZ+JluDEV+Raoi3WQ2CflnRdSVnQ==} + engines: {node: '>=18'} + p-limit@2.3.0: resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} engines: {node: '>=6'} @@ -4528,6 +5290,10 @@ packages: resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} engines: {node: '>=10'} + p-limit@6.1.0: + resolution: {integrity: sha512-H0jc0q1vOzlEk0TqAKXKZxdl7kX3OFUzCnNVUnq5Pc3DGo0kpeaMuPqxQn235HibwBEb0/pm9dgKTjXy66fBkg==} + engines: {node: '>=18'} + p-locate@4.1.0: resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} engines: {node: '>=8'} @@ -4536,15 +5302,27 @@ packages: resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} engines: {node: '>=10'} + p-queue@8.0.1: + resolution: {integrity: sha512-NXzu9aQJTAzbBqOt2hwsR63ea7yvxJc0PwN/zobNAudYfb1B7R08SzB4TsLeSbUCuG467NhnoT0oO6w1qRO+BA==} + engines: {node: '>=18'} + + p-timeout@6.1.3: + resolution: {integrity: sha512-UJUyfKbwvr/uZSV6btANfb+0t/mOhKV/KXcCUTp8FcQI+v/0d+wXqH4htrW0E4rR6WiEO/EPvUFiV9D5OI4vlw==} + engines: {node: '>=14.16'} + p-try@2.2.0: resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} engines: {node: '>=6'} - package-json-from-dist@1.0.0: - resolution: {integrity: sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==} + package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} - package-manager-detector@0.2.0: - resolution: {integrity: sha512-E385OSk9qDcXhcM9LNSe4sdhx8a9mAPrZ4sMLW+tmxl5ZuGtPUcdFu+MPP2jbgiWAZ6Pfe5soGFMd+0Db5Vrog==} + package-manager-detector@0.2.2: + resolution: {integrity: sha512-VgXbyrSNsml4eHWIvxxG/nTL4wgybMTXCV2Un/+yEc3aDKKU6nQBZjbeP3Pl3qm9Qg92X/1ng4ffvCeD/zwHgg==} + + pagefind@1.1.1: + resolution: {integrity: sha512-U2YR0dQN5B2fbIXrLtt/UXNS0yWSSYfePaad1KcBPTi0p+zRtsVjwmoPaMQgTks5DnHNbmDxyJUL5TGaLljK3A==} + hasBin: true pako@1.0.11: resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} @@ -4553,24 +5331,33 @@ packages: resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} engines: {node: '>=6'} + parse-entities@4.0.1: + resolution: {integrity: sha512-SWzvYcSJh4d/SGLIOQfZ/CoNv6BTlI6YEQ7Nj82oDVnRpwe/Z/F1EMx42x3JAOwGBlCjeCH0BRJQbQ/opHL17w==} + parse-git-config@3.0.0: resolution: {integrity: sha512-wXoQGL1D+2COYWCD35/xbiKma1Z15xvZL8cI25wvxzled58V51SJM04Urt/uznS900iQor7QO04SgdfT/XlbuA==} engines: {node: '>=8'} - parse-imports@2.1.1: - resolution: {integrity: sha512-TDT4HqzUiTMO1wJRwg/t/hYk8Wdp3iF/ToMIlAoVQfL1Xs/sTxq1dKWSMjMbQmIarfWKymOyly40+zmPHXMqCA==} + parse-imports@2.2.1: + resolution: {integrity: sha512-OL/zLggRp8mFhKL0rNORUTR4yBYujK/uU+xZL+/0Rgm2QE4nLO9v8PzEweSJEbMGKmDRjJE4R3IMJlL2di4JeQ==} engines: {node: '>= 18'} parse-json@5.2.0: resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} engines: {node: '>=8'} + parse-latin@7.0.0: + resolution: {integrity: sha512-mhHgobPPua5kZ98EF4HWiH167JWBfl4pvAIXXdbaVohtK7a6YBOy56kvhCqduqyo/f3yrHFWmqmiMg/BkBkYYQ==} + parse-path@7.0.0: resolution: {integrity: sha512-Euf9GG8WT9CdqwuWJGdf3RkUcTBArppHABkO7Lm8IzRQp0e2r/kkFnmhu4TSK30Wcu5rVAZLmfPKSBBi9tWFog==} parse-url@8.1.0: resolution: {integrity: sha512-xDvOoLU5XRrcOZvnI6b8zA6n9O9ejNk/GExuz1yBuWUGn9KA97GI6HTs6u02wKara1CeVmZhH+0TZFdWScR89w==} + parse5@7.2.0: + resolution: {integrity: sha512-ZkDsAOcxsUMZ4Lz5fVciOehNcJ+Gb8gTzcA4yl3wnc273BAybYWrQ+Ks/OjCjSEpjvQkDSeZbybK9qj2VHHdGA==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} @@ -4615,12 +5402,12 @@ packages: perfect-debounce@1.0.0: resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} - picocolors@1.0.1: - resolution: {integrity: sha512-anP1Z8qwhkbmu7MFP5iTt+wQKXgwzf7zTyGlcdzabySa9vd0Xt392U0rVmz9poOaBj0uHJKyyo9/upk0HrEQew==} - picocolors@1.1.0: resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==} + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + picomatch@2.3.1: resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} engines: {node: '>=8.6'} @@ -4633,8 +5420,12 @@ packages: resolution: {integrity: sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==} engines: {node: '>=0.10.0'} - pinia@2.2.2: - resolution: {integrity: sha512-ja2XqFWZC36mupU4z1ZzxeTApV7DOw44cV4dhQ9sGwun+N89v/XP7+j7q6TanS1u1tdbK4r+1BUx7heMaIdagA==} + pify@4.0.1: + resolution: {integrity: sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==} + engines: {node: '>=6'} + + pinia@2.2.4: + resolution: {integrity: sha512-K7ZhpMY9iJ9ShTC0cR2+PnxdQRuwVIsXDO/WIEV/RnMC/vmSoKDTKW/exNQYPI+4ij10UjXqdNiEHwn47McANQ==} peerDependencies: '@vue/composition-api': ^1.4.0 typescript: '>=4.4.4' @@ -4649,8 +5440,12 @@ packages: resolution: {integrity: sha512-saLsH7WeYYPiD25LDuLRRY/i+6HaPYr6G1OUlN39otzkSTxKnubR9RTxS3/Kk50s1g2JTgFwWQDQyplC5/SHZg==} engines: {node: '>= 6'} - pkg-types@1.2.0: - resolution: {integrity: sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA==} + pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + + pkg-types@1.2.1: + resolution: {integrity: sha512-sQoqa8alT3nHjGuTjuKgOnvjo4cljkufdtLMnO2LBP/wRwuDlo1tkaEdMxCRhyGRPacv/ztlZgDPm2b7FAmEvw==} pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} @@ -4762,8 +5557,8 @@ packages: peerDependencies: postcss: ^8.4.31 - postcss-nested@6.0.1: - resolution: {integrity: sha512-mEp4xPMi5bSWiMbsgoPfcP74lsWLHkQbZc3sY+jWYd65CUwXrUaTp0fmNpa01ZcETKlIgUdFN/MpS2xZtqL9dQ==} + postcss-nested@6.2.0: + resolution: {integrity: sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==} engines: {node: '>=12.0'} peerDependencies: postcss: ^8.2.14 @@ -4840,14 +5635,6 @@ packages: peerDependencies: postcss: ^8.4.31 - postcss-selector-parser@6.1.0: - resolution: {integrity: sha512-UMz42UD0UY0EApS0ZL9o1XnLhSTtvvvLe5Dc2H2O56fvRZi+KulDyf5ctDhhtYJBGKStV2FL1fy6253cmLgqVQ==} - engines: {node: '>=4'} - - postcss-selector-parser@6.1.1: - resolution: {integrity: sha512-b4dlw/9V8A71rLIDsSwVmak9z2DuBUB7CA1/wSdelNEzqsjoSPeADTWNO09lpH49Diy3/JIZ2bSPB1dI3LJCHg==} - engines: {node: '>=4'} - postcss-selector-parser@6.1.2: resolution: {integrity: sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==} engines: {node: '>=4'} @@ -4867,19 +5654,24 @@ packages: postcss-value-parser@4.2.0: resolution: {integrity: sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==} - postcss@8.4.45: - resolution: {integrity: sha512-7KTLTdzdZZYscUc65XmjFiB73vBhBfbPztCYdUNvlaso9PrzjzcmjqBPR0lNGkcVlcO4BjiO5rK/qNz+XAen1Q==} - engines: {node: ^10 || ^12 || >=14} - postcss@8.4.47: resolution: {integrity: sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==} engines: {node: ^10 || ^12 || >=14} - posthog-js@1.161.3: - resolution: {integrity: sha512-TQ77jtLemkUJUyJAPrwGay6tLqcAmXEM1IJgXOx5Tr4UohiTx8JTznzrCuh/SdwPIrbcSM1r2YPwb72XwTC3wg==} + posthog-js@1.169.0: + resolution: {integrity: sha512-C0TiNv6ehbiy78F9gKZIqy3RbCRsWDSQDbQMi1YW2iuO4kDQUQwacmx2DKyaCwsH0/oN69FdBl99WoEJdjmxXg==} - preact@10.23.2: - resolution: {integrity: sha512-kKYfePf9rzKnxOAKDpsWhg/ysrHPqT+yQ7UW4JjdnqjFIeNUnNcEJvhuA8fDenxAGWzUqtd51DfVg7xp/8T9NA==} + preact@10.24.3: + resolution: {integrity: sha512-Z2dPnBnMUfyQfSQ+GBdsGa16hz35YmLmtTLhM169uW944hYL6xzTYkJjC07j+Wosz733pMWx0fgON3JNw1jJQA==} + + prebuild-install@7.1.2: + resolution: {integrity: sha512-UnNke3IQb6sgarcZIDU3gbMeTp/9SSU1DAIkil7PrqG1vZlBtY5msYccSKSHDqa3hNg436IXK+SNImReuA1wEQ==} + engines: {node: '>=10'} + hasBin: true + + preferred-pm@4.0.0: + resolution: {integrity: sha512-gYBeFTZLu055D8Vv3cSPox/0iTPtkzxpLroSYYA7WXgRi31WCJ51Uyl8ZiPeUUjyvs2MBzK+S8v9JVUgHU/Sqw==} + engines: {node: '>=18.12'} prelude-ls@1.2.1: resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} @@ -4889,8 +5681,8 @@ packages: resolution: {integrity: sha512-GbK2cP9nraSSUF9N2XwUwqfzlAFlMNYYl+ShE/V+H8a9uNl/oUqB1w2EL54Jh0OlyRSd8RfWYJ3coVS4TROP2w==} engines: {node: '>=6.0.0'} - prettier-plugin-tailwindcss@0.6.6: - resolution: {integrity: sha512-OPva5S7WAsPLEsOuOWXATi13QrCKACCiIonFgIR6V4lYv4QLp++UXVhZSzRbZxXGimkQtQT86CC6fQqTOybGng==} + prettier-plugin-tailwindcss@0.6.8: + resolution: {integrity: sha512-dGu3kdm7SXPkiW4nzeWKCl3uoImdd5CTZEJGxyypEPL37Wj0HT2pLqjrvSei1nTeuQfO4PUfjeW5cTUNRLZ4sA==} engines: {node: '>=14.21.3'} peerDependencies: '@ianvs/prettier-plugin-sort-imports': '*' @@ -4944,6 +5736,11 @@ packages: prettier-plugin-svelte: optional: true + prettier@2.8.7: + resolution: {integrity: sha512-yPngTo3aXUUmyuTjeTUT75txrf+aMh9FiD7q9ZE/i6r0bPb22g4FsE6Y338PQX1bmfy08i9QQCB7/rcUAVntfw==} + engines: {node: '>=10.13.0'} + hasBin: true + prettier@3.3.3: resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==} engines: {node: '>=14'} @@ -4953,6 +5750,10 @@ packages: resolution: {integrity: sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==} engines: {node: ^14.13.1 || >=16.0.0} + prismjs@1.29.0: + resolution: {integrity: sha512-Kx/1w86q/epKcmte75LNrEoT+lX8pBpavuAbvJWRXar7Hz8jrtF+e3vY751p0R8H9HdArwaCTNDDzHg/ScJK1Q==} + engines: {node: '>=6'} + process-nextick-args@2.0.1: resolution: {integrity: sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==} @@ -4964,9 +5765,15 @@ packages: resolution: {integrity: sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q==} engines: {node: '>= 6'} + property-information@6.5.0: + resolution: {integrity: sha512-PgTgs/BlvHxOu8QuEN7wi5A0OmXaBcHpmCSTehcs6Uuu9IkDIEo13Hy7n898RHfrQ49vKCoGeWZSaAK01nwVig==} + protocols@2.0.1: resolution: {integrity: sha512-/XJ368cyBJ7fzLMwLKv1e4vLxOju2MNAIokcr7meSaNcVbWz/CPcW22cP04mwxOErdA5mwjA8Q6w/cdAQxVn7Q==} + pump@3.0.2: + resolution: {integrity: sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==} + punycode.js@2.3.1: resolution: {integrity: sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==} engines: {node: '>=6'} @@ -4975,8 +5782,8 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} - qrcode.vue@3.4.1: - resolution: {integrity: sha512-wq/zHsifH4FJ1GXQi8/wNxD1KfQkckIpjK1KPTc/qwYU5/Bkd4me0w4xZSg6EXk6xLBkVDE0zxVagewv5EMAVA==} + qrcode.vue@3.5.0: + resolution: {integrity: sha512-lQE9DrozLmApXSS6ueKIfLGZYgjAqmsEQsdw3I4Tg7WnNapS0BEPhcZZCUIMS0aMMLsn+VXVeEwdoD0i0oJXgg==} peerDependencies: vue: ^3.0.0 @@ -4999,6 +5806,10 @@ packages: rc9@2.1.2: resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + rc@1.2.8: + resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==} + hasBin: true + read-cache@1.0.0: resolution: {integrity: sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==} @@ -5028,6 +5839,22 @@ packages: resolution: {integrity: sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==} engines: {node: '>=8.10.0'} + readdirp@4.0.2: + resolution: {integrity: sha512-yDMz9g+VaZkqBYS/ozoBJwaBhTbZo3UNYQHNRw1D3UFQB8oHB4uS/tAODO+ZLjGWmUbKnIlOWO+aaIiAxrUWHA==} + engines: {node: '>= 14.16.0'} + + recma-build-jsx@1.0.0: + resolution: {integrity: sha512-8GtdyqaBcDfva+GUKDr3nev3VpKAhup1+RvkMvUxURHpW7QyIvk9F5wz7Vzo06CEMSilw6uArgRqhpiUcWp8ew==} + + recma-jsx@1.0.0: + resolution: {integrity: sha512-5vwkv65qWwYxg+Atz95acp8DMu1JDSqdGkA2Of1j6rCreyFUE/gp15fC8MnGEuG1W68UKjM6x6+YTWIh7hZM/Q==} + + recma-parse@1.0.0: + resolution: {integrity: sha512-OYLsIGBB5Y5wjnSnQW6t3Xg7q3fQ7FWbw/vcXtORTnyaSFscOtABg+7Pnz6YZ6c27fG1/aN8CjfwoUEUIdwqWQ==} + + recma-stringify@1.0.0: + resolution: {integrity: sha512-cjwII1MdIIVloKvC9ErQ+OgAtwHBmcZ0Bg4ciz78FtbT8In39aAYbaA7zvxQ61xVMSPE8WxhLwLbhif4Js2C+g==} + redis-errors@1.2.0: resolution: {integrity: sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==} engines: {node: '>=4'} @@ -5040,6 +5867,12 @@ packages: resolution: {integrity: sha512-J8rn6v4DBb2nnFqkqwy6/NnTYMcgLA+sLr0iIO41qpv0n+ngb7ksag2tMRl0inb1bbO/esUwzW1vbJi7K0sI0g==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + + regex@4.3.3: + resolution: {integrity: sha512-r/AadFO7owAq1QJVeZ/nq9jNS1vyZt+6t1p/E59B56Rn2GCya+gr1KSyOzNL/er+r+B7phv5jG2xU2Nz1YkmJg==} + regexp-ast-analysis@0.7.1: resolution: {integrity: sha512-sZuz1dYW/ZsfG17WSAG7eS85r5a0dDsvg+7BiiYR5o6lKCAtUrEwdmRmaGF6rwVj3LcmAeYkOWKEPlbPzN3Y3A==} engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} @@ -5048,8 +5881,8 @@ packages: resolution: {integrity: sha512-iETxpjK6YoRWJG5o6hXLwvjYAoW+FEZn9os0PD/b6AP6xQwsa/Y7lCVgIixBbUPMfhu+i2LtdeAqVTgGlQarfA==} hasBin: true - regexp.prototype.flags@1.5.2: - resolution: {integrity: sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==} + regexp.prototype.flags@1.5.3: + resolution: {integrity: sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==} engines: {node: '>= 0.4'} regexpp@3.2.0: @@ -5060,10 +5893,63 @@ packages: resolution: {integrity: sha512-qx+xQGZVsy55CH0a1hiVwHmqjLryfh7wQyF5HO07XJ9f7dQMY/gPQHhlyDkIzJKC+x2fUCpCcUODUUUFrm7SHA==} hasBin: true + rehype-expressive-code@0.35.6: + resolution: {integrity: sha512-pPdE+pRcRw01kxMOwHQjuRxgwlblZt5+wAc3w2aPGgmcnn57wYjn07iKO7zaznDxYVxMYVvYlnL+R3vWFQS4Gw==} + + rehype-format@5.0.1: + resolution: {integrity: sha512-zvmVru9uB0josBVpr946OR8ui7nJEdzZobwLOOqHb/OOD88W0Vk2SqLwoVOj0fM6IPCCO6TaV9CvQvJMWwukFQ==} + + rehype-parse@9.0.1: + resolution: {integrity: sha512-ksCzCD0Fgfh7trPDxr2rSylbwq9iYDkSn8TCDmEJ49ljEUBxDVCzCHv7QNzZOfODanX4+bWQ4WZqLCRWYLfhag==} + + rehype-raw@7.0.0: + resolution: {integrity: sha512-/aE8hCfKlQeA8LmyeyQvQF3eBiLRGNlfBJEvWH7ivp9sBqs7TNqBL5X3v157rM4IFETqDnIOO+z5M/biZbo9Ww==} + + rehype-recma@1.0.0: + resolution: {integrity: sha512-lqA4rGUf1JmacCNWWZx0Wv1dHqMwxzsDWYMTowuplHF3xH0N/MmrZ/G3BDZnzAkRmxDadujCjaKM2hqYdCBOGw==} + + rehype-stringify@10.0.1: + resolution: {integrity: sha512-k9ecfXHmIPuFVI61B9DeLPN0qFHfawM6RsuX48hoqlaKSF61RskNjSm1lI8PhBEM0MRdLxVVm4WmTqJQccH9mA==} + + rehype@13.0.2: + resolution: {integrity: sha512-j31mdaRFrwFRUIlxGeuPXXKWQxet52RBQRvCmzl5eCefn/KGbomK5GMHNMsOJf55fgo3qw5tST5neDuarDYR2A==} + + remark-directive@3.0.0: + resolution: {integrity: sha512-l1UyWJ6Eg1VPU7Hm/9tt0zKtReJQNOA4+iDMAxTyZNWnJnFlbS/7zhiel/rogTLQ2vMYwDzSJa4BiVNqGlqIMA==} + + remark-gfm@4.0.0: + resolution: {integrity: sha512-U92vJgBPkbw4Zfu/IiW2oTZLSL3Zpv+uI7My2eq8JxKgqraFdU8YUGicEJCEgSbeaG+QDFqIcwwfMTOEelPxuA==} + + remark-mdx@3.1.0: + resolution: {integrity: sha512-Ngl/H3YXyBV9RcRNdlYsZujAmhsxwzxpDzpDEhFBVAGthS4GDgnctpDjgFl/ULx5UEDzqtW1cyBSNKqYYrqLBA==} + + remark-parse@11.0.0: + resolution: {integrity: sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==} + + remark-rehype@11.1.1: + resolution: {integrity: sha512-g/osARvjkBXb6Wo0XvAeXQohVta8i84ACbenPpoSsxTOQH/Ae0/RGP4WZgnMH5pMLpsj4FG7OHmcIcXxpza8eQ==} + + remark-smartypants@3.0.2: + resolution: {integrity: sha512-ILTWeOriIluwEvPjv67v7Blgrcx+LZOkAUVtKI3putuhlZm84FnqDORNXPPm+HY3NdZOMhyDwZ1E+eZB/Df5dA==} + engines: {node: '>=16.0.0'} + + remark-stringify@11.0.0: + resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + + request-light@0.5.8: + resolution: {integrity: sha512-3Zjgh+8b5fhRJBQZoy+zbVKpAQGLyka0MPgW3zruTF4dFFJ8Fqcfu9YsAvi/rvdcaTeWG3MkbZv4WKxAn/84Lg==} + + request-light@0.7.0: + resolution: {integrity: sha512-lMbBMrDoxgsyO+yB3sDcrDuX85yYt7sS8BfQd11jtbW/z5ZWgLZRcEGLsLoYw7I0WSUGQBs8CC8ScIxkTX1+6Q==} + require-directory@2.1.1: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + resolve-from@4.0.0: resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} engines: {node: '>=4'} @@ -5079,6 +5965,22 @@ packages: resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} hasBin: true + restore-cursor@5.1.0: + resolution: {integrity: sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==} + engines: {node: '>=18'} + + retext-latin@4.0.0: + resolution: {integrity: sha512-hv9woG7Fy0M9IlRQloq/N6atV82NxLGveq+3H2WOi79dtIYWN8OaxogDm77f8YnVXJL2VD3bbqowu5E3EMhBYA==} + + retext-smartypants@6.2.0: + resolution: {integrity: sha512-kk0jOU7+zGv//kfjXEBjdIryL1Acl4i9XNkHxtM7Tm5lFiCog576fjNC9hjoR7LTKQ0DsPWy09JummSsH1uqfQ==} + + retext-stringify@4.0.0: + resolution: {integrity: sha512-rtfN/0o8kL1e+78+uxPTqu1Klt0yPzKuQ2BfWwwfgIUSayyzxpM1PJzkKt4V8803uB9qSy32MvI7Xep9khTpiA==} + + retext@9.0.0: + resolution: {integrity: sha512-sbMDcpHCNjvlheSgMfEcVrZko3cDzdbe1x/e7G66dFp0Ff7Mldvi2uv6JkJQzdRcvLYE8CA8Oe8siQx8ZOgTcA==} + reusify@1.0.4: resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} @@ -5130,14 +6032,13 @@ packages: safe-regex@2.1.1: resolution: {integrity: sha512-rx+x8AMzKb5Q5lQ95Zoi6ZbJqwCLkqi3XuJXp5P3rT8OEc6sZCJG5AE5dU3lsgRr/F4Bs31jSlVN+j5KrsGu9A==} - sass@1.78.0: - resolution: {integrity: sha512-AaIqGSrjo5lA2Yg7RvFZrlXDBCp3nV4XP73GrLGvdRWWwk+8H3l0SDvq/5bA4eF+0RFPLuWUk3E+P1U/YqnpsQ==} + sass@1.79.5: + resolution: {integrity: sha512-W1h5kp6bdhqFh2tk3DsI771MoEJjvrSY/2ihJRJS4pjIyfJCw0nTsxqhnrUzaLMOJjFchj8rOvraI/YUVjtx5g==} engines: {node: '>=14.0.0'} hasBin: true - schema-utils@3.3.0: - resolution: {integrity: sha512-pN/yOAvcC+5rQ5nERGuwrjLlYvLTbCibnZ1I7B1LaiAz9BRBlE9GMgE/eqV30P7aJQUf7Ddimy/RsbYO/GrVGg==} - engines: {node: '>= 10.13.0'} + sax@1.4.1: + resolution: {integrity: sha512-+aWOz7yVScEGoKNd4PA10LZ8sk0A/z5+nXQG5giUO5rprX9jgYsTdov9qCchZiPIZezbZH+jRut8nPodFAX4Jg==} scslre@0.3.0: resolution: {integrity: sha512-3A6sD0WYP7+QrjbfNA2FN3FsOaGGFoekCVgTyypy53gPxhbkCIjtO6YWgdrfM+n/8sI8JeXZOIxsHjMTNxQ4nQ==} @@ -5146,6 +6047,10 @@ packages: scule@1.3.0: resolution: {integrity: sha512-6FtHJEvt+pVMIB9IBY+IcCJ6Z5f1iQnytgyfKMhDKgmzYG+TeH/wx1y3l27rshSbLiSanrR9ffZDrEsmjlQF2g==} + section-matter@1.0.0: + resolution: {integrity: sha512-vfD3pmTzGpufjScBh50YHKzEu2lxBWhVEHsNGoEXmCmn2hKGfeNLYMzCJpe8cD7gqX7TJluOVpBkAequ6dgMmA==} + engines: {node: '>=4'} + semver@5.7.2: resolution: {integrity: sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==} hasBin: true @@ -5190,6 +6095,14 @@ packages: setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + sharp@0.32.6: + resolution: {integrity: sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==} + engines: {node: '>=14.15.0'} + + sharp@0.33.5: + resolution: {integrity: sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==} + engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} + shebang-command@2.0.0: resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} engines: {node: '>=8'} @@ -5201,6 +6114,9 @@ packages: shell-quote@1.8.1: resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==} + shiki@1.22.0: + resolution: {integrity: sha512-/t5LlhNs+UOKQCYBtl5ZsH/Vclz73GIqT2yQsCBygr8L/ppTdmpL4w3kPLoZJbMKVWtoG77Ue1feOjZfDxvMkw==} + side-channel@1.0.6: resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} engines: {node: '>= 0.4'} @@ -5212,9 +6128,18 @@ packages: resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} engines: {node: '>=14'} + simple-concat@1.0.1: + resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==} + + simple-get@4.0.1: + resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==} + simple-git@3.27.0: resolution: {integrity: sha512-ivHoFS9Yi9GY49ogc6/YAi3Fl9ROnF4VyubNylgCkA+RVqLaKWnDSzXOVzya8csELIaWaYNutsEuAhZrtOjozA==} + simple-swizzle@0.2.2: + resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==} + sirv@2.0.4: resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} engines: {node: '>= 10'} @@ -5222,6 +6147,11 @@ packages: sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} + sitemap@8.0.0: + resolution: {integrity: sha512-+AbdxhM9kJsHtruUF39bwS/B0Fytw6Fr1o4ZAIAEqA6cke2xcoO2GleBw9Zw7nRzILVEgz7zBM5GiTJjie1G9A==} + engines: {node: '>=14.0.0', npm: '>=6.0.0'} + hasBin: true + slash@3.0.0: resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} engines: {node: '>=8'} @@ -5236,10 +6166,6 @@ packages: smob@1.5.0: resolution: {integrity: sha512-g6T+p7QO8npa+/hNx9ohv1E5pVCmWrVCUzUXJyLdMmftX6ER0oiWY/w9knEonLpnOp6b6FenKnMfR8gqwWdwig==} - source-map-js@1.2.0: - resolution: {integrity: sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==} - engines: {node: '>=0.10.0'} - source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -5255,6 +6181,9 @@ packages: resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} engines: {node: '>= 8'} + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + spdx-correct@3.2.0: resolution: {integrity: sha512-kN9dJbvnySHULIluDHy32WHRUu3Og7B9sbY7tsFLctQkIqnMh3hErYgdMjTYuqmcXX+lK5T1lnUt3G7zNswmZA==} @@ -5274,12 +6203,23 @@ packages: resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} engines: {node: '>=0.10.0'} + sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + stable-hash@0.0.4: resolution: {integrity: sha512-LjdcbuBeLcdETCrPn9i8AYAZ1eCtu4ECAWtP7UleOiZ9LzVxRzzUZEoZ8zB24nhkQnDWyET0I+3sWokSDS3E7g==} standard-as-callback@2.1.0: resolution: {integrity: sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==} + starlight-openapi@0.7.0: + resolution: {integrity: sha512-+aqVVqmoFZTb3ibXM6mfl3nmKY/bCZOn5+FTRzOGulEOtG4R8Y8NGwj9HN+6vxLZSPlAPfKwb3ipcAdDbVBuCg==} + engines: {node: '>=18.14.1'} + peerDependencies: + '@astrojs/markdown-remark': '>=4.2.0' + '@astrojs/starlight': '>=0.26.0' + astro: '>=4.8.6' + statuses@2.0.1: resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} engines: {node: '>= 0.8'} @@ -5287,6 +6227,13 @@ packages: std-env@3.7.0: resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} + stdin-discarder@0.2.2: + resolution: {integrity: sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ==} + engines: {node: '>=18'} + + stream-replace-string@2.0.0: + resolution: {integrity: sha512-TlnjJ1C0QrmxRNrON00JvaFFlNh5TTG00APw23j74ET7gkQpTASi6/L2fuiav8pzK715HXtUeClpBTw2NPSn6w==} + streamx@2.20.1: resolution: {integrity: sha512-uTa0mU6WUC65iUvzKH4X9hEdvSW7rbPxPtwfWiLMSj3qTdQbAiUboZTxauKfpFuGIGa1C2BYijZ7wgdUXICJhA==} @@ -5298,6 +6245,10 @@ packages: resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} engines: {node: '>=12'} + string-width@7.2.0: + resolution: {integrity: sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==} + engines: {node: '>=18'} + string.prototype.trim@1.2.9: resolution: {integrity: sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==} engines: {node: '>= 0.4'} @@ -5315,6 +6266,9 @@ packages: string_decoder@1.3.0: resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + strip-ansi@6.0.1: resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} engines: {node: '>=8'} @@ -5323,6 +6277,10 @@ packages: resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} engines: {node: '>=12'} + strip-bom-string@1.0.0: + resolution: {integrity: sha512-uCC2VHvQRYu+lMh4My/sFNmF2klFymLX1wHJeXnbEJERpV/ZsVuonzerjfrGpIGF7LBVa1O7i9kjiWvJiFck8g==} + engines: {node: '>=0.10.0'} + strip-bom@3.0.0: resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} engines: {node: '>=4'} @@ -5335,6 +6293,10 @@ packages: resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} engines: {node: '>=8'} + strip-json-comments@2.0.1: + resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==} + engines: {node: '>=0.10.0'} + strip-json-comments@3.1.1: resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} engines: {node: '>=8'} @@ -5345,6 +6307,12 @@ packages: style-mod@4.1.2: resolution: {integrity: sha512-wnD1HyVqpJUI2+eKZ+eo1UwghftP6yuFheBqqe+bWCotBjC2K1YnteJILRMs3SM4V/0dLEW1SC27MWP5y+mwmw==} + style-to-object@0.4.4: + resolution: {integrity: sha512-HYNoHZa2GorYNyqiCaBgsxvcJIn7OHq6inEga+E6Ke3m5JkoqpQbnFssk4jwe+K7AhGa2fcha4wSOf1Kn01dMg==} + + style-to-object@1.0.8: + resolution: {integrity: sha512-xT47I/Eo0rwJmaXC4oilDGDWLohVhR6o/xAQcPQN8q6QBuZVL8qMYL85kLmST5cPjAorwvqIA4qXTRQoYHaL6g==} + stylehacks@7.0.4: resolution: {integrity: sha512-i4zfNrGMt9SB4xRK9L83rlsFCgdGANfeDAYacO1pkqcE7cRHPdWHwnKZVz7WY17Veq/FvyYsRAU++Ga+qDFIww==} engines: {node: ^18.12.0 || ^20.9.0 || >=22.0} @@ -5368,10 +6336,6 @@ packages: resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} engines: {node: '>=8'} - supports-color@8.1.1: - resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} - engines: {node: '>=10'} - supports-color@9.4.0: resolution: {integrity: sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==} engines: {node: '>=12'} @@ -5419,16 +6383,16 @@ packages: engines: {node: '>=14.0.0'} hasBin: true - synckit@0.9.1: - resolution: {integrity: sha512-7gr8p9TQP6RAHusBOSLs46F4564ZrjV8xFmw5zCmgmhGUcw2hxsShhJ6CEiHQMgPDwAQ1fWHPM0ypc4RMAig4A==} + synckit@0.9.2: + resolution: {integrity: sha512-vrozgXDQwYO72vHjUb/HnFbQx1exDjoKzqx23aXEg2a9VIg2TSFZ8FmeZpTjUCFMYw7mpX4BE2SFu8wI7asYsw==} engines: {node: ^14.18.0 || >=16.0.0} system-architecture@0.1.0: resolution: {integrity: sha512-ulAk51I9UVUyJgxlv9M6lFot2WP3e7t8Kz9+IS6D4rVba1tR9kON+Ey69f+1R4Q8cd45Lod6a4IcJIxnzGc/zA==} engines: {node: '>=18'} - tailwindcss@3.4.11: - resolution: {integrity: sha512-qhEuBcLemjSJk5ajccN9xJFtM/h0AVCPaA6C92jNP+M2J8kX+eMJHI7R2HFKUvvAsMpcfLILMCFYSeDwpMmlUg==} + tailwindcss@3.4.14: + resolution: {integrity: sha512-IcSvOcTRcUtQQ7ILQL5quRDg7Xs93PdJEk1ZLbhhvJc7uj/OAhYOnruEiwnGgBvUtaUAJ8/mhSw1o8L2jCiENA==} engines: {node: '>=14.0.0'} hasBin: true @@ -5436,6 +6400,16 @@ packages: resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} engines: {node: '>=6'} + tar-fs@2.1.1: + resolution: {integrity: sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==} + + tar-fs@3.0.6: + resolution: {integrity: sha512-iokBDQQkUyeXhgPYaZxmczGPhnhXZ0CmrqI+MOb/WFGS9DW5wnfrLgtjUJBvz50vQ3qfRwJ62QVoCFu8mPVu5w==} + + tar-stream@2.2.0: + resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==} + engines: {node: '>=6'} + tar-stream@3.1.7: resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} @@ -5443,29 +6417,13 @@ packages: resolution: {integrity: sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==} engines: {node: '>=10'} - terser-webpack-plugin@5.3.10: - resolution: {integrity: sha512-BKFPWlPDndPs+NGGCr1U59t0XScL5317Y0UReNrHaw9/FwhPENlq6bfgs+4yPfyP51vqC1bQ4rp1EfXW5ZSH9w==} - engines: {node: '>= 10.13.0'} - peerDependencies: - '@swc/core': '*' - esbuild: '*' - uglify-js: '*' - webpack: ^5.1.0 - peerDependenciesMeta: - '@swc/core': - optional: true - esbuild: - optional: true - uglify-js: - optional: true - terser@5.34.1: resolution: {integrity: sha512-FsJZ7iZLd/BXkz+4xrRTGJ26o/6VTjQytUk8b8OxkwcD2I+79VPJlz7qss1+zE7h8GNIScFqXcDyJ/KqBYZFVA==} engines: {node: '>=10'} hasBin: true - text-decoder@1.2.0: - resolution: {integrity: sha512-n1yg1mOj9DNpk3NeZOx7T6jchTbyJS3i3cucbNN6FcdPriMZx7NsgrGpWWdWZZGxD7ES1XB+3uoqHMgOKaN+fg==} + text-decoder@1.2.1: + resolution: {integrity: sha512-x9v3H/lTKIJKQQe7RPQkLfKAnc9lUTkWDypIQgTzPJAq+5/GCDHonmshfvlsNSj58yyshbIJJDLmU15qNERrXQ==} text-table@0.2.0: resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} @@ -5480,6 +6438,9 @@ packages: tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + tinyexec@0.3.1: + resolution: {integrity: sha512-WiCJLEECkO18gwqIp6+hJg0//p23HXp4S+gGtAKu3mI2F2/sXC4FvHvXvB0zJVVaTPhx1/tOwdbRsa1sOBIKqQ==} + tinyglobby@0.2.6: resolution: {integrity: sha512-NbBoFBpqfcgd1tCiO8Lkfdk+xrA7mlLR9zgvZcZWQQwU63XAfUePyd6wZBaU93Hqw347lHnwFzttAkemHzzz4g==} engines: {node: '>=12.0.0'} @@ -5507,6 +6468,12 @@ packages: tr46@0.0.3: resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + trough@2.2.0: + resolution: {integrity: sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==} + ts-api-utils@1.3.0: resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==} engines: {node: '>=16'} @@ -5516,47 +6483,57 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tsconfck@3.1.4: + resolution: {integrity: sha512-kdqWFGVJqe+KGYvlSO9NIaWn9jT1Ny4oKVzAJsKii5eoE9snzTJzL4+MMVOMn+fikWGFmKEylcXL710V/kIPJQ==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + tsconfig-paths@3.15.0: resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} - tslib@2.6.3: - resolution: {integrity: sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==} + tslib@2.8.0: + resolution: {integrity: sha512-jWVzBLplnCmoaTr13V9dYbiQ99wvZRd0vNWaDRg+aVYRcjDF3nDksxFDE/+fkXnKhpnUUkmx5pK/v8mCtLVqZA==} - tslib@2.7.0: - resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + tunnel-agent@0.6.0: + resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} - turbo-darwin-64@2.1.1: - resolution: {integrity: sha512-aYNuJpZlCoi0Htd79fl/2DywpewGKijdXeOfg9KzNuPVKzSMYlAXuAlNGh0MKjiOcyqxQGL7Mq9LFhwA0VpDpQ==} + turbo-darwin-64@2.2.1: + resolution: {integrity: sha512-jltMdSQ+7rQDVaorjW729PCw6fwAn1MgZSdoa0Gil7GZCOF3SnR/ok0uJw6G5mdm6F5XM8ZTlz+mdGzBLuBRaA==} cpu: [x64] os: [darwin] - turbo-darwin-arm64@2.1.1: - resolution: {integrity: sha512-tifJKD8yHY48rHXPMcM8o1jI/Jk2KCaXiNjTKvvy9Zsim61BZksNVLelIbrRoCGwAN6PUBZO2lGU5iL/TQJ5Pw==} + turbo-darwin-arm64@2.2.1: + resolution: {integrity: sha512-RHW0c1NonsJXXlutlZeunmhLanf0/WbeizFfYgWuTEaJE4MbbhyD/RG4Fm/7iob5kxQ4Es2TzfDPqyMqpIO0GA==} cpu: [arm64] os: [darwin] - turbo-linux-64@2.1.1: - resolution: {integrity: sha512-Js6d/bSQe9DuV9c7ITXYpsU/ADzFHABdz1UIHa7Oqjj9VOEbFeA9WpAn0c+mdJrVD+IXJFbbDZUjN7VYssmtcg==} + turbo-linux-64@2.2.1: + resolution: {integrity: sha512-RasrjV+i2B90hoR8r6B2Btf2/ebNT5MJbhkpY0G1EN06E1IkjCKfAXj/1Dwmjy9+Zo0NC2r69L3HxRrtpar8jQ==} cpu: [x64] os: [linux] - turbo-linux-arm64@2.1.1: - resolution: {integrity: sha512-LidzTCq0yvQ+N8w8Qub9FmhQ/mmEIeoqFi7DSupekEV2EjvE9jw/zYc9Pk67X+g7dHVfgOnvVzmrjChdxpFePw==} + turbo-linux-arm64@2.2.1: + resolution: {integrity: sha512-LNkUUJuu1gNkhlo7Ky/zilXEiajLoGlWLiKT1XV5neEf+x1s+aU9Hzd/+HhSVMiyI8l7z6zLbrM1a6+v4co/SQ==} cpu: [arm64] os: [linux] - turbo-windows-64@2.1.1: - resolution: {integrity: sha512-GKc9ZywKwy4xLDhwXd6H07yzl0TB52HjXMrFLyHGhCVnf/w0oq4sLJv2sjbvuarPjsyx4xnCBJ3m3oyL2XmFtA==} + turbo-windows-64@2.2.1: + resolution: {integrity: sha512-Mn5tlFrLzlQ6tW6wTWNlyT1osXuDUg0VT1VAjRpmRXlK2Zi3oKVVG0rs0nkkq4rmuheryD1xyuGPN9nFKbAn/A==} cpu: [x64] os: [win32] - turbo-windows-arm64@2.1.1: - resolution: {integrity: sha512-oFKkMj11KKUv3xSK9/fhAEQTxLUp1Ol1EOktwc32+SFtEU0uls7kosAz0b+qe8k3pJGEMFdDPdqoEjyJidbxtQ==} + turbo-windows-arm64@2.2.1: + resolution: {integrity: sha512-bvYOJ3SMN00yiem+uAqwRMbUMau/KiMzJYxnD0YkFo6INc08z8gZi5g0GLZAR7g/L3JegktX3UQW2cJvryjvLg==} cpu: [arm64] os: [win32] - turbo@2.1.1: - resolution: {integrity: sha512-u9gUDkmR9dFS8b5kAYqIETK4OnzsS4l2ragJ0+soSMHh6VEeNHjTfSjk1tKxCqLyziCrPogadxP680J+v6yGHw==} + turbo@2.2.1: + resolution: {integrity: sha512-clZFkh6U6NpsLKBVZYRjlZjRTfju1Z5STqvFVaOGu5443uM75alJe1nCYH9pQ9YJoiOvXAqA2rDHWN5kLS9JMg==} hasBin: true type-check@0.4.0: @@ -5583,6 +6560,10 @@ packages: resolution: {integrity: sha512-tLq3bSNx+xSpwvAJnzrK0Ep5CLNWjvFTOp71URMaAEWBfRb9nnJiBoUe0tF8bI4ZFO3omgBR6NvnbzVUT3Ly4g==} engines: {node: '>=14.16'} + type-fest@4.26.1: + resolution: {integrity: sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg==} + engines: {node: '>=16'} + typed-array-buffer@1.0.2: resolution: {integrity: sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==} engines: {node: '>= 0.4'} @@ -5599,8 +6580,14 @@ packages: resolution: {integrity: sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==} engines: {node: '>= 0.4'} - typescript@5.6.2: - resolution: {integrity: sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==} + typesafe-path@0.2.2: + resolution: {integrity: sha512-OJabfkAg1WLZSqJAJ0Z6Sdt3utnbzr/jh+NAHoyWHJe8CMSy79Gm085094M9nvTPy22KzTVn5Zq5mbapCI/hPA==} + + typescript-auto-import-cache@0.3.3: + resolution: {integrity: sha512-ojEC7+Ci1ij9eE6hp8Jl9VUNnsEKzztktP5gtYNRMrTmfXVwA1PITYYAkpxCvvupdSYa/Re51B6KMcv1CTZEUA==} + + typescript@5.6.3: + resolution: {integrity: sha512-hjcS1mhfuyi4WW8IWtjP7brDrG2cuDZukyrYrSauoXGNgx0S7zceP07adYkJycEr56BOUTNPzbInooiN3fn1qw==} engines: {node: '>=14.17'} hasBin: true @@ -5610,9 +6597,6 @@ packages: uc.micro@2.1.0: resolution: {integrity: sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==} - ufo@1.5.3: - resolution: {integrity: sha512-Y7HYmWaFwPUmkoQCUIAYpKqkOf+SbVj/2fJJZ4RJMCfZp0rTGwRbzQD+HghfnhKOjL9E01okqz+ncJskGYfBNw==} - ufo@1.5.4: resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} @@ -5628,6 +6612,9 @@ packages: unctx@2.3.1: resolution: {integrity: sha512-PhKke8ZYauiqh3FEMVNm7ljvzQiph0Mt3GBRve03IJm7ukfaON2OBK795tLwhbyfzknuRRkW0+Ze+CQUmzOZ+A==} + undici-types@5.26.5: + resolution: {integrity: sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==} + undici-types@6.19.8: resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} @@ -5638,19 +6625,49 @@ packages: unenv@1.10.0: resolution: {integrity: sha512-wY5bskBQFL9n3Eca5XnhH6KbUo/tfvkwm9OpcdCvLaeA7piBNbavbOKJySEwQ1V0RH6HvNlSAFRTpvTqgKRQXQ==} - unhead@1.11.7: - resolution: {integrity: sha512-aA0+JBRryLhDKUq6L2JhMDLZEG/ElyyDASyC9wiwDl6nvvsj9hD26LgPWgmAsSd+9HtMGM2N1gU27CWEMo16CQ==} + unhead@1.11.9: + resolution: {integrity: sha512-EwEGMjbXVVn2O5vNfXUHiAjHWFHngPjkAx0yVZZsrTgqzs7+A/YvJ90TLvBna874+HCKZWtufo7QAI7luU2CgA==} unicorn-magic@0.1.0: resolution: {integrity: sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ==} engines: {node: '>=18'} - unimport@3.11.1: - resolution: {integrity: sha512-DuB1Uoq01LrrXTScxnwOoMSlTXxyKcULguFxbLrMDFcE/CO0ZWHpEiyhovN0mycPt7K6luAHe8laqvwvuoeUPg==} + unified@11.0.5: + resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} unimport@3.13.1: resolution: {integrity: sha512-nNrVzcs93yrZQOW77qnyOVHtb68LegvhYFwxFMfuuWScmwQmyVCG/NBuN8tYsaGzgQUVYv34E/af+Cc9u4og4A==} + unist-util-find-after@5.0.0: + resolution: {integrity: sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==} + + unist-util-is@6.0.0: + resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} + + unist-util-modify-children@4.0.0: + resolution: {integrity: sha512-+tdN5fGNddvsQdIzUF3Xx82CU9sMM+fA0dLgR9vOmT0oPT2jH+P1nd5lSqfCfXAw+93NhcXNY2qqvTUtE4cQkw==} + + unist-util-position-from-estree@2.0.0: + resolution: {integrity: sha512-KaFVRjoqLyF6YXCbVLNad/eS4+OfPQQn2yOd7zF/h5T/CSL2v8NpN6a5TPvtbXthAGw5nG+PuTtq+DdIZr+cRQ==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-remove-position@5.0.0: + resolution: {integrity: sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-children@3.0.0: + resolution: {integrity: sha512-RgmdTfSBOg04sdPcpTSD1jzoNBjt9a80/ZCzp5cI9n1qPzLZWF9YdvWGN2zmTumP1HWhXKdUWexjy/Wy/lJ7tA==} + + unist-util-visit-parents@6.0.1: + resolution: {integrity: sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==} + + unist-util-visit@5.0.0: + resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + universalify@2.0.1: resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} engines: {node: '>= 10.0.0'} @@ -5720,23 +6737,13 @@ packages: resolution: {integrity: sha512-4luGP9LMYszMRZwsvyUd9MrxgEGZdZuZgpVQHEEX0lCYFESasVRvZd0EYpCkOIbJKHMuv0LskpXc/8Un+MJzEQ==} hasBin: true - untyped@1.4.2: - resolution: {integrity: sha512-nC5q0DnPEPVURPhfPQLahhSTnemVtPzdx7ofiRxXpOB2SYnb3MfdU3DVGyJdS8Lx+tBWeAePO8BfU/3EgksM7Q==} - hasBin: true - - untyped@1.5.0: - resolution: {integrity: sha512-o2Vjmn2dal08BzCcINxSmWuAteReUUiXseii5VRhmxyLF0b21K0iKZQ9fMYK7RWspVkY+0saqaVQNq4roe3Efg==} + untyped@1.5.1: + resolution: {integrity: sha512-reBOnkJBFfBZ8pCKaeHgfZLcehXtM6UTxc+vqs1JvCps0c4amLNp3fhdGBZwYp+VLyoY9n3X5KOP7lCyWBUX9A==} hasBin: true unwasm@0.3.9: resolution: {integrity: sha512-LDxTx/2DkFURUd+BU1vUsF/moj0JsoTvl+2tcg2AUOiEzVturhGGx17/IMgGvKUYdZwr33EJHtChCJuhu9Ouvg==} - update-browserslist-db@1.1.0: - resolution: {integrity: sha512-EdRAaAyk2cUE1wOf2DkEhzxqOQvFOoRJFNS6NeyJ01Gp2beMRpBAINjM2iDXE3KCuKhwnvHIQCJm6ThL2Z+HzQ==} - hasBin: true - peerDependencies: - browserslist: '>= 4.21.0' - update-browserslist-db@1.1.1: resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==} hasBin: true @@ -5758,13 +6765,22 @@ packages: validate-npm-package-license@3.0.4: resolution: {integrity: sha512-DpKm2Ui/xN7/HQKCtpZxoRWBhZ9Z0kqtygG8XCgNQ8ZlDnxuQmWhj566j8fN4Cu3/JmbhsDo7fcAJq4s9h27Ew==} + vfile-location@5.0.3: + resolution: {integrity: sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==} + + vfile-message@4.0.2: + resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + vite-hot-client@0.2.3: resolution: {integrity: sha512-rOGAV7rUlUHX89fP2p2v0A2WWvV3QMX2UYq0fRqsWSvFvev4atHWqjwGoKaZT1VTKyLGk533ecu3eyd0o59CAg==} peerDependencies: vite: ^2.6.0 || ^3.0.0 || ^4.0.0 || ^5.0.0-0 - vite-node@2.1.2: - resolution: {integrity: sha512-HPcGNN5g/7I2OtPjLqgOtCRu/qhVvBxTUD3qzitmL0SrG1cWFzxzhMDWussxSbrRYWqnKf8P2jiNhPMSN+ymsQ==} + vite-node@2.1.3: + resolution: {integrity: sha512-I1JadzO+xYX887S39Do+paRePCKoiDrWRRjp9kkG5he0t7RXNvPAJPCQSJqbGN4uCrFFeS3Kj3sLqY8NMYBEdA==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true @@ -5822,8 +6838,8 @@ packages: peerDependencies: vue: '>=3.2.13' - vite@5.4.8: - resolution: {integrity: sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==} + vite@5.4.9: + resolution: {integrity: sha512-20OVpJHh0PAM0oSOELa5GaZNWeDjcAvQjGXy2Uyr+Tp+/D2/Hdz6NLgpJLsarPTA2QJ6v8mX2P1ZfbsSKvdMkg==} engines: {node: ^18.0.0 || >=20.0.0} hasBin: true peerDependencies: @@ -5853,10 +6869,91 @@ packages: terser: optional: true + vitefu@1.0.3: + resolution: {integrity: sha512-iKKfOMBHob2WxEJbqbJjHAkmYgvFDPhuqrO82om83S8RLk+17FtyMBfcyeH8GqD0ihShtkMW/zzJgiA51hCNCQ==} + peerDependencies: + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0-beta.0 + peerDependenciesMeta: + vite: + optional: true + + volar-service-css@0.0.61: + resolution: {integrity: sha512-Ct9L/w+IB1JU8F4jofcNCGoHy6TF83aiapfZq9A0qYYpq+Kk5dH+ONS+rVZSsuhsunq8UvAuF8Gk6B8IFLfniw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-emmet@0.0.61: + resolution: {integrity: sha512-iiYqBxjjcekqrRruw4COQHZME6EZYWVbkHjHDbULpml3g8HGJHzpAMkj9tXNCPxf36A+f1oUYjsvZt36qPg4cg==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-html@0.0.61: + resolution: {integrity: sha512-yFE+YmmgqIL5HI4ORqP++IYb1QaGcv+xBboI0WkCxJJ/M35HZj7f5rbT3eQ24ECLXFbFCFanckwyWJVz5KmN3Q==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-prettier@0.0.61: + resolution: {integrity: sha512-F612nql5I0IS8HxXemCGvOR2Uxd4XooIwqYVUvk7WSBxP/+xu1jYvE3QJ7EVpl8Ty3S4SxPXYiYTsG3bi+gzIQ==} + peerDependencies: + '@volar/language-service': ~2.4.0 + prettier: ^2.2 || ^3.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + prettier: + optional: true + + volar-service-typescript-twoslash-queries@0.0.61: + resolution: {integrity: sha512-99FICGrEF0r1E2tV+SvprHPw9Knyg7BdW2fUch0tf59kG+KG+Tj4tL6tUg+cy8f23O/VXlmsWFMIE+bx1dXPnQ==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-typescript@0.0.61: + resolution: {integrity: sha512-4kRHxVbW7wFBHZWRU6yWxTgiKETBDIJNwmJUAWeP0mHaKpnDGj/astdRFKqGFRYVeEYl45lcUPhdJyrzanjsdQ==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + volar-service-yaml@0.0.61: + resolution: {integrity: sha512-L+gbDiLDQQ1rZUbJ3mf3doDsoQUa8OZM/xdpk/unMg1Vz24Zmi2Ign8GrZyBD7bRoIQDwOH9gdktGDKzRPpUNw==} + peerDependencies: + '@volar/language-service': ~2.4.0 + peerDependenciesMeta: + '@volar/language-service': + optional: true + + vscode-css-languageservice@6.3.1: + resolution: {integrity: sha512-1BzTBuJfwMc3A0uX4JBdJgoxp74cjj4q2mDJdp49yD/GuAq4X0k5WtK6fNcMYr+FfJ9nqgR6lpfCSZDkARJ5qQ==} + + vscode-html-languageservice@5.3.1: + resolution: {integrity: sha512-ysUh4hFeW/WOWz/TO9gm08xigiSsV/FOAZ+DolgJfeLftna54YdmZ4A+lIn46RbdO3/Qv5QHTn1ZGqmrXQhZyA==} + + vscode-json-languageservice@4.1.8: + resolution: {integrity: sha512-0vSpg6Xd9hfV+eZAaYN63xVVMOTmJ4GgHxXnkLCh+9RsQBkWKIghzLhW2B9ebfG+LQQg8uLtsQ2aUKjTgE+QOg==} + engines: {npm: '>=7.0.0'} + vscode-jsonrpc@6.0.0: resolution: {integrity: sha512-wnJA4BnEjOSyFMvjZdpiOwhSq9uDoK8e/kpRJDTaMYzwlkrhG1fwDIZI94CLsLzlCK5cIbMMtFlJlfR57Lavmg==} engines: {node: '>=8.0.0 || >=10.0.0'} + vscode-jsonrpc@8.2.0: + resolution: {integrity: sha512-C+r0eKJUIfiDIfwJhria30+TYWPtuHJXHtI7J0YlOmKAo7ogxP20T0zxB7HZQIFhIyvoBPwWskjxrvAtfjyZfA==} + engines: {node: '>=14.0.0'} + vscode-languageclient@7.0.0: resolution: {integrity: sha512-P9AXdAPlsCgslpP9pRxYPqkNYV7Xq8300/aZDpO35j1fJm/ncize8iGswzYlcvFw5DQUx4eVk+KvfXdL0rehNg==} engines: {vscode: ^1.52.0} @@ -5864,16 +6961,32 @@ packages: vscode-languageserver-protocol@3.16.0: resolution: {integrity: sha512-sdeUoAawceQdgIfTI+sdcwkiK2KU+2cbEYA0agzM2uqaUy2UpnnGHtWTHVEtS0ES4zHU0eMFRGN+oQgDxlD66A==} + vscode-languageserver-protocol@3.17.5: + resolution: {integrity: sha512-mb1bvRJN8SVznADSGWM9u/b07H7Ecg0I3OgXDuLdn307rl/J3A9YD6/eYOssqhecL27hK1IPZAsaqh00i/Jljg==} + vscode-languageserver-textdocument@1.0.12: resolution: {integrity: sha512-cxWNPesCnQCcMPeenjKKsOCKQZ/L6Tv19DTRIGuLWe32lyzWhihGVJ/rcckZXJxfdKCFvRLS3fpBIsV/ZGX4zA==} vscode-languageserver-types@3.16.0: resolution: {integrity: sha512-k8luDIWJWyenLc5ToFQQMaSrqCHiLwyKPHKPQZ5zz21vM+vIVUSvsRpcbiECH4WR88K2XZqc4ScRcZ7nk/jbeA==} + vscode-languageserver-types@3.17.5: + resolution: {integrity: sha512-Ld1VelNuX9pdF39h2Hgaeb5hEZM2Z3jUrrMgWQAu82jMtZp7p3vJT3BzToKtZI7NgQssZje5o0zryOrhQvzQAg==} + vscode-languageserver@7.0.0: resolution: {integrity: sha512-60HTx5ID+fLRcgdHfmz0LDZAXYEV68fzwG0JWwEPBode9NuMYTIxuYXPg4ngO8i8+Ou0lM7y6GzaYWbiDL0drw==} hasBin: true + vscode-languageserver@9.0.1: + resolution: {integrity: sha512-woByF3PDpkHFUreUa7Hos7+pUWdeWMXRd26+ZX2A8cFx6v/JPTtd4/uN0/jB6XQHYaOlHbio03NTHCqrgG5n7g==} + hasBin: true + + vscode-nls@5.2.0: + resolution: {integrity: sha512-RAaHx7B14ZU04EU31pT+rKz2/zSl7xMsfIZuo8pd+KZO6PXtQmpevpq3vxvWNcrGbdmhM/rr5Uw5Mz+NBfhVng==} + + vscode-uri@2.1.2: + resolution: {integrity: sha512-8TEXQxlldWAuIODdukIb+TR5s+9Ds40eSJrw+1iDDA9IFORPjMELarNQE3myz5XIkWWpdprmJjm1/SxMlWOC8A==} + vscode-uri@3.0.8: resolution: {integrity: sha512-AyFQ0EVmsOZOlAnxoFOGOq1SQDWAB7C6aqMGS23svWAllfOaxbuFvcT8D1i8z3Gyn8fraVeZNNmN6e9bxxXkKw==} @@ -5940,22 +7053,14 @@ packages: peerDependencies: vue: ^3.2.0 - vue3-apexcharts@1.6.0: - resolution: {integrity: sha512-gemKFXpw4TuVcllwyKJGYjTwiJQxxCUwbXsiiEEZjs0zc9jvOHvreN8frXz7QbnYqMqOHF9D1TBqwENvoPNjLw==} + vue3-apexcharts@1.7.0: + resolution: {integrity: sha512-BmWoS8+x5XLCtk2ml7rLVO+QU+fjgQUUCjUXSFW9cNQpCMa5Z0eRPvZjvYLt5aDKNREtuZoidlG9WRjZ/Af7lA==} peerDependencies: apexcharts: '> 3.0.0' vue: '> 3.0.0' - vue@3.5.11: - resolution: {integrity: sha512-/8Wurrd9J3lb72FTQS7gRMNQD4nztTtKPmuDuPuhqXmmpD6+skVjAeahNpVzsuky6Sy9gy7wn8UadqPtt9SQIg==} - peerDependencies: - typescript: '*' - peerDependenciesMeta: - typescript: - optional: true - - vue@3.5.4: - resolution: {integrity: sha512-3yAj2gkmiY+i7+22A1PWM+kjOVXjU74UPINcTiN7grIVPyFFI0lpGwHlV/4xydDmobaBn7/xmi+YG8HeSlCTcg==} + vue@3.5.12: + resolution: {integrity: sha512-CLVZtXtn2ItBIi/zHZ0Sg1Xkb7+PU32bJJ8Bmy7ts3jxXTcbfsEfBivFYYWz1Hur+lalqGAh65Coin0r+HRUfg==} peerDependencies: typescript: '*' peerDependenciesMeta: @@ -5965,9 +7070,8 @@ packages: w3c-keyname@2.2.8: resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==} - watchpack@2.4.2: - resolution: {integrity: sha512-TnbFSbcOCcDgjZ4piURLCbJ3nJhznVh9kw6F6iokjiFPl8ONxe9A6nMDVXDiNbrSfLILs6vB07F7wLBrwPYzJw==} - engines: {node: '>=10.13.0'} + web-namespaces@2.0.1: + resolution: {integrity: sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==} web-vitals@4.2.3: resolution: {integrity: sha512-/CFAm1mNxSmOj6i0Co+iGFJ58OS4NRGVP+AWS/l509uIK5a1bSoIVaHz/ZumpHTfHSZBpgrJ+wjfpAOrTHok5Q==} @@ -5975,29 +7079,23 @@ packages: webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} - webpack-sources@3.2.3: - resolution: {integrity: sha512-/DyMEOrDgLKKIG0fmvtz+4dUX/3Ghozwgm6iPp8KRhvn+eQf9+Q7GWxVNMk3+uCPWfdXYC4ExGBckIXdFEfH1w==} - engines: {node: '>=10.13.0'} - webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} - webpack@5.94.0: - resolution: {integrity: sha512-KcsGn50VT+06JH/iunZJedYGUJS5FGjow8wb9c0v5n1Om8O1g4L6LjtfxwlXIATopoQu+vOXXa7gYisWxCoPyg==} - engines: {node: '>=10.13.0'} - hasBin: true - peerDependencies: - webpack-cli: '*' - peerDependenciesMeta: - webpack-cli: - optional: true - whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} which-boxed-primitive@1.0.2: resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} + which-pm-runs@1.1.0: + resolution: {integrity: sha512-n1brCuqClxfFfq/Rb0ICg9giSZqCS+pLtccdag6C2HyufBrh3fBOiy9nb6ggRMvWOVH5GrdJskj5iGTZNxd7SA==} + engines: {node: '>=4'} + + which-pm@3.0.0: + resolution: {integrity: sha512-ysVYmw6+ZBhx3+ZkcPwRuJi38ZOTLJJ33PSHaitLxSKUMsh0LkKd0nC69zZCwt5D+AYUcMK2hhw4yWny20vSGg==} + engines: {node: '>=18.12'} + which-typed-array@1.1.15: resolution: {integrity: sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==} engines: {node: '>= 0.4'} @@ -6015,6 +7113,10 @@ packages: wide-align@1.1.5: resolution: {integrity: sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==} + widest-line@5.0.0: + resolution: {integrity: sha512-c9bZp7b5YtRj2wOe6dlj32MK+Bx/M/d+9VB2SHM1OtsUHR0aV0tdP6DWh/iMt0kWi1t5g1Iudu6hQRNd1A4PVA==} + engines: {node: '>=18'} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -6027,6 +7129,10 @@ packages: resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} engines: {node: '>=12'} + wrap-ansi@9.0.0: + resolution: {integrity: sha512-G8ura3S+3Z2G+mkgNRq8dqaFZAuxfsxpBB8OCTGRTCtp+l/v9nbFNmCUP1BZMts3G1142MsZfn6eeUKrr4PD1Q==} + engines: {node: '>=18'} + wrappy@1.0.2: resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} @@ -6051,6 +7157,9 @@ packages: engines: {node: '>= 0.10.0'} hasBin: true + xxhash-wasm@1.0.2: + resolution: {integrity: sha512-ibF0Or+FivM9lNrg+HGJfVX8WJqgo+kCLDc4vx6xMeTce7Aj+DLttKbxxRR/gNLSAelRc1omAPlJ77N/Jem07A==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -6061,13 +7170,16 @@ packages: yallist@4.0.0: resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} - yaml@2.4.5: - resolution: {integrity: sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg==} - engines: {node: '>= 14'} + yaml-language-server@1.15.0: + resolution: {integrity: sha512-N47AqBDCMQmh6mBLmI6oqxryHRzi33aPFPsJhYy3VTUGCdLHYjGh4FZzpUjRlphaADBBkDmnkM/++KNIOHi5Rw==} hasBin: true - yaml@2.5.1: - resolution: {integrity: sha512-bLQOjaX/ADgQ20isPJRvF0iRUHIxVhYvr53Of7wGcWlO2jvtUlH5m87DsmulFVxRpNLOnI4tB6p/oh8D7kpn9Q==} + yaml@2.2.2: + resolution: {integrity: sha512-CBKFWExMn46Foo4cldiChEzn7S7SRV+wqiluAb6xmueD/fGyRHIhX8m14vVGgeFWjN540nKCNVj6P21eQjgTuA==} + engines: {node: '>= 14'} + + yaml@2.6.0: + resolution: {integrity: sha512-a6ae//JvKDEra2kdi1qzCyrJW/WZCgFi8ydDV+eXExl95t+5R+ijnqHJbz9tmMh8FUjx3iv2fCQ4dclAQlO2UQ==} engines: {node: '>= 14'} hasBin: true @@ -6083,6 +7195,10 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + yocto-queue@1.1.1: + resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==} + engines: {node: '>=12.20'} + zhead@2.2.4: resolution: {integrity: sha512-8F0OI5dpWIA5IGG5NHUg9staDwz/ZPxZtvGVf01j7vHqSyZ0raHY+78atOVxRqb73AotX22uV1pXt3gYSstGag==} @@ -6090,6 +7206,23 @@ packages: resolution: {integrity: sha512-zK7YHHz4ZXpW89AHXUPbQVGKI7uvkd3hzusTdotCg1UxyaVtg0zFJSTfW/Dq5f7OBBVnq6cZIaC8Ti4hb6dtCA==} engines: {node: '>= 14'} + zod-to-json-schema@3.23.3: + resolution: {integrity: sha512-TYWChTxKQbRJp5ST22o/Irt9KC5nj7CdBKYB/AosCRdj/wxEMvv4NNaj9XVUHDOIp53ZxArGhnw5HMZziPFjog==} + peerDependencies: + zod: ^3.23.3 + + zod-to-ts@1.2.0: + resolution: {integrity: sha512-x30XE43V+InwGpvTySRNz9kB7qFU8DlyEy7BsSTCHPH1R0QasMmHWZDCzYm6bVXtj/9NNJAZF3jW8rzFvH5OFA==} + peerDependencies: + typescript: ^4.9.4 || ^5.0.2 + zod: ^3 + + zod@3.23.8: + resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + snapshots: '@alloc/quick-lru@5.2.0': {} @@ -6101,52 +7234,165 @@ snapshots: '@antfu/utils@0.7.10': {} - '@babel/code-frame@7.24.7': + '@apidevtools/openapi-schemas@2.1.0': {} + + '@apidevtools/swagger-methods@3.0.2': {} + + '@astrojs/check@0.9.4(prettier@3.3.3)(typescript@5.6.3)': dependencies: - '@babel/highlight': 7.24.7 - picocolors: 1.1.0 + '@astrojs/language-server': 2.15.3(prettier@3.3.3)(typescript@5.6.3) + chokidar: 4.0.1 + kleur: 4.1.5 + typescript: 5.6.3 + yargs: 17.7.2 + transitivePeerDependencies: + - prettier + - prettier-plugin-astro + + '@astrojs/compiler@2.10.3': {} + + '@astrojs/internal-helpers@0.4.1': {} + + '@astrojs/language-server@2.15.3(prettier@3.3.3)(typescript@5.6.3)': + dependencies: + '@astrojs/compiler': 2.10.3 + '@astrojs/yaml2ts': 0.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@volar/kit': 2.4.6(typescript@5.6.3) + '@volar/language-core': 2.4.6 + '@volar/language-server': 2.4.6 + '@volar/language-service': 2.4.6 + fast-glob: 3.3.2 + muggle-string: 0.4.1 + volar-service-css: 0.0.61(@volar/language-service@2.4.6) + volar-service-emmet: 0.0.61(@volar/language-service@2.4.6) + volar-service-html: 0.0.61(@volar/language-service@2.4.6) + volar-service-prettier: 0.0.61(@volar/language-service@2.4.6)(prettier@3.3.3) + volar-service-typescript: 0.0.61(@volar/language-service@2.4.6) + volar-service-typescript-twoslash-queries: 0.0.61(@volar/language-service@2.4.6) + volar-service-yaml: 0.0.61(@volar/language-service@2.4.6) + vscode-html-languageservice: 5.3.1 + vscode-uri: 3.0.8 + optionalDependencies: + prettier: 3.3.3 + transitivePeerDependencies: + - typescript + + '@astrojs/markdown-remark@5.3.0': + dependencies: + '@astrojs/prism': 3.1.0 + github-slugger: 2.0.0 + hast-util-from-html: 2.0.3 + hast-util-to-text: 4.0.2 + import-meta-resolve: 4.1.0 + mdast-util-definitions: 6.0.0 + rehype-raw: 7.0.0 + rehype-stringify: 10.0.1 + remark-gfm: 4.0.0 + remark-parse: 11.0.0 + remark-rehype: 11.1.1 + remark-smartypants: 3.0.2 + shiki: 1.22.0 + unified: 11.0.5 + unist-util-remove-position: 5.0.0 + unist-util-visit: 5.0.0 + unist-util-visit-parents: 6.0.1 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@astrojs/mdx@3.1.8(astro@4.16.6(@types/node@20.16.11)(rollup@4.24.0)(sass@1.79.5)(terser@5.34.1)(typescript@5.6.3))': + dependencies: + '@astrojs/markdown-remark': 5.3.0 + '@mdx-js/mdx': 3.1.0(acorn@8.13.0) + acorn: 8.13.0 + astro: 4.16.6(@types/node@20.16.11)(rollup@4.24.0)(sass@1.79.5)(terser@5.34.1)(typescript@5.6.3) + es-module-lexer: 1.5.4 + estree-util-visit: 2.0.0 + gray-matter: 4.0.3 + hast-util-to-html: 9.0.3 + kleur: 4.1.5 + rehype-raw: 7.0.0 + remark-gfm: 4.0.0 + remark-smartypants: 3.0.2 + source-map: 0.7.4 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@astrojs/prism@3.1.0': + dependencies: + prismjs: 1.29.0 + + '@astrojs/sitemap@3.2.1': + dependencies: + sitemap: 8.0.0 + stream-replace-string: 2.0.0 + zod: 3.23.8 + + '@astrojs/starlight@0.26.4(astro@4.16.6(@types/node@20.16.11)(rollup@4.24.0)(sass@1.79.5)(terser@5.34.1)(typescript@5.6.3))': + dependencies: + '@astrojs/mdx': 3.1.8(astro@4.16.6(@types/node@20.16.11)(rollup@4.24.0)(sass@1.79.5)(terser@5.34.1)(typescript@5.6.3)) + '@astrojs/sitemap': 3.2.1 + '@pagefind/default-ui': 1.1.1 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + astro: 4.16.6(@types/node@20.16.11)(rollup@4.24.0)(sass@1.79.5)(terser@5.34.1)(typescript@5.6.3) + astro-expressive-code: 0.35.6(astro@4.16.6(@types/node@20.16.11)(rollup@4.24.0)(sass@1.79.5)(terser@5.34.1)(typescript@5.6.3)) + bcp-47: 2.1.0 + hast-util-from-html: 2.0.3 + hast-util-select: 6.0.3 + hast-util-to-string: 3.0.1 + hastscript: 9.0.0 + mdast-util-directive: 3.0.0 + mdast-util-to-markdown: 2.1.0 + mdast-util-to-string: 4.0.0 + pagefind: 1.1.1 + rehype: 13.0.2 + rehype-format: 5.0.1 + remark-directive: 3.0.0 + unified: 11.0.5 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + transitivePeerDependencies: + - supports-color + + '@astrojs/telemetry@3.1.0': + dependencies: + ci-info: 4.0.0 + debug: 4.3.7 + dlv: 1.1.3 + dset: 3.1.4 + is-docker: 3.0.0 + is-wsl: 3.1.0 + which-pm-runs: 1.1.0 + transitivePeerDependencies: + - supports-color + + '@astrojs/yaml2ts@0.2.1': + dependencies: + yaml: 2.6.0 '@babel/code-frame@7.25.7': dependencies: '@babel/highlight': 7.25.7 - picocolors: 1.1.0 + picocolors: 1.1.1 - '@babel/compat-data@7.25.4': {} + '@babel/compat-data@7.25.8': {} - '@babel/compat-data@7.25.7': {} - - '@babel/core@7.25.2': - dependencies: - '@ampproject/remapping': 2.3.0 - '@babel/code-frame': 7.24.7 - '@babel/generator': 7.25.6 - '@babel/helper-compilation-targets': 7.25.2 - '@babel/helper-module-transforms': 7.25.2(@babel/core@7.25.2) - '@babel/helpers': 7.25.6 - '@babel/parser': 7.25.6 - '@babel/template': 7.25.0 - '@babel/traverse': 7.25.6 - '@babel/types': 7.25.6 - convert-source-map: 2.0.0 - debug: 4.3.7 - gensync: 1.0.0-beta.2 - json5: 2.2.3 - semver: 6.3.1 - transitivePeerDependencies: - - supports-color - - '@babel/core@7.25.7': + '@babel/core@7.25.8': dependencies: '@ampproject/remapping': 2.3.0 '@babel/code-frame': 7.25.7 '@babel/generator': 7.25.7 '@babel/helper-compilation-targets': 7.25.7 - '@babel/helper-module-transforms': 7.25.7(@babel/core@7.25.7) + '@babel/helper-module-transforms': 7.25.7(@babel/core@7.25.8) '@babel/helpers': 7.25.7 - '@babel/parser': 7.25.7 + '@babel/parser': 7.25.8 '@babel/template': 7.25.7 '@babel/traverse': 7.25.7 - '@babel/types': 7.25.7 + '@babel/types': 7.25.8 convert-source-map: 2.0.0 debug: 4.3.7 gensync: 1.0.0-beta.2 @@ -6155,47 +7401,32 @@ snapshots: transitivePeerDependencies: - supports-color - '@babel/generator@7.25.6': - dependencies: - '@babel/types': 7.25.6 - '@jridgewell/gen-mapping': 0.3.5 - '@jridgewell/trace-mapping': 0.3.25 - jsesc: 2.5.2 - '@babel/generator@7.25.7': dependencies: - '@babel/types': 7.25.7 + '@babel/types': 7.25.8 '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 jsesc: 3.0.2 '@babel/helper-annotate-as-pure@7.25.7': dependencies: - '@babel/types': 7.25.7 - - '@babel/helper-compilation-targets@7.25.2': - dependencies: - '@babel/compat-data': 7.25.4 - '@babel/helper-validator-option': 7.24.8 - browserslist: 4.23.3 - lru-cache: 5.1.1 - semver: 6.3.1 + '@babel/types': 7.25.8 '@babel/helper-compilation-targets@7.25.7': dependencies: - '@babel/compat-data': 7.25.7 + '@babel/compat-data': 7.25.8 '@babel/helper-validator-option': 7.25.7 browserslist: 4.24.0 lru-cache: 5.1.1 semver: 6.3.1 - '@babel/helper-create-class-features-plugin@7.25.7(@babel/core@7.25.7)': + '@babel/helper-create-class-features-plugin@7.25.7(@babel/core@7.25.8)': dependencies: - '@babel/core': 7.25.7 + '@babel/core': 7.25.8 '@babel/helper-annotate-as-pure': 7.25.7 '@babel/helper-member-expression-to-functions': 7.25.7 '@babel/helper-optimise-call-expression': 7.25.7 - '@babel/helper-replace-supers': 7.25.7(@babel/core@7.25.7) + '@babel/helper-replace-supers': 7.25.7(@babel/core@7.25.8) '@babel/helper-skip-transparent-expression-wrappers': 7.25.7 '@babel/traverse': 7.25.7 semver: 6.3.1 @@ -6205,37 +7436,20 @@ snapshots: '@babel/helper-member-expression-to-functions@7.25.7': dependencies: '@babel/traverse': 7.25.7 - '@babel/types': 7.25.7 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-imports@7.24.7': - dependencies: - '@babel/traverse': 7.25.6 - '@babel/types': 7.25.6 + '@babel/types': 7.25.8 transitivePeerDependencies: - supports-color '@babel/helper-module-imports@7.25.7': dependencies: '@babel/traverse': 7.25.7 - '@babel/types': 7.25.7 + '@babel/types': 7.25.8 transitivePeerDependencies: - supports-color - '@babel/helper-module-transforms@7.25.2(@babel/core@7.25.2)': + '@babel/helper-module-transforms@7.25.7(@babel/core@7.25.8)': dependencies: - '@babel/core': 7.25.2 - '@babel/helper-module-imports': 7.24.7 - '@babel/helper-simple-access': 7.24.7 - '@babel/helper-validator-identifier': 7.24.7 - '@babel/traverse': 7.25.6 - transitivePeerDependencies: - - supports-color - - '@babel/helper-module-transforms@7.25.7(@babel/core@7.25.7)': - dependencies: - '@babel/core': 7.25.7 + '@babel/core': 7.25.8 '@babel/helper-module-imports': 7.25.7 '@babel/helper-simple-access': 7.25.7 '@babel/helper-validator-identifier': 7.25.7 @@ -6245,273 +7459,259 @@ snapshots: '@babel/helper-optimise-call-expression@7.25.7': dependencies: - '@babel/types': 7.25.7 + '@babel/types': 7.25.8 '@babel/helper-plugin-utils@7.25.7': {} - '@babel/helper-replace-supers@7.25.7(@babel/core@7.25.7)': + '@babel/helper-replace-supers@7.25.7(@babel/core@7.25.8)': dependencies: - '@babel/core': 7.25.7 + '@babel/core': 7.25.8 '@babel/helper-member-expression-to-functions': 7.25.7 '@babel/helper-optimise-call-expression': 7.25.7 '@babel/traverse': 7.25.7 transitivePeerDependencies: - supports-color - '@babel/helper-simple-access@7.24.7': - dependencies: - '@babel/traverse': 7.25.6 - '@babel/types': 7.25.6 - transitivePeerDependencies: - - supports-color - '@babel/helper-simple-access@7.25.7': dependencies: '@babel/traverse': 7.25.7 - '@babel/types': 7.25.7 + '@babel/types': 7.25.8 transitivePeerDependencies: - supports-color '@babel/helper-skip-transparent-expression-wrappers@7.25.7': dependencies: '@babel/traverse': 7.25.7 - '@babel/types': 7.25.7 + '@babel/types': 7.25.8 transitivePeerDependencies: - supports-color - '@babel/helper-string-parser@7.24.8': {} - '@babel/helper-string-parser@7.25.7': {} - '@babel/helper-validator-identifier@7.24.7': {} - '@babel/helper-validator-identifier@7.25.7': {} - '@babel/helper-validator-option@7.24.8': {} - '@babel/helper-validator-option@7.25.7': {} - '@babel/helpers@7.25.6': - dependencies: - '@babel/template': 7.25.0 - '@babel/types': 7.25.6 - '@babel/helpers@7.25.7': dependencies: '@babel/template': 7.25.7 - '@babel/types': 7.25.7 - - '@babel/highlight@7.24.7': - dependencies: - '@babel/helper-validator-identifier': 7.24.7 - chalk: 2.4.2 - js-tokens: 4.0.0 - picocolors: 1.1.0 + '@babel/types': 7.25.8 '@babel/highlight@7.25.7': dependencies: '@babel/helper-validator-identifier': 7.25.7 chalk: 2.4.2 js-tokens: 4.0.0 - picocolors: 1.1.0 + picocolors: 1.1.1 - '@babel/parser@7.25.6': + '@babel/parser@7.25.8': dependencies: - '@babel/types': 7.25.6 + '@babel/types': 7.25.8 - '@babel/parser@7.25.7': + '@babel/plugin-proposal-decorators@7.25.7(@babel/core@7.25.8)': dependencies: - '@babel/types': 7.25.7 - - '@babel/plugin-proposal-decorators@7.25.7(@babel/core@7.25.7)': - dependencies: - '@babel/core': 7.25.7 - '@babel/helper-create-class-features-plugin': 7.25.7(@babel/core@7.25.7) + '@babel/core': 7.25.8 + '@babel/helper-create-class-features-plugin': 7.25.7(@babel/core@7.25.8) '@babel/helper-plugin-utils': 7.25.7 - '@babel/plugin-syntax-decorators': 7.25.7(@babel/core@7.25.7) + '@babel/plugin-syntax-decorators': 7.25.7(@babel/core@7.25.8) transitivePeerDependencies: - supports-color - '@babel/plugin-syntax-decorators@7.25.7(@babel/core@7.25.7)': + '@babel/plugin-syntax-decorators@7.25.7(@babel/core@7.25.8)': dependencies: - '@babel/core': 7.25.7 + '@babel/core': 7.25.8 '@babel/helper-plugin-utils': 7.25.7 - '@babel/plugin-syntax-import-attributes@7.25.7(@babel/core@7.25.7)': + '@babel/plugin-syntax-import-attributes@7.25.7(@babel/core@7.25.8)': dependencies: - '@babel/core': 7.25.7 + '@babel/core': 7.25.8 '@babel/helper-plugin-utils': 7.25.7 - '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.25.7)': + '@babel/plugin-syntax-import-meta@7.10.4(@babel/core@7.25.8)': dependencies: - '@babel/core': 7.25.7 + '@babel/core': 7.25.8 '@babel/helper-plugin-utils': 7.25.7 - '@babel/plugin-syntax-jsx@7.25.7(@babel/core@7.25.7)': + '@babel/plugin-syntax-jsx@7.25.7(@babel/core@7.25.8)': dependencies: - '@babel/core': 7.25.7 + '@babel/core': 7.25.8 '@babel/helper-plugin-utils': 7.25.7 - '@babel/plugin-syntax-typescript@7.25.7(@babel/core@7.25.7)': + '@babel/plugin-syntax-typescript@7.25.7(@babel/core@7.25.8)': dependencies: - '@babel/core': 7.25.7 + '@babel/core': 7.25.8 '@babel/helper-plugin-utils': 7.25.7 - '@babel/plugin-transform-typescript@7.25.7(@babel/core@7.25.7)': + '@babel/plugin-transform-react-jsx@7.25.7(@babel/core@7.25.8)': dependencies: - '@babel/core': 7.25.7 + '@babel/core': 7.25.8 '@babel/helper-annotate-as-pure': 7.25.7 - '@babel/helper-create-class-features-plugin': 7.25.7(@babel/core@7.25.7) + '@babel/helper-module-imports': 7.25.7 + '@babel/helper-plugin-utils': 7.25.7 + '@babel/plugin-syntax-jsx': 7.25.7(@babel/core@7.25.8) + '@babel/types': 7.25.8 + transitivePeerDependencies: + - supports-color + + '@babel/plugin-transform-typescript@7.25.7(@babel/core@7.25.8)': + dependencies: + '@babel/core': 7.25.8 + '@babel/helper-annotate-as-pure': 7.25.7 + '@babel/helper-create-class-features-plugin': 7.25.7(@babel/core@7.25.8) '@babel/helper-plugin-utils': 7.25.7 '@babel/helper-skip-transparent-expression-wrappers': 7.25.7 - '@babel/plugin-syntax-typescript': 7.25.7(@babel/core@7.25.7) + '@babel/plugin-syntax-typescript': 7.25.7(@babel/core@7.25.8) transitivePeerDependencies: - supports-color - '@babel/standalone@7.24.7': {} - - '@babel/standalone@7.25.7': {} - - '@babel/template@7.25.0': + '@babel/runtime@7.25.7': dependencies: - '@babel/code-frame': 7.24.7 - '@babel/parser': 7.25.6 - '@babel/types': 7.25.6 + regenerator-runtime: 0.14.1 + + '@babel/standalone@7.25.8': {} '@babel/template@7.25.7': dependencies: '@babel/code-frame': 7.25.7 - '@babel/parser': 7.25.7 - '@babel/types': 7.25.7 - - '@babel/traverse@7.25.6': - dependencies: - '@babel/code-frame': 7.24.7 - '@babel/generator': 7.25.6 - '@babel/parser': 7.25.6 - '@babel/template': 7.25.0 - '@babel/types': 7.25.6 - debug: 4.3.7 - globals: 11.12.0 - transitivePeerDependencies: - - supports-color + '@babel/parser': 7.25.8 + '@babel/types': 7.25.8 '@babel/traverse@7.25.7': dependencies: '@babel/code-frame': 7.25.7 '@babel/generator': 7.25.7 - '@babel/parser': 7.25.7 + '@babel/parser': 7.25.8 '@babel/template': 7.25.7 - '@babel/types': 7.25.7 + '@babel/types': 7.25.8 debug: 4.3.7 globals: 11.12.0 transitivePeerDependencies: - supports-color - '@babel/types@7.25.6': - dependencies: - '@babel/helper-string-parser': 7.24.8 - '@babel/helper-validator-identifier': 7.24.7 - to-fast-properties: 2.0.0 - - '@babel/types@7.25.7': + '@babel/types@7.25.8': dependencies: '@babel/helper-string-parser': 7.25.7 '@babel/helper-validator-identifier': 7.25.7 to-fast-properties: 2.0.0 - '@braw/async-computed@5.0.2(vue@3.5.11(typescript@5.6.2))': + '@braw/async-computed@5.0.2(vue@3.5.12(typescript@5.6.3))': dependencies: - vue: 3.5.11(typescript@5.6.2) - - '@braw/async-computed@5.0.2(vue@3.5.4(typescript@5.6.2))': - dependencies: - vue: 3.5.4(typescript@5.6.2) + vue: 3.5.12(typescript@5.6.3) '@cloudflare/kv-asset-handler@0.3.4': dependencies: mime: 3.0.0 - '@codemirror/autocomplete@6.17.0(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.33.0)(@lezer/common@1.2.1)': + '@codemirror/autocomplete@6.18.1(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.1)(@lezer/common@1.2.2)': dependencies: - '@codemirror/language': 6.10.2 + '@codemirror/language': 6.10.3 '@codemirror/state': 6.4.1 - '@codemirror/view': 6.33.0 - '@lezer/common': 1.2.1 + '@codemirror/view': 6.34.1 + '@lezer/common': 1.2.2 - '@codemirror/commands@6.6.1': + '@codemirror/commands@6.7.0': dependencies: - '@codemirror/language': 6.10.2 + '@codemirror/language': 6.10.3 '@codemirror/state': 6.4.1 - '@codemirror/view': 6.33.0 - '@lezer/common': 1.2.1 + '@codemirror/view': 6.34.1 + '@lezer/common': 1.2.2 - '@codemirror/lang-css@6.2.1(@codemirror/view@6.33.0)': + '@codemirror/lang-css@6.3.0(@codemirror/view@6.34.1)': dependencies: - '@codemirror/autocomplete': 6.17.0(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.33.0)(@lezer/common@1.2.1) - '@codemirror/language': 6.10.2 + '@codemirror/autocomplete': 6.18.1(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.1)(@lezer/common@1.2.2) + '@codemirror/language': 6.10.3 '@codemirror/state': 6.4.1 - '@lezer/common': 1.2.1 - '@lezer/css': 1.1.8 + '@lezer/common': 1.2.2 + '@lezer/css': 1.1.9 transitivePeerDependencies: - '@codemirror/view' '@codemirror/lang-html@6.4.9': dependencies: - '@codemirror/autocomplete': 6.17.0(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.33.0)(@lezer/common@1.2.1) - '@codemirror/lang-css': 6.2.1(@codemirror/view@6.33.0) + '@codemirror/autocomplete': 6.18.1(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.1)(@lezer/common@1.2.2) + '@codemirror/lang-css': 6.3.0(@codemirror/view@6.34.1) '@codemirror/lang-javascript': 6.2.2 - '@codemirror/language': 6.10.2 + '@codemirror/language': 6.10.3 '@codemirror/state': 6.4.1 - '@codemirror/view': 6.33.0 - '@lezer/common': 1.2.1 - '@lezer/css': 1.1.8 + '@codemirror/view': 6.34.1 + '@lezer/common': 1.2.2 + '@lezer/css': 1.1.9 '@lezer/html': 1.3.10 '@codemirror/lang-javascript@6.2.2': dependencies: - '@codemirror/autocomplete': 6.17.0(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.33.0)(@lezer/common@1.2.1) - '@codemirror/language': 6.10.2 - '@codemirror/lint': 6.8.1 + '@codemirror/autocomplete': 6.18.1(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.1)(@lezer/common@1.2.2) + '@codemirror/language': 6.10.3 + '@codemirror/lint': 6.8.2 '@codemirror/state': 6.4.1 - '@codemirror/view': 6.33.0 - '@lezer/common': 1.2.1 - '@lezer/javascript': 1.4.17 + '@codemirror/view': 6.34.1 + '@lezer/common': 1.2.2 + '@lezer/javascript': 1.4.19 - '@codemirror/lang-markdown@6.2.5': + '@codemirror/lang-markdown@6.3.0': dependencies: - '@codemirror/autocomplete': 6.17.0(@codemirror/language@6.10.2)(@codemirror/state@6.4.1)(@codemirror/view@6.33.0)(@lezer/common@1.2.1) + '@codemirror/autocomplete': 6.18.1(@codemirror/language@6.10.3)(@codemirror/state@6.4.1)(@codemirror/view@6.34.1)(@lezer/common@1.2.2) '@codemirror/lang-html': 6.4.9 - '@codemirror/language': 6.10.2 + '@codemirror/language': 6.10.3 '@codemirror/state': 6.4.1 - '@codemirror/view': 6.33.0 - '@lezer/common': 1.2.1 - '@lezer/markdown': 1.3.0 + '@codemirror/view': 6.34.1 + '@lezer/common': 1.2.2 + '@lezer/markdown': 1.3.1 - '@codemirror/language@6.10.2': + '@codemirror/language@6.10.3': dependencies: '@codemirror/state': 6.4.1 - '@codemirror/view': 6.33.0 - '@lezer/common': 1.2.1 - '@lezer/highlight': 1.2.0 - '@lezer/lr': 1.4.1 + '@codemirror/view': 6.34.1 + '@lezer/common': 1.2.2 + '@lezer/highlight': 1.2.1 + '@lezer/lr': 1.4.2 style-mod: 4.1.2 - '@codemirror/lint@6.8.1': + '@codemirror/lint@6.8.2': dependencies: '@codemirror/state': 6.4.1 - '@codemirror/view': 6.33.0 + '@codemirror/view': 6.34.1 crelt: 1.0.6 '@codemirror/state@6.4.1': {} - '@codemirror/view@6.33.0': + '@codemirror/view@6.34.1': dependencies: '@codemirror/state': 6.4.1 style-mod: 4.1.2 w3c-keyname: 2.2.8 - '@es-joy/jsdoccomment@0.48.0': + '@ctrl/tinycolor@4.1.0': {} + + '@emmetio/abbreviation@2.3.3': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-abbreviation@2.1.8': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/css-parser@0.4.0': + dependencies: + '@emmetio/stream-reader': 2.2.0 + '@emmetio/stream-reader-utils': 0.1.0 + + '@emmetio/html-matcher@1.3.0': + dependencies: + '@emmetio/scanner': 1.0.4 + + '@emmetio/scanner@1.0.4': {} + + '@emmetio/stream-reader-utils@0.1.0': {} + + '@emmetio/stream-reader@2.2.0': {} + + '@emnapi/runtime@1.3.1': + dependencies: + tslib: 2.8.0 + optional: true + + '@es-joy/jsdoccomment@0.49.0': dependencies: comment-parser: 1.4.1 esquery: 1.6.0 @@ -6727,19 +7927,21 @@ snapshots: '@esbuild/win32-x64@0.23.1': optional: true - '@eslint-community/eslint-utils@4.4.0(eslint@8.57.0)': + '@eslint-community/eslint-utils@4.4.0(eslint@8.57.1)': dependencies: - eslint: 8.57.0 + eslint: 8.57.1 eslint-visitor-keys: 3.4.3 - '@eslint-community/eslint-utils@4.4.0(eslint@9.10.0(jiti@2.1.2))': + '@eslint-community/eslint-utils@4.4.0(eslint@9.12.0(jiti@2.3.3))': dependencies: - eslint: 9.10.0(jiti@2.1.2) + eslint: 9.12.0(jiti@2.3.3) eslint-visitor-keys: 3.4.3 - '@eslint-community/regexpp@4.11.0': {} + '@eslint-community/regexpp@4.11.1': {} - '@eslint/compat@1.1.1': {} + '@eslint/compat@1.2.0(eslint@9.12.0(jiti@2.3.3))': + optionalDependencies: + eslint: 9.12.0(jiti@2.3.3) '@eslint/config-array@0.18.0': dependencies: @@ -6749,6 +7951,8 @@ snapshots: transitivePeerDependencies: - supports-color + '@eslint/core@0.6.0': {} + '@eslint/eslintrc@2.1.4': dependencies: ajv: 6.12.6 @@ -6767,7 +7971,7 @@ snapshots: dependencies: ajv: 6.12.6 debug: 4.3.7 - espree: 10.1.0 + espree: 10.2.0 globals: 14.0.0 ignore: 5.3.2 import-fresh: 3.3.0 @@ -6777,23 +7981,48 @@ snapshots: transitivePeerDependencies: - supports-color - '@eslint/js@8.57.0': {} + '@eslint/js@8.57.1': {} - '@eslint/js@9.10.0': {} + '@eslint/js@9.12.0': {} '@eslint/object-schema@2.1.4': {} - '@eslint/plugin-kit@0.1.0': + '@eslint/plugin-kit@0.2.0': dependencies: levn: 0.4.1 + '@expressive-code/core@0.35.6': + dependencies: + '@ctrl/tinycolor': 4.1.0 + hast-util-select: 6.0.3 + hast-util-to-html: 9.0.3 + hast-util-to-text: 4.0.2 + hastscript: 9.0.0 + postcss: 8.4.47 + postcss-nested: 6.2.0(postcss@8.4.47) + unist-util-visit: 5.0.0 + unist-util-visit-parents: 6.0.1 + + '@expressive-code/plugin-frames@0.35.6': + dependencies: + '@expressive-code/core': 0.35.6 + + '@expressive-code/plugin-shiki@0.35.6': + dependencies: + '@expressive-code/core': 0.35.6 + shiki: 1.22.0 + + '@expressive-code/plugin-text-markers@0.35.6': + dependencies: + '@expressive-code/core': 0.35.6 + '@fastify/busboy@2.1.1': {} '@floating-ui/core@0.3.1': {} - '@floating-ui/core@1.6.7': + '@floating-ui/core@1.6.8': dependencies: - '@floating-ui/utils': 0.2.7 + '@floating-ui/utils': 0.2.8 '@floating-ui/dom@0.1.10': dependencies: @@ -6801,144 +8030,118 @@ snapshots: '@floating-ui/dom@1.1.1': dependencies: - '@floating-ui/core': 1.6.7 + '@floating-ui/core': 1.6.8 - '@floating-ui/utils@0.2.7': {} + '@floating-ui/utils@0.2.8': {} - '@formatjs/cli-lib@6.4.2(@vue/compiler-core@3.5.11)(vue@3.5.11(typescript@5.6.2))': + '@formatjs/cli-lib@6.4.5(@vue/compiler-core@3.5.12)(vue@3.5.12(typescript@5.6.3))': dependencies: - '@formatjs/icu-messageformat-parser': 2.7.8 - '@formatjs/ts-transformer': 3.13.14 - '@types/estree': 1.0.5 + '@formatjs/icu-messageformat-parser': 2.7.10 + '@formatjs/icu-skeleton-parser': 1.8.4 + '@formatjs/ts-transformer': 3.13.16 + '@types/estree': 1.0.6 '@types/fs-extra': 9.0.13 '@types/json-stable-stringify': 1.0.36 - '@types/node': 17.0.45 + '@types/node': 18.19.55 chalk: 4.1.2 commander: 8.3.0 fast-glob: 3.3.2 fs-extra: 10.1.0 json-stable-stringify: 1.1.1 loud-rejection: 2.2.0 - tslib: 2.7.0 - typescript: 5.6.2 + tslib: 2.8.0 + typescript: 5.6.3 optionalDependencies: - '@vue/compiler-core': 3.5.11 - vue: 3.5.11(typescript@5.6.2) + '@vue/compiler-core': 3.5.12 + vue: 3.5.12(typescript@5.6.3) transitivePeerDependencies: - ts-jest - '@formatjs/cli-lib@6.4.2(@vue/compiler-core@3.5.11)(vue@3.5.4(typescript@5.6.2))': - dependencies: - '@formatjs/icu-messageformat-parser': 2.7.8 - '@formatjs/ts-transformer': 3.13.14 - '@types/estree': 1.0.5 - '@types/fs-extra': 9.0.13 - '@types/json-stable-stringify': 1.0.36 - '@types/node': 17.0.45 - chalk: 4.1.2 - commander: 8.3.0 - fast-glob: 3.3.2 - fs-extra: 10.1.0 - json-stable-stringify: 1.1.1 - loud-rejection: 2.2.0 - tslib: 2.7.0 - typescript: 5.6.2 + '@formatjs/cli@6.2.15(@vue/compiler-core@3.5.12)(vue@3.5.12(typescript@5.6.3))': optionalDependencies: - '@vue/compiler-core': 3.5.11 - vue: 3.5.4(typescript@5.6.2) - transitivePeerDependencies: - - ts-jest - - '@formatjs/cli@6.2.12(@vue/compiler-core@3.5.11)(vue@3.5.11(typescript@5.6.2))': - optionalDependencies: - '@vue/compiler-core': 3.5.11 - vue: 3.5.11(typescript@5.6.2) - - '@formatjs/cli@6.2.12(@vue/compiler-core@3.5.11)(vue@3.5.4(typescript@5.6.2))': - optionalDependencies: - '@vue/compiler-core': 3.5.11 - vue: 3.5.4(typescript@5.6.2) + '@vue/compiler-core': 3.5.12 + vue: 3.5.12(typescript@5.6.3) '@formatjs/ecma402-abstract@1.18.3': dependencies: '@formatjs/intl-localematcher': 0.5.4 - tslib: 2.6.3 + tslib: 2.8.0 - '@formatjs/ecma402-abstract@2.0.0': + '@formatjs/ecma402-abstract@2.2.0': dependencies: - '@formatjs/intl-localematcher': 0.5.4 - tslib: 2.6.3 + '@formatjs/fast-memoize': 2.2.1 + '@formatjs/intl-localematcher': 0.5.5 + tslib: 2.8.0 - '@formatjs/fast-memoize@2.2.0': + '@formatjs/fast-memoize@2.2.1': dependencies: - tslib: 2.6.3 + tslib: 2.8.0 - '@formatjs/icu-messageformat-parser@2.7.8': + '@formatjs/icu-messageformat-parser@2.7.10': dependencies: - '@formatjs/ecma402-abstract': 2.0.0 - '@formatjs/icu-skeleton-parser': 1.8.2 - tslib: 2.6.3 + '@formatjs/ecma402-abstract': 2.2.0 + '@formatjs/icu-skeleton-parser': 1.8.4 + tslib: 2.8.0 - '@formatjs/icu-skeleton-parser@1.8.2': + '@formatjs/icu-skeleton-parser@1.8.4': dependencies: - '@formatjs/ecma402-abstract': 2.0.0 - tslib: 2.6.3 + '@formatjs/ecma402-abstract': 2.2.0 + tslib: 2.8.0 - '@formatjs/intl-displaynames@6.6.8': + '@formatjs/intl-displaynames@6.6.10': dependencies: - '@formatjs/ecma402-abstract': 2.0.0 - '@formatjs/intl-localematcher': 0.5.4 - tslib: 2.6.3 + '@formatjs/ecma402-abstract': 2.2.0 + '@formatjs/intl-localematcher': 0.5.5 + tslib: 2.8.0 - '@formatjs/intl-listformat@7.5.7': + '@formatjs/intl-listformat@7.5.9': dependencies: - '@formatjs/ecma402-abstract': 2.0.0 - '@formatjs/intl-localematcher': 0.5.4 - tslib: 2.6.3 + '@formatjs/ecma402-abstract': 2.2.0 + '@formatjs/intl-localematcher': 0.5.5 + tslib: 2.8.0 '@formatjs/intl-localematcher@0.4.2': dependencies: - tslib: 2.6.3 + tslib: 2.8.0 '@formatjs/intl-localematcher@0.5.4': dependencies: - tslib: 2.6.3 + tslib: 2.8.0 - '@formatjs/intl@2.10.4(typescript@5.6.2)': + '@formatjs/intl-localematcher@0.5.5': dependencies: - '@formatjs/ecma402-abstract': 2.0.0 - '@formatjs/fast-memoize': 2.2.0 - '@formatjs/icu-messageformat-parser': 2.7.8 - '@formatjs/intl-displaynames': 6.6.8 - '@formatjs/intl-listformat': 7.5.7 - intl-messageformat: 10.5.14 - tslib: 2.6.3 + tslib: 2.8.0 + + '@formatjs/intl@2.10.8(typescript@5.6.3)': + dependencies: + '@formatjs/ecma402-abstract': 2.2.0 + '@formatjs/fast-memoize': 2.2.1 + '@formatjs/icu-messageformat-parser': 2.7.10 + '@formatjs/intl-displaynames': 6.6.10 + '@formatjs/intl-listformat': 7.5.9 + intl-messageformat: 10.7.0 + tslib: 2.8.0 optionalDependencies: - typescript: 5.6.2 + typescript: 5.6.3 - '@formatjs/intl@2.10.5(typescript@5.6.2)': + '@formatjs/ts-transformer@3.13.16': dependencies: - '@formatjs/ecma402-abstract': 2.0.0 - '@formatjs/fast-memoize': 2.2.0 - '@formatjs/icu-messageformat-parser': 2.7.8 - '@formatjs/intl-displaynames': 6.6.8 - '@formatjs/intl-listformat': 7.5.7 - intl-messageformat: 10.5.14 - tslib: 2.7.0 - optionalDependencies: - typescript: 5.6.2 - - '@formatjs/ts-transformer@3.13.14': - dependencies: - '@formatjs/icu-messageformat-parser': 2.7.8 + '@formatjs/icu-messageformat-parser': 2.7.10 '@types/json-stable-stringify': 1.0.36 - '@types/node': 17.0.45 + '@types/node': 18.19.55 chalk: 4.1.2 json-stable-stringify: 1.1.1 - tslib: 2.7.0 - typescript: 5.6.2 + tslib: 2.8.0 + typescript: 5.6.3 - '@humanwhocodes/config-array@0.11.14': + '@humanfs/core@0.19.0': {} + + '@humanfs/node@0.16.5': + dependencies: + '@humanfs/core': 0.19.0 + '@humanwhocodes/retry': 0.3.1 + + '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 debug: 4.3.7 @@ -6948,9 +8151,86 @@ snapshots: '@humanwhocodes/module-importer@1.0.1': {} + '@humanwhocodes/momoa@2.0.4': {} + '@humanwhocodes/object-schema@2.0.3': {} - '@humanwhocodes/retry@0.3.0': {} + '@humanwhocodes/retry@0.3.1': {} + + '@img/sharp-darwin-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-arm64': 1.0.4 + optional: true + + '@img/sharp-darwin-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-darwin-x64': 1.0.4 + optional: true + + '@img/sharp-libvips-darwin-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-darwin-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linux-arm@1.0.5': + optional: true + + '@img/sharp-libvips-linux-s390x@1.0.4': + optional: true + + '@img/sharp-libvips-linux-x64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-arm64@1.0.4': + optional: true + + '@img/sharp-libvips-linuxmusl-x64@1.0.4': + optional: true + + '@img/sharp-linux-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm64': 1.0.4 + optional: true + + '@img/sharp-linux-arm@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-arm': 1.0.5 + optional: true + + '@img/sharp-linux-s390x@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-s390x': 1.0.4 + optional: true + + '@img/sharp-linux-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linux-x64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-arm64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + optional: true + + '@img/sharp-linuxmusl-x64@0.33.5': + optionalDependencies: + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + optional: true + + '@img/sharp-wasm32@0.33.5': + dependencies: + '@emnapi/runtime': 1.3.1 + optional: true + + '@img/sharp-win32-ia32@0.33.5': + optional: true + + '@img/sharp-win32-x64@0.33.5': + optional: true '@ioredis/commands@1.2.0': {} @@ -6966,7 +8246,7 @@ snapshots: '@jridgewell/gen-mapping@0.3.5': dependencies: '@jridgewell/set-array': 1.2.1 - '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/sourcemap-codec': 1.5.0 '@jridgewell/trace-mapping': 0.3.25 '@jridgewell/resolve-uri@3.1.2': {} @@ -6978,14 +8258,14 @@ snapshots: '@jridgewell/gen-mapping': 0.3.5 '@jridgewell/trace-mapping': 0.3.25 - '@jridgewell/sourcemap-codec@1.4.15': {} - '@jridgewell/sourcemap-codec@1.5.0': {} '@jridgewell/trace-mapping@0.3.25': dependencies: '@jridgewell/resolve-uri': 3.1.2 - '@jridgewell/sourcemap-codec': 1.4.15 + '@jridgewell/sourcemap-codec': 1.5.0 + + '@jsdevtools/ono@7.1.3': {} '@kwsites/file-exists@1.1.1': dependencies: @@ -6995,38 +8275,38 @@ snapshots: '@kwsites/promise-deferred@1.1.1': {} - '@lezer/common@1.2.1': {} + '@lezer/common@1.2.2': {} - '@lezer/css@1.1.8': + '@lezer/css@1.1.9': dependencies: - '@lezer/common': 1.2.1 - '@lezer/highlight': 1.2.0 - '@lezer/lr': 1.4.1 + '@lezer/common': 1.2.2 + '@lezer/highlight': 1.2.1 + '@lezer/lr': 1.4.2 - '@lezer/highlight@1.2.0': + '@lezer/highlight@1.2.1': dependencies: - '@lezer/common': 1.2.1 + '@lezer/common': 1.2.2 '@lezer/html@1.3.10': dependencies: - '@lezer/common': 1.2.1 - '@lezer/highlight': 1.2.0 - '@lezer/lr': 1.4.1 + '@lezer/common': 1.2.2 + '@lezer/highlight': 1.2.1 + '@lezer/lr': 1.4.2 - '@lezer/javascript@1.4.17': + '@lezer/javascript@1.4.19': dependencies: - '@lezer/common': 1.2.1 - '@lezer/highlight': 1.2.0 - '@lezer/lr': 1.4.1 + '@lezer/common': 1.2.2 + '@lezer/highlight': 1.2.1 + '@lezer/lr': 1.4.2 - '@lezer/lr@1.4.1': + '@lezer/lr@1.4.2': dependencies: - '@lezer/common': 1.2.1 + '@lezer/common': 1.2.2 - '@lezer/markdown@1.3.0': + '@lezer/markdown@1.3.1': dependencies: - '@lezer/common': 1.2.1 - '@lezer/highlight': 1.2.0 + '@lezer/common': 1.2.2 + '@lezer/highlight': 1.2.1 '@ltd/j-toml@1.38.0': {} @@ -7045,6 +8325,36 @@ snapshots: - encoding - supports-color + '@mdx-js/mdx@3.1.0(acorn@8.13.0)': + dependencies: + '@types/estree': 1.0.6 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdx': 2.0.13 + collapse-white-space: 2.1.0 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-util-scope: 1.0.0 + estree-walker: 3.0.3 + hast-util-to-jsx-runtime: 2.3.2 + markdown-extensions: 2.0.0 + recma-build-jsx: 1.0.0 + recma-jsx: 1.0.0(acorn@8.13.0) + recma-stringify: 1.0.0 + rehype-recma: 1.0.0 + remark-mdx: 3.1.0 + remark-parse: 11.0.0 + remark-rehype: 11.1.1 + source-map: 0.7.4 + unified: 11.0.5 + unist-util-position-from-estree: 2.0.0 + unist-util-stringify-position: 4.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + transitivePeerDependencies: + - acorn + - supports-color + '@netlify/functions@2.8.2': dependencies: '@netlify/serverless-functions-api': 1.26.1 @@ -7068,21 +8378,23 @@ snapshots: '@nodelib/fs.scandir': 2.1.5 fastq: 1.17.1 + '@nolyfill/is-core-module@1.0.39': {} + '@nuxt/devalue@2.0.2': {} - '@nuxt/devtools-kit@1.5.2(magicast@0.3.5)(rollup@4.24.0)(vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1))(webpack-sources@3.2.3)': + '@nuxt/devtools-kit@1.6.0(magicast@0.3.5)(rollup@4.24.0)(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1))': dependencies: - '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.24.0)(webpack-sources@3.2.3) - '@nuxt/schema': 3.13.2(rollup@4.24.0)(webpack-sources@3.2.3) + '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.24.0) + '@nuxt/schema': 3.13.2(rollup@4.24.0) execa: 7.2.0 - vite: 5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1) + vite: 5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1) transitivePeerDependencies: - magicast - rollup - supports-color - webpack-sources - '@nuxt/devtools-wizard@1.5.2': + '@nuxt/devtools-wizard@1.6.0': dependencies: consola: 3.2.3 diff: 7.0.0 @@ -7090,20 +8402,20 @@ snapshots: global-directory: 4.0.1 magicast: 0.3.5 pathe: 1.1.2 - pkg-types: 1.2.0 + pkg-types: 1.2.1 prompts: 2.4.2 rc9: 2.1.2 semver: 7.6.3 - '@nuxt/devtools@1.5.2(rollup@4.24.0)(vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1))(vue@3.5.11(typescript@5.6.2))(webpack-sources@3.2.3)': + '@nuxt/devtools@1.6.0(rollup@4.24.0)(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1))(vue@3.5.12(typescript@5.6.3))': dependencies: '@antfu/utils': 0.7.10 - '@nuxt/devtools-kit': 1.5.2(magicast@0.3.5)(rollup@4.24.0)(vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1))(webpack-sources@3.2.3) - '@nuxt/devtools-wizard': 1.5.2 - '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.24.0)(webpack-sources@3.2.3) - '@vue/devtools-core': 7.4.4(vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1))(vue@3.5.11(typescript@5.6.2)) + '@nuxt/devtools-kit': 1.6.0(magicast@0.3.5)(rollup@4.24.0)(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1)) + '@nuxt/devtools-wizard': 1.6.0 + '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.24.0) + '@vue/devtools-core': 7.4.4(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1))(vue@3.5.12(typescript@5.6.3)) '@vue/devtools-kit': 7.4.4 - birpc: 0.2.17 + birpc: 0.2.19 consola: 3.2.3 cronstrue: 2.50.0 destr: 2.0.3 @@ -7122,17 +8434,17 @@ snapshots: ohash: 1.1.4 pathe: 1.1.2 perfect-debounce: 1.0.0 - pkg-types: 1.2.0 + pkg-types: 1.2.1 rc9: 2.1.2 scule: 1.3.0 semver: 7.6.3 simple-git: 3.27.0 sirv: 2.0.4 tinyglobby: 0.2.9 - unimport: 3.13.1(rollup@4.24.0)(webpack-sources@3.2.3) - vite: 5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1) - vite-plugin-inspect: 0.8.7(@nuxt/kit@3.13.2(magicast@0.3.5)(rollup@4.24.0)(webpack-sources@3.2.3))(rollup@4.24.0)(vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1)) - vite-plugin-vue-inspector: 5.1.3(vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1)) + unimport: 3.13.1(rollup@4.24.0) + vite: 5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1) + vite-plugin-inspect: 0.8.7(@nuxt/kit@3.13.2(magicast@0.3.5)(rollup@4.24.0))(rollup@4.24.0)(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1)) + vite-plugin-vue-inspector: 5.1.3(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1)) which: 3.0.1 ws: 8.18.0 transitivePeerDependencies: @@ -7143,41 +8455,41 @@ snapshots: - vue - webpack-sources - '@nuxt/eslint-config@0.5.7(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2)': + '@nuxt/eslint-config@0.5.7(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3)': dependencies: - '@eslint/js': 9.10.0 - '@nuxt/eslint-plugin': 0.5.7(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) - '@stylistic/eslint-plugin': 2.8.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) - '@typescript-eslint/eslint-plugin': 8.5.0(@typescript-eslint/parser@8.5.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) - '@typescript-eslint/parser': 8.5.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) - eslint: 9.10.0(jiti@2.1.2) - eslint-config-flat-gitignore: 0.3.0(eslint@9.10.0(jiti@2.1.2)) + '@eslint/js': 9.12.0 + '@nuxt/eslint-plugin': 0.5.7(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) + '@stylistic/eslint-plugin': 2.9.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) + '@typescript-eslint/eslint-plugin': 8.9.0(@typescript-eslint/parser@8.9.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) + '@typescript-eslint/parser': 8.9.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) + eslint: 9.12.0(jiti@2.3.3) + eslint-config-flat-gitignore: 0.3.0(eslint@9.12.0(jiti@2.3.3)) eslint-flat-config-utils: 0.4.0 - eslint-plugin-import-x: 4.2.1(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) - eslint-plugin-jsdoc: 50.2.2(eslint@9.10.0(jiti@2.1.2)) - eslint-plugin-regexp: 2.6.0(eslint@9.10.0(jiti@2.1.2)) - eslint-plugin-unicorn: 55.0.0(eslint@9.10.0(jiti@2.1.2)) - eslint-plugin-vue: 9.28.0(eslint@9.10.0(jiti@2.1.2)) - globals: 15.9.0 + eslint-plugin-import-x: 4.3.1(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) + eslint-plugin-jsdoc: 50.4.1(eslint@9.12.0(jiti@2.3.3)) + eslint-plugin-regexp: 2.6.0(eslint@9.12.0(jiti@2.3.3)) + eslint-plugin-unicorn: 55.0.0(eslint@9.12.0(jiti@2.3.3)) + eslint-plugin-vue: 9.29.0(eslint@9.12.0(jiti@2.3.3)) + globals: 15.11.0 local-pkg: 0.5.0 pathe: 1.1.2 - vue-eslint-parser: 9.4.3(eslint@9.10.0(jiti@2.1.2)) + vue-eslint-parser: 9.4.3(eslint@9.12.0(jiti@2.3.3)) transitivePeerDependencies: - supports-color - typescript - '@nuxt/eslint-plugin@0.5.7(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2)': + '@nuxt/eslint-plugin@0.5.7(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3)': dependencies: - '@typescript-eslint/types': 8.5.0 - '@typescript-eslint/utils': 8.5.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) - eslint: 9.10.0(jiti@2.1.2) + '@typescript-eslint/types': 8.9.0 + '@typescript-eslint/utils': 8.9.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) + eslint: 9.12.0(jiti@2.3.3) transitivePeerDependencies: - supports-color - typescript - '@nuxt/kit@3.13.1(magicast@0.3.5)(rollup@4.24.0)(webpack-sources@3.2.3)': + '@nuxt/kit@3.13.2(magicast@0.3.5)(rollup@4.24.0)': dependencies: - '@nuxt/schema': 3.13.1(rollup@4.24.0)(webpack-sources@3.2.3) + '@nuxt/schema': 3.13.2(rollup@4.24.0) c12: 1.11.2(magicast@0.3.5) consola: 3.2.3 defu: 6.1.4 @@ -7188,90 +8500,43 @@ snapshots: jiti: 1.21.6 klona: 2.0.6 knitwork: 1.1.0 - mlly: 1.7.1 + mlly: 1.7.2 pathe: 1.1.2 - pkg-types: 1.2.0 + pkg-types: 1.2.1 scule: 1.3.0 semver: 7.6.3 ufo: 1.5.4 - unctx: 2.3.1(webpack-sources@3.2.3) - unimport: 3.11.1(rollup@4.24.0)(webpack-sources@3.2.3) - untyped: 1.4.2 + unctx: 2.3.1 + unimport: 3.13.1(rollup@4.24.0) + untyped: 1.5.1 transitivePeerDependencies: - magicast - rollup - supports-color - webpack-sources - '@nuxt/kit@3.13.2(magicast@0.3.5)(rollup@4.24.0)(webpack-sources@3.2.3)': - dependencies: - '@nuxt/schema': 3.13.2(rollup@4.24.0)(webpack-sources@3.2.3) - c12: 1.11.2(magicast@0.3.5) - consola: 3.2.3 - defu: 6.1.4 - destr: 2.0.3 - globby: 14.0.2 - hash-sum: 2.0.0 - ignore: 5.3.2 - jiti: 1.21.6 - klona: 2.0.6 - knitwork: 1.1.0 - mlly: 1.7.1 - pathe: 1.1.2 - pkg-types: 1.2.0 - scule: 1.3.0 - semver: 7.6.3 - ufo: 1.5.4 - unctx: 2.3.1(webpack-sources@3.2.3) - unimport: 3.13.1(rollup@4.24.0)(webpack-sources@3.2.3) - untyped: 1.5.0 - transitivePeerDependencies: - - magicast - - rollup - - supports-color - - webpack-sources - - '@nuxt/schema@3.13.1(rollup@4.24.0)(webpack-sources@3.2.3)': + '@nuxt/schema@3.13.2(rollup@4.24.0)': dependencies: compatx: 0.1.8 consola: 3.2.3 defu: 6.1.4 hookable: 5.5.3 pathe: 1.1.2 - pkg-types: 1.2.0 + pkg-types: 1.2.1 scule: 1.3.0 std-env: 3.7.0 ufo: 1.5.4 uncrypto: 0.1.3 - unimport: 3.11.1(rollup@4.24.0)(webpack-sources@3.2.3) - untyped: 1.4.2 + unimport: 3.13.1(rollup@4.24.0) + untyped: 1.5.1 transitivePeerDependencies: - rollup - supports-color - webpack-sources - '@nuxt/schema@3.13.2(rollup@4.24.0)(webpack-sources@3.2.3)': + '@nuxt/telemetry@2.6.0(magicast@0.3.5)(rollup@4.24.0)': dependencies: - compatx: 0.1.8 - consola: 3.2.3 - defu: 6.1.4 - hookable: 5.5.3 - pathe: 1.1.2 - pkg-types: 1.2.0 - scule: 1.3.0 - std-env: 3.7.0 - ufo: 1.5.4 - uncrypto: 0.1.3 - unimport: 3.13.1(rollup@4.24.0)(webpack-sources@3.2.3) - untyped: 1.5.0 - transitivePeerDependencies: - - rollup - - supports-color - - webpack-sources - - '@nuxt/telemetry@2.6.0(magicast@0.3.5)(rollup@4.24.0)(webpack-sources@3.2.3)': - dependencies: - '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.24.0)(webpack-sources@3.2.3) + '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.24.0) ci-info: 4.0.0 consola: 3.2.3 create-require: 1.1.1 @@ -7283,8 +8548,8 @@ snapshots: jiti: 1.21.6 mri: 1.2.0 nanoid: 5.0.7 - ofetch: 1.4.0 - package-manager-detector: 0.2.0 + ofetch: 1.4.1 + package-manager-detector: 0.2.2 parse-git-config: 3.0.0 pathe: 1.1.2 rc9: 2.1.2 @@ -7295,12 +8560,12 @@ snapshots: - supports-color - webpack-sources - '@nuxt/vite-builder@3.13.2(@types/node@20.16.5)(eslint@8.57.0)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.24.0)(sass@1.78.0)(terser@5.34.1)(typescript@5.6.2)(vue-tsc@2.1.6(typescript@5.6.2))(vue@3.5.11(typescript@5.6.2))(webpack-sources@3.2.3)': + '@nuxt/vite-builder@3.13.2(@types/node@20.16.11)(eslint@8.57.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.24.0)(sass@1.79.5)(terser@5.34.1)(typescript@5.6.3)(vue-tsc@2.1.6(typescript@5.6.3))(vue@3.5.12(typescript@5.6.3))': dependencies: - '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.24.0)(webpack-sources@3.2.3) + '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.24.0) '@rollup/plugin-replace': 5.0.7(rollup@4.24.0) - '@vitejs/plugin-vue': 5.1.4(vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1))(vue@3.5.11(typescript@5.6.2)) - '@vitejs/plugin-vue-jsx': 4.0.1(vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1))(vue@3.5.11(typescript@5.6.2)) + '@vitejs/plugin-vue': 5.1.4(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1))(vue@3.5.12(typescript@5.6.3)) + '@vitejs/plugin-vue-jsx': 4.0.1(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1))(vue@3.5.12(typescript@5.6.3)) autoprefixer: 10.4.20(postcss@8.4.47) clear: 0.1.0 consola: 3.2.3 @@ -7313,23 +8578,23 @@ snapshots: get-port-please: 3.1.2 h3: 1.13.0 knitwork: 1.1.0 - magic-string: 0.30.11 - mlly: 1.7.1 + magic-string: 0.30.12 + mlly: 1.7.2 ohash: 1.1.4 pathe: 1.1.2 perfect-debounce: 1.0.0 - pkg-types: 1.2.0 + pkg-types: 1.2.1 postcss: 8.4.47 rollup-plugin-visualizer: 5.12.0(rollup@4.24.0) std-env: 3.7.0 strip-literal: 2.1.0 ufo: 1.5.4 unenv: 1.10.0 - unplugin: 1.14.1(webpack-sources@3.2.3) - vite: 5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1) - vite-node: 2.1.2(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1) - vite-plugin-checker: 0.8.0(eslint@8.57.0)(optionator@0.9.4)(typescript@5.6.2)(vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1))(vue-tsc@2.1.6(typescript@5.6.2)) - vue: 3.5.11(typescript@5.6.2) + unplugin: 1.14.1 + vite: 5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1) + vite-node: 2.1.3(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1) + vite-plugin-checker: 0.8.0(eslint@8.57.1)(optionator@0.9.4)(typescript@5.6.3)(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1))(vue-tsc@2.1.6(typescript@5.6.3)) + vue: 3.5.12(typescript@5.6.3) vue-bundle-renderer: 2.1.1 transitivePeerDependencies: - '@biomejs/biome' @@ -7354,31 +8619,32 @@ snapshots: - vue-tsc - webpack-sources - '@nuxtjs/eslint-config-typescript@12.1.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2)': + '@nuxtjs/eslint-config-typescript@12.1.0(eslint-plugin-import-x@4.3.1(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3)': dependencies: - '@nuxtjs/eslint-config': 12.0.0(@typescript-eslint/parser@6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint-plugin-import@2.29.1)(eslint@9.10.0(jiti@2.1.2)))(eslint@9.10.0(jiti@2.1.2)) - '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) - '@typescript-eslint/parser': 6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) - eslint: 9.10.0(jiti@2.1.2) - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint-plugin-import@2.29.1)(eslint@9.10.0(jiti@2.1.2)) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.16.1(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint@9.10.0(jiti@2.1.2)) - eslint-plugin-vue: 9.27.0(eslint@9.10.0(jiti@2.1.2)) + '@nuxtjs/eslint-config': 12.0.0(@typescript-eslint/parser@6.21.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3)(eslint@9.12.0(jiti@2.3.3)) + '@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) + '@typescript-eslint/parser': 6.21.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) + eslint: 9.12.0(jiti@2.3.3) + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@6.21.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint-plugin-import-x@4.3.1(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint-plugin-import@2.31.0)(eslint@9.12.0(jiti@2.3.3)) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.18.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.12.0(jiti@2.3.3)) + eslint-plugin-vue: 9.29.0(eslint@9.12.0(jiti@2.3.3)) transitivePeerDependencies: - eslint-import-resolver-node - eslint-import-resolver-webpack + - eslint-plugin-import-x - supports-color - typescript - '@nuxtjs/eslint-config@12.0.0(@typescript-eslint/parser@6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint-plugin-import@2.29.1)(eslint@9.10.0(jiti@2.1.2)))(eslint@9.10.0(jiti@2.1.2))': + '@nuxtjs/eslint-config@12.0.0(@typescript-eslint/parser@6.21.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3)(eslint@9.12.0(jiti@2.3.3))': dependencies: - eslint: 9.10.0(jiti@2.1.2) - eslint-config-standard: 17.1.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.1)(eslint@9.10.0(jiti@2.1.2)))(eslint-plugin-n@15.7.0(eslint@9.10.0(jiti@2.1.2)))(eslint-plugin-promise@6.4.0(eslint@9.10.0(jiti@2.1.2)))(eslint@9.10.0(jiti@2.1.2)) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.1)(eslint@9.10.0(jiti@2.1.2)) - eslint-plugin-n: 15.7.0(eslint@9.10.0(jiti@2.1.2)) - eslint-plugin-node: 11.1.0(eslint@9.10.0(jiti@2.1.2)) - eslint-plugin-promise: 6.4.0(eslint@9.10.0(jiti@2.1.2)) - eslint-plugin-unicorn: 44.0.2(eslint@9.10.0(jiti@2.1.2)) - eslint-plugin-vue: 9.27.0(eslint@9.10.0(jiti@2.1.2)) + eslint: 9.12.0(jiti@2.3.3) + eslint-config-standard: 17.1.0(eslint-plugin-import@2.31.0)(eslint-plugin-n@15.7.0(eslint@9.12.0(jiti@2.3.3)))(eslint-plugin-promise@6.6.0(eslint@9.12.0(jiti@2.3.3)))(eslint@9.12.0(jiti@2.3.3)) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@6.21.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3)(eslint@9.12.0(jiti@2.3.3)) + eslint-plugin-n: 15.7.0(eslint@9.12.0(jiti@2.3.3)) + eslint-plugin-node: 11.1.0(eslint@9.12.0(jiti@2.3.3)) + eslint-plugin-promise: 6.6.0(eslint@9.12.0(jiti@2.3.3)) + eslint-plugin-unicorn: 44.0.2(eslint@9.12.0(jiti@2.3.3)) + eslint-plugin-vue: 9.29.0(eslint@9.12.0(jiti@2.3.3)) local-pkg: 0.4.3 transitivePeerDependencies: - '@typescript-eslint/parser' @@ -7386,9 +8652,9 @@ snapshots: - eslint-import-resolver-webpack - supports-color - '@nuxtjs/turnstile@0.8.0(magicast@0.3.5)(rollup@4.24.0)(webpack-sources@3.2.3)': + '@nuxtjs/turnstile@0.8.0(magicast@0.3.5)(rollup@4.24.0)': dependencies: - '@nuxt/kit': 3.13.1(magicast@0.3.5)(rollup@4.24.0)(webpack-sources@3.2.3) + '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.24.0) defu: 6.1.4 pathe: 1.1.2 transitivePeerDependencies: @@ -7397,6 +8663,25 @@ snapshots: - supports-color - webpack-sources + '@oslojs/encoding@1.1.0': {} + + '@pagefind/darwin-arm64@1.1.1': + optional: true + + '@pagefind/darwin-x64@1.1.1': + optional: true + + '@pagefind/default-ui@1.1.1': {} + + '@pagefind/linux-arm64@1.1.1': + optional: true + + '@pagefind/linux-x64@1.1.1': + optional: true + + '@pagefind/windows-x64@1.1.1': + optional: true + '@parcel/watcher-android-arm64@2.4.1': optional: true @@ -7465,6 +8750,36 @@ snapshots: '@polka/url@1.0.0-next.28': {} + '@readme/better-ajv-errors@1.6.0(ajv@8.17.1)': + dependencies: + '@babel/code-frame': 7.25.7 + '@babel/runtime': 7.25.7 + '@humanwhocodes/momoa': 2.0.4 + ajv: 8.17.1 + chalk: 4.1.2 + json-to-ast: 2.1.0 + jsonpointer: 5.0.1 + leven: 3.1.0 + + '@readme/json-schema-ref-parser@1.2.0': + dependencies: + '@jsdevtools/ono': 7.1.3 + '@types/json-schema': 7.0.15 + call-me-maybe: 1.0.2 + js-yaml: 4.1.0 + + '@readme/openapi-parser@2.5.0(openapi-types@12.1.3)': + dependencies: + '@apidevtools/openapi-schemas': 2.1.0 + '@apidevtools/swagger-methods': 3.0.2 + '@jsdevtools/ono': 7.1.3 + '@readme/better-ajv-errors': 1.6.0(ajv@8.17.1) + '@readme/json-schema-ref-parser': 1.2.0 + ajv: 8.17.1 + ajv-draft-04: 1.0.0(ajv@8.17.1) + call-me-maybe: 1.0.2 + openapi-types: 12.1.3 + '@rollup/plugin-alias@5.1.1(rollup@4.24.0)': optionalDependencies: rollup: 4.24.0 @@ -7476,7 +8791,7 @@ snapshots: estree-walker: 2.0.2 glob: 8.1.0 is-reference: 1.2.1 - magic-string: 0.30.11 + magic-string: 0.30.12 optionalDependencies: rollup: 4.24.0 @@ -7484,7 +8799,7 @@ snapshots: dependencies: '@rollup/pluginutils': 5.1.2(rollup@4.24.0) estree-walker: 2.0.2 - magic-string: 0.30.11 + magic-string: 0.30.12 optionalDependencies: rollup: 4.24.0 @@ -7507,7 +8822,7 @@ snapshots: '@rollup/plugin-replace@5.0.7(rollup@4.24.0)': dependencies: '@rollup/pluginutils': 5.1.2(rollup@4.24.0) - magic-string: 0.30.11 + magic-string: 0.30.12 optionalDependencies: rollup: 4.24.0 @@ -7524,14 +8839,6 @@ snapshots: estree-walker: 2.0.2 picomatch: 2.3.1 - '@rollup/pluginutils@5.1.0(rollup@4.24.0)': - dependencies: - '@types/estree': 1.0.5 - estree-walker: 2.0.2 - picomatch: 2.3.1 - optionalDependencies: - rollup: 4.24.0 - '@rollup/pluginutils@5.1.2(rollup@4.24.0)': dependencies: '@types/estree': 1.0.6 @@ -7588,78 +8895,105 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.24.0': optional: true - '@sentry-internal/browser-utils@8.30.0': - dependencies: - '@sentry/core': 8.30.0 - '@sentry/types': 8.30.0 - '@sentry/utils': 8.30.0 + '@rtsao/scc@1.1.0': {} - '@sentry-internal/feedback@8.30.0': + '@sentry-internal/browser-utils@8.34.0': dependencies: - '@sentry/core': 8.30.0 - '@sentry/types': 8.30.0 - '@sentry/utils': 8.30.0 + '@sentry/core': 8.34.0 + '@sentry/types': 8.34.0 + '@sentry/utils': 8.34.0 - '@sentry-internal/replay-canvas@8.30.0': + '@sentry-internal/feedback@8.34.0': dependencies: - '@sentry-internal/replay': 8.30.0 - '@sentry/core': 8.30.0 - '@sentry/types': 8.30.0 - '@sentry/utils': 8.30.0 + '@sentry/core': 8.34.0 + '@sentry/types': 8.34.0 + '@sentry/utils': 8.34.0 - '@sentry-internal/replay@8.30.0': + '@sentry-internal/replay-canvas@8.34.0': dependencies: - '@sentry-internal/browser-utils': 8.30.0 - '@sentry/core': 8.30.0 - '@sentry/types': 8.30.0 - '@sentry/utils': 8.30.0 + '@sentry-internal/replay': 8.34.0 + '@sentry/core': 8.34.0 + '@sentry/types': 8.34.0 + '@sentry/utils': 8.34.0 - '@sentry/browser@8.30.0': + '@sentry-internal/replay@8.34.0': dependencies: - '@sentry-internal/browser-utils': 8.30.0 - '@sentry-internal/feedback': 8.30.0 - '@sentry-internal/replay': 8.30.0 - '@sentry-internal/replay-canvas': 8.30.0 - '@sentry/core': 8.30.0 - '@sentry/types': 8.30.0 - '@sentry/utils': 8.30.0 + '@sentry-internal/browser-utils': 8.34.0 + '@sentry/core': 8.34.0 + '@sentry/types': 8.34.0 + '@sentry/utils': 8.34.0 - '@sentry/core@8.30.0': + '@sentry/browser@8.34.0': dependencies: - '@sentry/types': 8.30.0 - '@sentry/utils': 8.30.0 + '@sentry-internal/browser-utils': 8.34.0 + '@sentry-internal/feedback': 8.34.0 + '@sentry-internal/replay': 8.34.0 + '@sentry-internal/replay-canvas': 8.34.0 + '@sentry/core': 8.34.0 + '@sentry/types': 8.34.0 + '@sentry/utils': 8.34.0 - '@sentry/types@8.30.0': {} - - '@sentry/utils@8.30.0': + '@sentry/core@8.34.0': dependencies: - '@sentry/types': 8.30.0 + '@sentry/types': 8.34.0 + '@sentry/utils': 8.34.0 - '@sentry/vue@8.30.0(vue@3.5.4(typescript@5.6.2))': + '@sentry/types@8.34.0': {} + + '@sentry/utils@8.34.0': dependencies: - '@sentry/browser': 8.30.0 - '@sentry/core': 8.30.0 - '@sentry/types': 8.30.0 - '@sentry/utils': 8.30.0 - vue: 3.5.4(typescript@5.6.2) + '@sentry/types': 8.34.0 + + '@sentry/vue@8.34.0(vue@3.5.12(typescript@5.6.3))': + dependencies: + '@sentry/browser': 8.34.0 + '@sentry/core': 8.34.0 + '@sentry/types': 8.34.0 + '@sentry/utils': 8.34.0 + vue: 3.5.12(typescript@5.6.3) + + '@shikijs/core@1.22.0': + dependencies: + '@shikijs/engine-javascript': 1.22.0 + '@shikijs/engine-oniguruma': 1.22.0 + '@shikijs/types': 1.22.0 + '@shikijs/vscode-textmate': 9.3.0 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.3 + + '@shikijs/engine-javascript@1.22.0': + dependencies: + '@shikijs/types': 1.22.0 + '@shikijs/vscode-textmate': 9.3.0 + oniguruma-to-js: 0.4.3 + + '@shikijs/engine-oniguruma@1.22.0': + dependencies: + '@shikijs/types': 1.22.0 + '@shikijs/vscode-textmate': 9.3.0 + + '@shikijs/types@1.22.0': + dependencies: + '@shikijs/vscode-textmate': 9.3.0 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@9.3.0': {} '@sindresorhus/merge-streams@2.3.0': {} - '@stylistic/eslint-plugin@2.8.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2)': + '@stylistic/eslint-plugin@2.9.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3)': dependencies: - '@typescript-eslint/utils': 8.5.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) - eslint: 9.10.0(jiti@2.1.2) - eslint-visitor-keys: 4.0.0 - espree: 10.1.0 + '@typescript-eslint/utils': 8.9.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) + eslint: 9.12.0(jiti@2.3.3) + eslint-visitor-keys: 4.1.0 + espree: 10.2.0 estraverse: 5.3.0 picomatch: 4.0.2 transitivePeerDependencies: - supports-color - typescript - '@tauri-apps/api@2.0.0-rc.3': {} - - '@tauri-apps/api@2.0.0-rc.4': {} + '@tauri-apps/api@2.0.2': {} '@tauri-apps/cli-darwin-arm64@2.0.0-rc.16': optional: true @@ -7704,45 +9038,76 @@ snapshots: '@tauri-apps/cli-win32-ia32-msvc': 2.0.0-rc.16 '@tauri-apps/cli-win32-x64-msvc': 2.0.0-rc.16 - '@tauri-apps/plugin-dialog@2.0.0-rc.1': + '@tauri-apps/plugin-dialog@2.0.0': dependencies: - '@tauri-apps/api': 2.0.0-rc.4 + '@tauri-apps/api': 2.0.2 - '@tauri-apps/plugin-os@2.0.0-rc.1': + '@tauri-apps/plugin-os@2.0.0': dependencies: - '@tauri-apps/api': 2.0.0-rc.4 + '@tauri-apps/api': 2.0.2 - '@tauri-apps/plugin-shell@2.0.0-rc.1': + '@tauri-apps/plugin-shell@2.0.0': dependencies: - '@tauri-apps/api': 2.0.0-rc.4 + '@tauri-apps/api': 2.0.2 - '@tauri-apps/plugin-updater@2.0.0-rc.2': + '@tauri-apps/plugin-updater@2.0.0': dependencies: - '@tauri-apps/api': 2.0.0-rc.4 + '@tauri-apps/api': 2.0.2 - '@tauri-apps/plugin-window-state@2.0.0-rc.1': + '@tauri-apps/plugin-window-state@2.0.0': dependencies: - '@tauri-apps/api': 2.0.0-rc.4 + '@tauri-apps/api': 2.0.2 '@trysound/sax@0.2.0': {} - '@types/eslint@9.6.1': + '@types/acorn@4.0.6': dependencies: '@types/estree': 1.0.6 - '@types/json-schema': 7.0.15 - optional: true - '@types/estree@1.0.5': {} + '@types/babel__core@7.20.5': + dependencies: + '@babel/parser': 7.25.8 + '@babel/types': 7.25.8 + '@types/babel__generator': 7.6.8 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.20.6 + + '@types/babel__generator@7.6.8': + dependencies: + '@babel/types': 7.25.8 + + '@types/babel__template@7.4.4': + dependencies: + '@babel/parser': 7.25.8 + '@babel/types': 7.25.8 + + '@types/babel__traverse@7.20.6': + dependencies: + '@babel/types': 7.25.8 + + '@types/cookie@0.6.0': {} + + '@types/debug@4.1.12': + dependencies: + '@types/ms': 0.7.34 + + '@types/estree-jsx@1.0.5': + dependencies: + '@types/estree': 1.0.6 '@types/estree@1.0.6': {} '@types/fs-extra@9.0.13': dependencies: - '@types/node': 20.16.5 + '@types/node': 18.19.55 + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 '@types/http-proxy@1.17.15': dependencies: - '@types/node': 20.16.5 + '@types/node': 20.16.11 '@types/json-schema@7.0.15': {} @@ -7757,117 +9122,136 @@ snapshots: '@types/linkify-it': 5.0.0 '@types/mdurl': 2.0.0 + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + '@types/mdurl@2.0.0': {} + '@types/mdx@2.0.13': {} + + '@types/ms@0.7.34': {} + + '@types/nlcst@2.0.3': + dependencies: + '@types/unist': 3.0.3 + '@types/node@17.0.45': {} - '@types/node@20.16.5': + '@types/node@18.19.55': dependencies: - undici-types: 6.19.8 + undici-types: 5.26.5 - '@types/node@22.7.4': + '@types/node@20.16.11': dependencies: undici-types: 6.19.8 - optional: true '@types/normalize-package-data@2.4.4': {} '@types/resolve@1.20.2': {} + '@types/sax@1.2.7': + dependencies: + '@types/node': 17.0.45 + '@types/semver@7.5.8': {} - '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2)': + '@types/unist@2.0.11': {} + + '@types/unist@3.0.3': {} + + '@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3)': dependencies: - '@eslint-community/regexpp': 4.11.0 - '@typescript-eslint/parser': 6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) + '@eslint-community/regexpp': 4.11.1 + '@typescript-eslint/parser': 6.21.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) '@typescript-eslint/scope-manager': 6.21.0 - '@typescript-eslint/type-utils': 6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) - '@typescript-eslint/utils': 6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) + '@typescript-eslint/type-utils': 6.21.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) + '@typescript-eslint/utils': 6.21.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.3.5 - eslint: 9.10.0(jiti@2.1.2) - graphemer: 1.4.0 - ignore: 5.3.1 - natural-compare: 1.4.0 - semver: 7.6.3 - ts-api-utils: 1.3.0(typescript@5.6.2) - optionalDependencies: - typescript: 5.6.2 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/eslint-plugin@7.16.1(@typescript-eslint/parser@7.16.1(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2)': - dependencies: - '@eslint-community/regexpp': 4.11.0 - '@typescript-eslint/parser': 7.16.1(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) - '@typescript-eslint/scope-manager': 7.16.1 - '@typescript-eslint/type-utils': 7.16.1(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) - '@typescript-eslint/utils': 7.16.1(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) - '@typescript-eslint/visitor-keys': 7.16.1 - eslint: 9.10.0(jiti@2.1.2) - graphemer: 1.4.0 - ignore: 5.3.1 - natural-compare: 1.4.0 - ts-api-utils: 1.3.0(typescript@5.6.2) - optionalDependencies: - typescript: 5.6.2 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/eslint-plugin@8.5.0(@typescript-eslint/parser@8.5.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2)': - dependencies: - '@eslint-community/regexpp': 4.11.0 - '@typescript-eslint/parser': 8.5.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) - '@typescript-eslint/scope-manager': 8.5.0 - '@typescript-eslint/type-utils': 8.5.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) - '@typescript-eslint/utils': 8.5.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) - '@typescript-eslint/visitor-keys': 8.5.0 - eslint: 9.10.0(jiti@2.1.2) + debug: 4.3.7 + eslint: 9.12.0(jiti@2.3.3) graphemer: 1.4.0 ignore: 5.3.2 natural-compare: 1.4.0 - ts-api-utils: 1.3.0(typescript@5.6.2) + semver: 7.6.3 + ts-api-utils: 1.3.0(typescript@5.6.3) optionalDependencies: - typescript: 5.6.2 + typescript: 5.6.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/parser@6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2)': + '@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3)': + dependencies: + '@eslint-community/regexpp': 4.11.1 + '@typescript-eslint/parser': 7.18.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/type-utils': 7.18.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) + '@typescript-eslint/utils': 7.18.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) + '@typescript-eslint/visitor-keys': 7.18.0 + eslint: 9.12.0(jiti@2.3.3) + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + ts-api-utils: 1.3.0(typescript@5.6.3) + optionalDependencies: + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/eslint-plugin@8.9.0(@typescript-eslint/parser@8.9.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3)': + dependencies: + '@eslint-community/regexpp': 4.11.1 + '@typescript-eslint/parser': 8.9.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) + '@typescript-eslint/scope-manager': 8.9.0 + '@typescript-eslint/type-utils': 8.9.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) + '@typescript-eslint/utils': 8.9.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) + '@typescript-eslint/visitor-keys': 8.9.0 + eslint: 9.12.0(jiti@2.3.3) + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + ts-api-utils: 1.3.0(typescript@5.6.3) + optionalDependencies: + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@6.21.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3)': dependencies: '@typescript-eslint/scope-manager': 6.21.0 '@typescript-eslint/types': 6.21.0 - '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.6.2) + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.6.3) '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.3.5 - eslint: 9.10.0(jiti@2.1.2) - optionalDependencies: - typescript: 5.6.2 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@7.16.1(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2)': - dependencies: - '@typescript-eslint/scope-manager': 7.16.1 - '@typescript-eslint/types': 7.16.1 - '@typescript-eslint/typescript-estree': 7.16.1(typescript@5.6.2) - '@typescript-eslint/visitor-keys': 7.16.1 - debug: 4.3.5 - eslint: 9.10.0(jiti@2.1.2) - optionalDependencies: - typescript: 5.6.2 - transitivePeerDependencies: - - supports-color - - '@typescript-eslint/parser@8.5.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2)': - dependencies: - '@typescript-eslint/scope-manager': 8.5.0 - '@typescript-eslint/types': 8.5.0 - '@typescript-eslint/typescript-estree': 8.5.0(typescript@5.6.2) - '@typescript-eslint/visitor-keys': 8.5.0 debug: 4.3.7 - eslint: 9.10.0(jiti@2.1.2) + eslint: 9.12.0(jiti@2.3.3) optionalDependencies: - typescript: 5.6.2 + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@7.18.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3)': + dependencies: + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.6.3) + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.3.7 + eslint: 9.12.0(jiti@2.3.3) + optionalDependencies: + typescript: 5.6.3 + transitivePeerDependencies: + - supports-color + + '@typescript-eslint/parser@8.9.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3)': + dependencies: + '@typescript-eslint/scope-manager': 8.9.0 + '@typescript-eslint/types': 8.9.0 + '@typescript-eslint/typescript-estree': 8.9.0(typescript@5.6.3) + '@typescript-eslint/visitor-keys': 8.9.0 + debug: 4.3.7 + eslint: 9.12.0(jiti@2.3.3) + optionalDependencies: + typescript: 5.6.3 transitivePeerDependencies: - supports-color @@ -7876,135 +9260,135 @@ snapshots: '@typescript-eslint/types': 6.21.0 '@typescript-eslint/visitor-keys': 6.21.0 - '@typescript-eslint/scope-manager@7.16.1': + '@typescript-eslint/scope-manager@7.18.0': dependencies: - '@typescript-eslint/types': 7.16.1 - '@typescript-eslint/visitor-keys': 7.16.1 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 - '@typescript-eslint/scope-manager@8.5.0': + '@typescript-eslint/scope-manager@8.9.0': dependencies: - '@typescript-eslint/types': 8.5.0 - '@typescript-eslint/visitor-keys': 8.5.0 + '@typescript-eslint/types': 8.9.0 + '@typescript-eslint/visitor-keys': 8.9.0 - '@typescript-eslint/type-utils@6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2)': + '@typescript-eslint/type-utils@6.21.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3)': dependencies: - '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.6.2) - '@typescript-eslint/utils': 6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) - debug: 4.3.5 - eslint: 9.10.0(jiti@2.1.2) - ts-api-utils: 1.3.0(typescript@5.6.2) + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.6.3) + '@typescript-eslint/utils': 6.21.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) + debug: 4.3.7 + eslint: 9.12.0(jiti@2.3.3) + ts-api-utils: 1.3.0(typescript@5.6.3) optionalDependencies: - typescript: 5.6.2 + typescript: 5.6.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@7.16.1(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2)': + '@typescript-eslint/type-utils@7.18.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3)': dependencies: - '@typescript-eslint/typescript-estree': 7.16.1(typescript@5.6.2) - '@typescript-eslint/utils': 7.16.1(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.6.3) + '@typescript-eslint/utils': 7.18.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) debug: 4.3.7 - eslint: 9.10.0(jiti@2.1.2) - ts-api-utils: 1.3.0(typescript@5.6.2) + eslint: 9.12.0(jiti@2.3.3) + ts-api-utils: 1.3.0(typescript@5.6.3) optionalDependencies: - typescript: 5.6.2 + typescript: 5.6.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/type-utils@8.5.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2)': + '@typescript-eslint/type-utils@8.9.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3)': dependencies: - '@typescript-eslint/typescript-estree': 8.5.0(typescript@5.6.2) - '@typescript-eslint/utils': 8.5.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) + '@typescript-eslint/typescript-estree': 8.9.0(typescript@5.6.3) + '@typescript-eslint/utils': 8.9.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) debug: 4.3.7 - ts-api-utils: 1.3.0(typescript@5.6.2) + ts-api-utils: 1.3.0(typescript@5.6.3) optionalDependencies: - typescript: 5.6.2 + typescript: 5.6.3 transitivePeerDependencies: - eslint - supports-color '@typescript-eslint/types@6.21.0': {} - '@typescript-eslint/types@7.16.1': {} + '@typescript-eslint/types@7.18.0': {} - '@typescript-eslint/types@8.5.0': {} + '@typescript-eslint/types@8.9.0': {} - '@typescript-eslint/typescript-estree@6.21.0(typescript@5.6.2)': + '@typescript-eslint/typescript-estree@6.21.0(typescript@5.6.3)': dependencies: '@typescript-eslint/types': 6.21.0 '@typescript-eslint/visitor-keys': 6.21.0 - debug: 4.3.5 + debug: 4.3.7 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.3 semver: 7.6.3 - ts-api-utils: 1.3.0(typescript@5.6.2) + ts-api-utils: 1.3.0(typescript@5.6.3) optionalDependencies: - typescript: 5.6.2 + typescript: 5.6.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@7.16.1(typescript@5.6.2)': + '@typescript-eslint/typescript-estree@7.18.0(typescript@5.6.3)': dependencies: - '@typescript-eslint/types': 7.16.1 - '@typescript-eslint/visitor-keys': 7.16.1 - debug: 4.3.5 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.3.7 globby: 11.1.0 is-glob: 4.0.3 minimatch: 9.0.5 semver: 7.6.3 - ts-api-utils: 1.3.0(typescript@5.6.2) + ts-api-utils: 1.3.0(typescript@5.6.3) optionalDependencies: - typescript: 5.6.2 + typescript: 5.6.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/typescript-estree@8.5.0(typescript@5.6.2)': + '@typescript-eslint/typescript-estree@8.9.0(typescript@5.6.3)': dependencies: - '@typescript-eslint/types': 8.5.0 - '@typescript-eslint/visitor-keys': 8.5.0 + '@typescript-eslint/types': 8.9.0 + '@typescript-eslint/visitor-keys': 8.9.0 debug: 4.3.7 fast-glob: 3.3.2 is-glob: 4.0.3 minimatch: 9.0.5 semver: 7.6.3 - ts-api-utils: 1.3.0(typescript@5.6.2) + ts-api-utils: 1.3.0(typescript@5.6.3) optionalDependencies: - typescript: 5.6.2 + typescript: 5.6.3 transitivePeerDependencies: - supports-color - '@typescript-eslint/utils@6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2)': + '@typescript-eslint/utils@6.21.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3)': dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@9.10.0(jiti@2.1.2)) + '@eslint-community/eslint-utils': 4.4.0(eslint@9.12.0(jiti@2.3.3)) '@types/json-schema': 7.0.15 '@types/semver': 7.5.8 '@typescript-eslint/scope-manager': 6.21.0 '@typescript-eslint/types': 6.21.0 - '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.6.2) - eslint: 9.10.0(jiti@2.1.2) + '@typescript-eslint/typescript-estree': 6.21.0(typescript@5.6.3) + eslint: 9.12.0(jiti@2.3.3) semver: 7.6.3 transitivePeerDependencies: - supports-color - typescript - '@typescript-eslint/utils@7.16.1(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2)': + '@typescript-eslint/utils@7.18.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3)': dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@9.10.0(jiti@2.1.2)) - '@typescript-eslint/scope-manager': 7.16.1 - '@typescript-eslint/types': 7.16.1 - '@typescript-eslint/typescript-estree': 7.16.1(typescript@5.6.2) - eslint: 9.10.0(jiti@2.1.2) + '@eslint-community/eslint-utils': 4.4.0(eslint@9.12.0(jiti@2.3.3)) + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.6.3) + eslint: 9.12.0(jiti@2.3.3) transitivePeerDependencies: - supports-color - typescript - '@typescript-eslint/utils@8.5.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2)': + '@typescript-eslint/utils@8.9.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3)': dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@9.10.0(jiti@2.1.2)) - '@typescript-eslint/scope-manager': 8.5.0 - '@typescript-eslint/types': 8.5.0 - '@typescript-eslint/typescript-estree': 8.5.0(typescript@5.6.2) - eslint: 9.10.0(jiti@2.1.2) + '@eslint-community/eslint-utils': 4.4.0(eslint@9.12.0(jiti@2.3.3)) + '@typescript-eslint/scope-manager': 8.9.0 + '@typescript-eslint/types': 8.9.0 + '@typescript-eslint/typescript-estree': 8.9.0(typescript@5.6.3) + eslint: 9.12.0(jiti@2.3.3) transitivePeerDependencies: - supports-color - typescript @@ -8014,45 +9398,45 @@ snapshots: '@typescript-eslint/types': 6.21.0 eslint-visitor-keys: 3.4.3 - '@typescript-eslint/visitor-keys@7.16.1': + '@typescript-eslint/visitor-keys@7.18.0': dependencies: - '@typescript-eslint/types': 7.16.1 + '@typescript-eslint/types': 7.18.0 eslint-visitor-keys: 3.4.3 - '@typescript-eslint/visitor-keys@8.5.0': + '@typescript-eslint/visitor-keys@8.9.0': dependencies: - '@typescript-eslint/types': 8.5.0 + '@typescript-eslint/types': 8.9.0 eslint-visitor-keys: 3.4.3 '@ungap/structured-clone@1.2.0': {} - '@unhead/dom@1.11.7': + '@unhead/dom@1.11.9': dependencies: - '@unhead/schema': 1.11.7 - '@unhead/shared': 1.11.7 + '@unhead/schema': 1.11.9 + '@unhead/shared': 1.11.9 - '@unhead/schema@1.11.7': + '@unhead/schema@1.11.9': dependencies: hookable: 5.5.3 zhead: 2.2.4 - '@unhead/shared@1.11.7': + '@unhead/shared@1.11.9': dependencies: - '@unhead/schema': 1.11.7 + '@unhead/schema': 1.11.9 - '@unhead/ssr@1.11.7': + '@unhead/ssr@1.11.9': dependencies: - '@unhead/schema': 1.11.7 - '@unhead/shared': 1.11.7 + '@unhead/schema': 1.11.9 + '@unhead/shared': 1.11.9 - '@unhead/vue@1.11.7(vue@3.5.11(typescript@5.6.2))': + '@unhead/vue@1.11.9(vue@3.5.12(typescript@5.6.3))': dependencies: - '@unhead/schema': 1.11.7 - '@unhead/shared': 1.11.7 + '@unhead/schema': 1.11.9 + '@unhead/shared': 1.11.9 defu: 6.1.4 hookable: 5.5.3 - unhead: 1.11.7 - vue: 3.5.11(typescript@5.6.2) + unhead: 1.11.9 + vue: 3.5.12(typescript@5.6.3) '@vercel/nft@0.26.5': dependencies: @@ -8072,25 +9456,25 @@ snapshots: - encoding - supports-color - '@vintl/compact-number@2.0.7(@formatjs/intl@2.10.5(typescript@5.6.2))': + '@vintl/compact-number@2.0.7(@formatjs/intl@2.10.8(typescript@5.6.3))': dependencies: '@formatjs/ecma402-abstract': 1.18.3 - '@formatjs/intl': 2.10.5(typescript@5.6.2) - '@formatjs/intl-localematcher': 0.5.4 - intl-messageformat: 10.5.14 + '@formatjs/intl': 2.10.8(typescript@5.6.3) + '@formatjs/intl-localematcher': 0.5.5 + intl-messageformat: 10.7.0 - '@vintl/how-ago@3.0.1(@formatjs/intl@2.10.5(typescript@5.6.2))': + '@vintl/how-ago@3.0.1(@formatjs/intl@2.10.8(typescript@5.6.3))': dependencies: - '@formatjs/intl': 2.10.5(typescript@5.6.2) - intl-messageformat: 10.5.14 + '@formatjs/intl': 2.10.8(typescript@5.6.3) + intl-messageformat: 10.7.0 - '@vintl/nuxt@1.9.2(@vue/compiler-core@3.5.11)(magicast@0.3.5)(rollup@4.24.0)(typescript@5.6.2)(vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1))(vue@3.5.11(typescript@5.6.2))(webpack-sources@3.2.3)(webpack@5.94.0)': + '@vintl/nuxt@1.9.2(@vue/compiler-core@3.5.12)(magicast@0.3.5)(rollup@4.24.0)(typescript@5.6.3)(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1))(vue@3.5.12(typescript@5.6.3))': dependencies: - '@formatjs/intl': 2.10.5(typescript@5.6.2) - '@formatjs/intl-localematcher': 0.5.4 - '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.24.0)(webpack-sources@3.2.3) - '@vintl/unplugin': 2.0.0(@vue/compiler-core@3.5.11)(rollup@4.24.0)(vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1))(vue@3.5.11(typescript@5.6.2))(webpack-sources@3.2.3)(webpack@5.94.0) - '@vintl/vintl': 4.4.1(typescript@5.6.2)(vue@3.5.11(typescript@5.6.2)) + '@formatjs/intl': 2.10.8(typescript@5.6.3) + '@formatjs/intl-localematcher': 0.5.5 + '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.24.0) + '@vintl/unplugin': 2.0.0(@vue/compiler-core@3.5.12)(rollup@4.24.0)(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1))(vue@3.5.12(typescript@5.6.3)) + '@vintl/vintl': 4.4.1(typescript@5.6.3)(vue@3.5.12(typescript@5.6.3)) astring: 1.9.0 consola: 3.2.3 hash-sum: 2.0.0 @@ -8117,42 +9501,15 @@ snapshots: - webpack - webpack-sources - '@vintl/unplugin@1.5.2(@vue/compiler-core@3.5.11)(vue@3.5.4(typescript@5.6.2))(webpack-sources@3.2.3)(webpack@5.94.0)': + '@vintl/unplugin@1.5.2(@vue/compiler-core@3.5.12)(vue@3.5.12(typescript@5.6.3))': dependencies: - '@formatjs/cli-lib': 6.4.2(@vue/compiler-core@3.5.11)(vue@3.5.4(typescript@5.6.2)) - '@formatjs/icu-messageformat-parser': 2.7.8 - '@rollup/pluginutils': 5.1.0(rollup@4.24.0) - glob: 10.4.5 - import-meta-resolve: 4.1.0 - pathe: 1.1.2 - unplugin: 1.14.1(webpack-sources@3.2.3) - optionalDependencies: - webpack: 5.94.0 - transitivePeerDependencies: - - '@glimmer/env' - - '@glimmer/reference' - - '@glimmer/syntax' - - '@glimmer/validator' - - '@vue/compiler-core' - - content-tag - - ember-template-recast - - ts-jest - - vue - - webpack-sources - - '@vintl/unplugin@2.0.0(@vue/compiler-core@3.5.11)(rollup@4.24.0)(vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1))(vue@3.5.11(typescript@5.6.2))(webpack-sources@3.2.3)(webpack@5.94.0)': - dependencies: - '@formatjs/cli-lib': 6.4.2(@vue/compiler-core@3.5.11)(vue@3.5.11(typescript@5.6.2)) - '@formatjs/icu-messageformat-parser': 2.7.8 + '@formatjs/cli-lib': 6.4.5(@vue/compiler-core@3.5.12)(vue@3.5.12(typescript@5.6.3)) + '@formatjs/icu-messageformat-parser': 2.7.10 '@rollup/pluginutils': 5.1.2(rollup@4.24.0) glob: 10.4.5 import-meta-resolve: 4.1.0 pathe: 1.1.2 - unplugin: 1.14.1(webpack-sources@3.2.3) - optionalDependencies: - rollup: 4.24.0 - vite: 5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1) - webpack: 5.94.0 + unplugin: 1.14.1 transitivePeerDependencies: - '@glimmer/env' - '@glimmer/reference' @@ -8165,188 +9522,202 @@ snapshots: - vue - webpack-sources - '@vintl/vintl@4.4.1(typescript@5.6.2)(vue@3.5.11(typescript@5.6.2))': + '@vintl/unplugin@2.0.0(@vue/compiler-core@3.5.12)(rollup@4.24.0)(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1))(vue@3.5.12(typescript@5.6.3))': dependencies: - '@braw/async-computed': 5.0.2(vue@3.5.11(typescript@5.6.2)) - '@formatjs/icu-messageformat-parser': 2.7.8 - '@formatjs/intl': 2.10.4(typescript@5.6.2) + '@formatjs/cli-lib': 6.4.5(@vue/compiler-core@3.5.12)(vue@3.5.12(typescript@5.6.3)) + '@formatjs/icu-messageformat-parser': 2.7.10 + '@rollup/pluginutils': 5.1.2(rollup@4.24.0) + glob: 10.4.5 + import-meta-resolve: 4.1.0 + pathe: 1.1.2 + unplugin: 1.14.1 + optionalDependencies: + rollup: 4.24.0 + vite: 5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1) + transitivePeerDependencies: + - '@glimmer/env' + - '@glimmer/reference' + - '@glimmer/syntax' + - '@glimmer/validator' + - '@vue/compiler-core' + - content-tag + - ember-template-recast + - ts-jest + - vue + - webpack-sources + + '@vintl/vintl@4.4.1(typescript@5.6.3)(vue@3.5.12(typescript@5.6.3))': + dependencies: + '@braw/async-computed': 5.0.2(vue@3.5.12(typescript@5.6.3)) + '@formatjs/icu-messageformat-parser': 2.7.10 + '@formatjs/intl': 2.10.8(typescript@5.6.3) '@formatjs/intl-localematcher': 0.4.2 - intl-messageformat: 10.5.14 - vue: 3.5.11(typescript@5.6.2) + intl-messageformat: 10.7.0 + vue: 3.5.12(typescript@5.6.3) transitivePeerDependencies: - typescript - '@vintl/vintl@4.4.1(typescript@5.6.2)(vue@3.5.4(typescript@5.6.2))': + '@vitejs/plugin-vue-jsx@4.0.1(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1))(vue@3.5.12(typescript@5.6.3))': dependencies: - '@braw/async-computed': 5.0.2(vue@3.5.4(typescript@5.6.2)) - '@formatjs/icu-messageformat-parser': 2.7.8 - '@formatjs/intl': 2.10.4(typescript@5.6.2) - '@formatjs/intl-localematcher': 0.4.2 - intl-messageformat: 10.5.14 - vue: 3.5.4(typescript@5.6.2) - transitivePeerDependencies: - - typescript - - '@vitejs/plugin-vue-jsx@4.0.1(vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1))(vue@3.5.11(typescript@5.6.2))': - dependencies: - '@babel/core': 7.25.7 - '@babel/plugin-transform-typescript': 7.25.7(@babel/core@7.25.7) - '@vue/babel-plugin-jsx': 1.2.5(@babel/core@7.25.7) - vite: 5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1) - vue: 3.5.11(typescript@5.6.2) + '@babel/core': 7.25.8 + '@babel/plugin-transform-typescript': 7.25.7(@babel/core@7.25.8) + '@vue/babel-plugin-jsx': 1.2.5(@babel/core@7.25.8) + vite: 5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1) + vue: 3.5.12(typescript@5.6.3) transitivePeerDependencies: - supports-color - '@vitejs/plugin-vue@5.1.4(vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1))(vue@3.5.11(typescript@5.6.2))': + '@vitejs/plugin-vue@5.1.4(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1))(vue@3.5.12(typescript@5.6.3))': dependencies: - vite: 5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1) - vue: 3.5.11(typescript@5.6.2) + vite: 5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1) + vue: 3.5.12(typescript@5.6.3) - '@vitejs/plugin-vue@5.1.4(vite@5.4.8(@types/node@22.7.4)(sass@1.78.0)(terser@5.34.1))(vue@3.5.4(typescript@5.6.2))': + '@volar/kit@2.4.6(typescript@5.6.3)': dependencies: - vite: 5.4.8(@types/node@22.7.4)(sass@1.78.0)(terser@5.34.1) - vue: 3.5.4(typescript@5.6.2) + '@volar/language-service': 2.4.6 + '@volar/typescript': 2.4.6 + typesafe-path: 0.2.2 + typescript: 5.6.3 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.0.8 - '@volar/language-core@2.4.4': + '@volar/language-core@2.4.6': dependencies: - '@volar/source-map': 2.4.4 + '@volar/source-map': 2.4.6 - '@volar/source-map@2.4.4': {} - - '@volar/typescript@2.4.4': + '@volar/language-server@2.4.6': dependencies: - '@volar/language-core': 2.4.4 + '@volar/language-core': 2.4.6 + '@volar/language-service': 2.4.6 + '@volar/typescript': 2.4.6 + path-browserify: 1.0.1 + request-light: 0.7.0 + vscode-languageserver: 9.0.1 + vscode-languageserver-protocol: 3.17.5 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.0.8 + + '@volar/language-service@2.4.6': + dependencies: + '@volar/language-core': 2.4.6 + vscode-languageserver-protocol: 3.17.5 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.0.8 + + '@volar/source-map@2.4.6': {} + + '@volar/typescript@2.4.6': + dependencies: + '@volar/language-core': 2.4.6 path-browserify: 1.0.1 vscode-uri: 3.0.8 - '@vue-macros/common@1.14.0(rollup@4.24.0)(vue@3.5.11(typescript@5.6.2))': + '@vscode/emmet-helper@2.9.3': dependencies: - '@babel/types': 7.25.7 + emmet: 2.4.11 + jsonc-parser: 2.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 2.1.2 + + '@vscode/l10n@0.0.18': {} + + '@vue-macros/common@1.15.0(rollup@4.24.0)(vue@3.5.12(typescript@5.6.3))': + dependencies: + '@babel/types': 7.25.8 '@rollup/pluginutils': 5.1.2(rollup@4.24.0) - '@vue/compiler-sfc': 3.5.11 - ast-kit: 1.2.1 + '@vue/compiler-sfc': 3.5.12 + ast-kit: 1.3.0 local-pkg: 0.5.0 magic-string-ast: 0.6.2 optionalDependencies: - vue: 3.5.11(typescript@5.6.2) + vue: 3.5.12(typescript@5.6.3) transitivePeerDependencies: - rollup '@vue/babel-helper-vue-transform-on@1.2.5': {} - '@vue/babel-plugin-jsx@1.2.5(@babel/core@7.25.7)': + '@vue/babel-plugin-jsx@1.2.5(@babel/core@7.25.8)': dependencies: '@babel/helper-module-imports': 7.25.7 '@babel/helper-plugin-utils': 7.25.7 - '@babel/plugin-syntax-jsx': 7.25.7(@babel/core@7.25.7) + '@babel/plugin-syntax-jsx': 7.25.7(@babel/core@7.25.8) '@babel/template': 7.25.7 '@babel/traverse': 7.25.7 - '@babel/types': 7.25.7 + '@babel/types': 7.25.8 '@vue/babel-helper-vue-transform-on': 1.2.5 - '@vue/babel-plugin-resolve-type': 1.2.5(@babel/core@7.25.7) + '@vue/babel-plugin-resolve-type': 1.2.5(@babel/core@7.25.8) html-tags: 3.3.1 svg-tags: 1.0.0 optionalDependencies: - '@babel/core': 7.25.7 + '@babel/core': 7.25.8 transitivePeerDependencies: - supports-color - '@vue/babel-plugin-resolve-type@1.2.5(@babel/core@7.25.7)': + '@vue/babel-plugin-resolve-type@1.2.5(@babel/core@7.25.8)': dependencies: '@babel/code-frame': 7.25.7 - '@babel/core': 7.25.7 + '@babel/core': 7.25.8 '@babel/helper-module-imports': 7.25.7 '@babel/helper-plugin-utils': 7.25.7 - '@babel/parser': 7.25.7 - '@vue/compiler-sfc': 3.5.11 + '@babel/parser': 7.25.8 + '@vue/compiler-sfc': 3.5.12 transitivePeerDependencies: - supports-color - '@vue/compiler-core@3.5.11': + '@vue/compiler-core@3.5.12': dependencies: - '@babel/parser': 7.25.7 - '@vue/shared': 3.5.11 + '@babel/parser': 7.25.8 + '@vue/shared': 3.5.12 entities: 4.5.0 estree-walker: 2.0.2 source-map-js: 1.2.1 - '@vue/compiler-core@3.5.4': + '@vue/compiler-dom@3.5.12': dependencies: - '@babel/parser': 7.25.6 - '@vue/shared': 3.5.4 - entities: 4.5.0 + '@vue/compiler-core': 3.5.12 + '@vue/shared': 3.5.12 + + '@vue/compiler-sfc@3.5.12': + dependencies: + '@babel/parser': 7.25.8 + '@vue/compiler-core': 3.5.12 + '@vue/compiler-dom': 3.5.12 + '@vue/compiler-ssr': 3.5.12 + '@vue/shared': 3.5.12 estree-walker: 2.0.2 - source-map-js: 1.2.1 - - '@vue/compiler-dom@3.5.11': - dependencies: - '@vue/compiler-core': 3.5.11 - '@vue/shared': 3.5.11 - - '@vue/compiler-dom@3.5.4': - dependencies: - '@vue/compiler-core': 3.5.4 - '@vue/shared': 3.5.4 - - '@vue/compiler-sfc@3.5.11': - dependencies: - '@babel/parser': 7.25.7 - '@vue/compiler-core': 3.5.11 - '@vue/compiler-dom': 3.5.11 - '@vue/compiler-ssr': 3.5.11 - '@vue/shared': 3.5.11 - estree-walker: 2.0.2 - magic-string: 0.30.11 + magic-string: 0.30.12 postcss: 8.4.47 source-map-js: 1.2.1 - '@vue/compiler-sfc@3.5.4': + '@vue/compiler-ssr@3.5.12': dependencies: - '@babel/parser': 7.25.6 - '@vue/compiler-core': 3.5.4 - '@vue/compiler-dom': 3.5.4 - '@vue/compiler-ssr': 3.5.4 - '@vue/shared': 3.5.4 - estree-walker: 2.0.2 - magic-string: 0.30.11 - postcss: 8.4.45 - source-map-js: 1.2.1 - - '@vue/compiler-ssr@3.5.11': - dependencies: - '@vue/compiler-dom': 3.5.11 - '@vue/shared': 3.5.11 - - '@vue/compiler-ssr@3.5.4': - dependencies: - '@vue/compiler-dom': 3.5.4 - '@vue/shared': 3.5.4 + '@vue/compiler-dom': 3.5.12 + '@vue/shared': 3.5.12 '@vue/compiler-vue2@2.7.16': dependencies: de-indent: 1.0.2 he: 1.2.0 - '@vue/devtools-api@6.6.3': {} - '@vue/devtools-api@6.6.4': {} - '@vue/devtools-core@7.4.4(vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1))(vue@3.5.11(typescript@5.6.2))': + '@vue/devtools-core@7.4.4(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1))(vue@3.5.12(typescript@5.6.3))': dependencies: '@vue/devtools-kit': 7.4.4 '@vue/devtools-shared': 7.4.6 mitt: 3.0.1 nanoid: 3.3.7 pathe: 1.1.2 - vite-hot-client: 0.2.3(vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1)) - vue: 3.5.11(typescript@5.6.2) + vite-hot-client: 0.2.3(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1)) + vue: 3.5.12(typescript@5.6.3) transitivePeerDependencies: - vite '@vue/devtools-kit@7.4.4': dependencies: '@vue/devtools-shared': 7.4.6 - birpc: 0.2.17 + birpc: 0.2.19 hookable: 5.5.3 mitt: 3.0.1 perfect-debounce: 1.0.0 @@ -8357,178 +9728,57 @@ snapshots: dependencies: rfdc: 1.4.1 - '@vue/eslint-config-typescript@13.0.0(eslint-plugin-vue@9.28.0(eslint@9.10.0(jiti@2.1.2)))(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2)': + '@vue/eslint-config-typescript@13.0.0(eslint-plugin-vue@9.29.0(eslint@9.12.0(jiti@2.3.3)))(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3)': dependencies: - '@typescript-eslint/eslint-plugin': 7.16.1(@typescript-eslint/parser@7.16.1(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) - '@typescript-eslint/parser': 7.16.1(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) - eslint: 9.10.0(jiti@2.1.2) - eslint-plugin-vue: 9.28.0(eslint@9.10.0(jiti@2.1.2)) - vue-eslint-parser: 9.4.3(eslint@9.10.0(jiti@2.1.2)) + '@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) + '@typescript-eslint/parser': 7.18.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) + eslint: 9.12.0(jiti@2.3.3) + eslint-plugin-vue: 9.29.0(eslint@9.12.0(jiti@2.3.3)) + vue-eslint-parser: 9.4.3(eslint@9.12.0(jiti@2.3.3)) optionalDependencies: - typescript: 5.6.2 + typescript: 5.6.3 transitivePeerDependencies: - supports-color - '@vue/language-core@2.1.6(typescript@5.6.2)': + '@vue/language-core@2.1.6(typescript@5.6.3)': dependencies: - '@volar/language-core': 2.4.4 - '@vue/compiler-dom': 3.5.4 + '@volar/language-core': 2.4.6 + '@vue/compiler-dom': 3.5.12 '@vue/compiler-vue2': 2.7.16 - '@vue/shared': 3.5.4 + '@vue/shared': 3.5.12 computeds: 0.0.1 minimatch: 9.0.5 muggle-string: 0.4.1 path-browserify: 1.0.1 optionalDependencies: - typescript: 5.6.2 + typescript: 5.6.3 - '@vue/reactivity@3.5.11': + '@vue/reactivity@3.5.12': dependencies: - '@vue/shared': 3.5.11 + '@vue/shared': 3.5.12 - '@vue/reactivity@3.5.4': + '@vue/runtime-core@3.5.12': dependencies: - '@vue/shared': 3.5.4 + '@vue/reactivity': 3.5.12 + '@vue/shared': 3.5.12 - '@vue/runtime-core@3.5.11': + '@vue/runtime-dom@3.5.12': dependencies: - '@vue/reactivity': 3.5.11 - '@vue/shared': 3.5.11 - - '@vue/runtime-core@3.5.4': - dependencies: - '@vue/reactivity': 3.5.4 - '@vue/shared': 3.5.4 - - '@vue/runtime-dom@3.5.11': - dependencies: - '@vue/reactivity': 3.5.11 - '@vue/runtime-core': 3.5.11 - '@vue/shared': 3.5.11 + '@vue/reactivity': 3.5.12 + '@vue/runtime-core': 3.5.12 + '@vue/shared': 3.5.12 csstype: 3.1.3 - '@vue/runtime-dom@3.5.4': + '@vue/server-renderer@3.5.12(vue@3.5.12(typescript@5.6.3))': dependencies: - '@vue/reactivity': 3.5.4 - '@vue/runtime-core': 3.5.4 - '@vue/shared': 3.5.4 - csstype: 3.1.3 + '@vue/compiler-ssr': 3.5.12 + '@vue/shared': 3.5.12 + vue: 3.5.12(typescript@5.6.3) - '@vue/server-renderer@3.5.11(vue@3.5.11(typescript@5.6.2))': - dependencies: - '@vue/compiler-ssr': 3.5.11 - '@vue/shared': 3.5.11 - vue: 3.5.11(typescript@5.6.2) - - '@vue/server-renderer@3.5.4(vue@3.5.4(typescript@5.6.2))': - dependencies: - '@vue/compiler-ssr': 3.5.4 - '@vue/shared': 3.5.4 - vue: 3.5.4(typescript@5.6.2) - - '@vue/shared@3.5.11': {} - - '@vue/shared@3.5.4': {} + '@vue/shared@3.5.12': {} '@vue/tsconfig@0.5.1': {} - '@webassemblyjs/ast@1.12.1': - dependencies: - '@webassemblyjs/helper-numbers': 1.11.6 - '@webassemblyjs/helper-wasm-bytecode': 1.11.6 - optional: true - - '@webassemblyjs/floating-point-hex-parser@1.11.6': - optional: true - - '@webassemblyjs/helper-api-error@1.11.6': - optional: true - - '@webassemblyjs/helper-buffer@1.12.1': - optional: true - - '@webassemblyjs/helper-numbers@1.11.6': - dependencies: - '@webassemblyjs/floating-point-hex-parser': 1.11.6 - '@webassemblyjs/helper-api-error': 1.11.6 - '@xtuc/long': 4.2.2 - optional: true - - '@webassemblyjs/helper-wasm-bytecode@1.11.6': - optional: true - - '@webassemblyjs/helper-wasm-section@1.12.1': - dependencies: - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/helper-buffer': 1.12.1 - '@webassemblyjs/helper-wasm-bytecode': 1.11.6 - '@webassemblyjs/wasm-gen': 1.12.1 - optional: true - - '@webassemblyjs/ieee754@1.11.6': - dependencies: - '@xtuc/ieee754': 1.2.0 - optional: true - - '@webassemblyjs/leb128@1.11.6': - dependencies: - '@xtuc/long': 4.2.2 - optional: true - - '@webassemblyjs/utf8@1.11.6': - optional: true - - '@webassemblyjs/wasm-edit@1.12.1': - dependencies: - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/helper-buffer': 1.12.1 - '@webassemblyjs/helper-wasm-bytecode': 1.11.6 - '@webassemblyjs/helper-wasm-section': 1.12.1 - '@webassemblyjs/wasm-gen': 1.12.1 - '@webassemblyjs/wasm-opt': 1.12.1 - '@webassemblyjs/wasm-parser': 1.12.1 - '@webassemblyjs/wast-printer': 1.12.1 - optional: true - - '@webassemblyjs/wasm-gen@1.12.1': - dependencies: - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/helper-wasm-bytecode': 1.11.6 - '@webassemblyjs/ieee754': 1.11.6 - '@webassemblyjs/leb128': 1.11.6 - '@webassemblyjs/utf8': 1.11.6 - optional: true - - '@webassemblyjs/wasm-opt@1.12.1': - dependencies: - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/helper-buffer': 1.12.1 - '@webassemblyjs/wasm-gen': 1.12.1 - '@webassemblyjs/wasm-parser': 1.12.1 - optional: true - - '@webassemblyjs/wasm-parser@1.12.1': - dependencies: - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/helper-api-error': 1.11.6 - '@webassemblyjs/helper-wasm-bytecode': 1.11.6 - '@webassemblyjs/ieee754': 1.11.6 - '@webassemblyjs/leb128': 1.11.6 - '@webassemblyjs/utf8': 1.11.6 - optional: true - - '@webassemblyjs/wast-printer@1.12.1': - dependencies: - '@webassemblyjs/ast': 1.12.1 - '@xtuc/long': 4.2.2 - optional: true - - '@xtuc/ieee754@1.2.0': - optional: true - - '@xtuc/long@4.2.2': - optional: true - '@yr/monotone-cubic-spline@1.0.3': {} abbrev@1.1.1: {} @@ -8545,18 +9795,23 @@ snapshots: dependencies: acorn: 8.12.1 + acorn-jsx@5.3.2(acorn@8.13.0): + dependencies: + acorn: 8.13.0 + acorn@8.12.1: {} + acorn@8.13.0: {} + agent-base@6.0.2: dependencies: debug: 4.3.7 transitivePeerDependencies: - supports-color - ajv-keywords@3.5.2(ajv@6.12.6): - dependencies: - ajv: 6.12.6 - optional: true + ajv-draft-04@1.0.0(ajv@8.17.1): + optionalDependencies: + ajv: 8.17.1 ajv@6.12.6: dependencies: @@ -8565,6 +9820,17 @@ snapshots: json-schema-traverse: 0.4.1 uri-js: 4.4.1 + ajv@8.17.1: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.0.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + ansi-align@3.0.1: + dependencies: + string-width: 4.2.3 + ansi-colors@4.1.3: {} ansi-escapes@4.3.2: @@ -8592,7 +9858,7 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 - apexcharts@3.53.0: + apexcharts@3.54.1: dependencies: '@yr/monotone-cubic-spline': 1.0.3 svg.draggable.js: 2.2.2 @@ -8633,8 +9899,14 @@ snapshots: arg@5.0.2: {} + argparse@1.0.10: + dependencies: + sprintf-js: 1.0.3 + argparse@2.0.1: {} + aria-query@5.3.2: {} + array-buffer-byte-length@1.0.1: dependencies: call-bind: 1.0.7 @@ -8651,6 +9923,8 @@ snapshots: get-intrinsic: 1.2.4 is-string: 1.0.7 + array-iterate@2.0.1: {} + array-union@2.1.0: {} array.prototype.findlastindex@1.2.5: @@ -8687,39 +9961,113 @@ snapshots: is-array-buffer: 3.0.4 is-shared-array-buffer: 1.0.3 - ast-kit@1.2.1: + ast-kit@1.3.0: dependencies: - '@babel/parser': 7.25.7 + '@babel/parser': 7.25.8 pathe: 1.1.2 ast-walker-scope@0.6.2: dependencies: - '@babel/parser': 7.25.7 - ast-kit: 1.2.1 + '@babel/parser': 7.25.8 + ast-kit: 1.3.0 astring@1.9.0: {} + astro-expressive-code@0.35.6(astro@4.16.6(@types/node@20.16.11)(rollup@4.24.0)(sass@1.79.5)(terser@5.34.1)(typescript@5.6.3)): + dependencies: + astro: 4.16.6(@types/node@20.16.11)(rollup@4.24.0)(sass@1.79.5)(terser@5.34.1)(typescript@5.6.3) + rehype-expressive-code: 0.35.6 + + astro@4.16.6(@types/node@20.16.11)(rollup@4.24.0)(sass@1.79.5)(terser@5.34.1)(typescript@5.6.3): + dependencies: + '@astrojs/compiler': 2.10.3 + '@astrojs/internal-helpers': 0.4.1 + '@astrojs/markdown-remark': 5.3.0 + '@astrojs/telemetry': 3.1.0 + '@babel/core': 7.25.8 + '@babel/plugin-transform-react-jsx': 7.25.7(@babel/core@7.25.8) + '@babel/types': 7.25.8 + '@oslojs/encoding': 1.1.0 + '@rollup/pluginutils': 5.1.2(rollup@4.24.0) + '@types/babel__core': 7.20.5 + '@types/cookie': 0.6.0 + acorn: 8.13.0 + aria-query: 5.3.2 + axobject-query: 4.1.0 + boxen: 8.0.1 + ci-info: 4.0.0 + clsx: 2.1.1 + common-ancestor-path: 1.0.1 + cookie: 0.7.2 + cssesc: 3.0.0 + debug: 4.3.7 + deterministic-object-hash: 2.0.2 + devalue: 5.1.1 + diff: 5.2.0 + dlv: 1.1.3 + dset: 3.1.4 + es-module-lexer: 1.5.4 + esbuild: 0.21.5 + estree-walker: 3.0.3 + fast-glob: 3.3.2 + flattie: 1.1.1 + github-slugger: 2.0.0 + gray-matter: 4.0.3 + html-escaper: 3.0.3 + http-cache-semantics: 4.1.1 + js-yaml: 4.1.0 + kleur: 4.1.5 + magic-string: 0.30.12 + magicast: 0.3.5 + micromatch: 4.0.8 + mrmime: 2.0.0 + neotraverse: 0.6.18 + ora: 8.1.0 + p-limit: 6.1.0 + p-queue: 8.0.1 + preferred-pm: 4.0.0 + prompts: 2.4.2 + rehype: 13.0.2 + semver: 7.6.3 + shiki: 1.22.0 + tinyexec: 0.3.1 + tsconfck: 3.1.4(typescript@5.6.3) + unist-util-visit: 5.0.0 + vfile: 6.0.3 + vite: 5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1) + vitefu: 1.0.3(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1)) + which-pm: 3.0.0 + xxhash-wasm: 1.0.2 + yargs-parser: 21.1.1 + zod: 3.23.8 + zod-to-json-schema: 3.23.3(zod@3.23.8) + zod-to-ts: 1.2.0(typescript@5.6.3)(zod@3.23.8) + optionalDependencies: + sharp: 0.33.5 + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - rollup + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - typescript + async-sema@3.1.1: {} async@3.2.6: {} - autoprefixer@10.4.20(postcss@8.4.45): - dependencies: - browserslist: 4.23.3 - caniuse-lite: 1.0.30001660 - fraction.js: 4.3.7 - normalize-range: 0.1.2 - picocolors: 1.0.1 - postcss: 8.4.45 - postcss-value-parser: 4.2.0 - autoprefixer@10.4.20(postcss@8.4.47): dependencies: - browserslist: 4.23.3 - caniuse-lite: 1.0.30001660 + browserslist: 4.24.0 + caniuse-lite: 1.0.30001668 fraction.js: 4.3.7 normalize-range: 0.1.2 - picocolors: 1.0.1 + picocolors: 1.1.0 postcss: 8.4.47 postcss-value-parser: 4.2.0 @@ -8727,25 +10075,76 @@ snapshots: dependencies: possible-typed-array-names: 1.0.0 + axobject-query@4.1.0: {} + b4a@1.6.7: {} + bail@2.0.2: {} + balanced-match@1.0.2: {} bare-events@2.5.0: optional: true + bare-fs@2.3.5: + dependencies: + bare-events: 2.5.0 + bare-path: 2.1.3 + bare-stream: 2.3.1 + optional: true + + bare-os@2.4.4: + optional: true + + bare-path@2.1.3: + dependencies: + bare-os: 2.4.4 + optional: true + + bare-stream@2.3.1: + dependencies: + streamx: 2.20.1 + optional: true + + base-64@1.0.0: {} + base64-js@1.5.1: {} + bcp-47-match@2.0.3: {} + + bcp-47@2.1.0: + dependencies: + is-alphabetical: 2.0.1 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + binary-extensions@2.3.0: {} bindings@1.5.0: dependencies: file-uri-to-path: 1.0.0 - birpc@0.2.17: {} + birpc@0.2.19: {} + + bl@4.1.0: + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 boolbase@1.0.0: {} + boxen@8.0.1: + dependencies: + ansi-align: 3.0.1 + camelcase: 8.0.0 + chalk: 5.3.0 + cli-boxes: 3.0.0 + string-width: 7.2.0 + type-fest: 4.26.1 + widest-line: 5.0.0 + wrap-ansi: 9.0.0 + brace-expansion@1.1.11: dependencies: balanced-match: 1.0.2 @@ -8759,17 +10158,10 @@ snapshots: dependencies: fill-range: 7.1.1 - browserslist@4.23.3: - dependencies: - caniuse-lite: 1.0.30001660 - electron-to-chromium: 1.5.19 - node-releases: 2.0.18 - update-browserslist-db: 1.1.0(browserslist@4.23.3) - browserslist@4.24.0: dependencies: - caniuse-lite: 1.0.30001666 - electron-to-chromium: 1.5.32 + caniuse-lite: 1.0.30001668 + electron-to-chromium: 1.5.38 node-releases: 2.0.18 update-browserslist-db: 1.1.1(browserslist@4.24.0) @@ -8777,6 +10169,11 @@ snapshots: buffer-from@1.1.2: {} + buffer@5.7.1: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + buffer@6.0.3: dependencies: base64-js: 1.5.1 @@ -8795,16 +10192,16 @@ snapshots: c12@1.11.2(magicast@0.3.5): dependencies: chokidar: 3.6.0 - confbox: 0.1.7 + confbox: 0.1.8 defu: 6.1.4 dotenv: 16.4.5 giget: 1.2.3 jiti: 1.21.6 - mlly: 1.7.1 + mlly: 1.7.2 ohash: 1.1.4 pathe: 1.1.2 perfect-debounce: 1.0.0 - pkg-types: 1.2.0 + pkg-types: 1.2.1 rc9: 2.1.2 optionalDependencies: magicast: 0.3.5 @@ -8819,20 +10216,24 @@ snapshots: get-intrinsic: 1.2.4 set-function-length: 1.2.2 + call-me-maybe@1.0.2: {} + callsites@3.1.0: {} camelcase-css@2.0.1: {} + camelcase@8.0.0: {} + caniuse-api@3.0.0: dependencies: browserslist: 4.24.0 - caniuse-lite: 1.0.30001666 + caniuse-lite: 1.0.30001668 lodash.memoize: 4.1.2 lodash.uniq: 4.5.0 - caniuse-lite@1.0.30001660: {} + caniuse-lite@1.0.30001668: {} - caniuse-lite@1.0.30001666: {} + ccount@2.0.1: {} chalk@2.4.2: dependencies: @@ -8847,6 +10248,14 @@ snapshots: chalk@5.3.0: {} + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + character-entities@2.0.2: {} + + character-reference-invalid@2.0.1: {} + chokidar@3.6.0: dependencies: anymatch: 3.1.3 @@ -8859,10 +10268,13 @@ snapshots: optionalDependencies: fsevents: 2.3.3 - chownr@2.0.0: {} + chokidar@4.0.1: + dependencies: + readdirp: 4.0.2 - chrome-trace-event@1.0.4: - optional: true + chownr@1.1.4: {} + + chownr@2.0.0: {} ci-info@3.9.0: {} @@ -8878,6 +10290,14 @@ snapshots: clear@0.1.0: {} + cli-boxes@3.0.0: {} + + cli-cursor@5.0.0: + dependencies: + restore-cursor: 5.1.0 + + cli-spinners@2.9.2: {} + clipboardy@4.0.0: dependencies: execa: 8.0.1 @@ -8890,8 +10310,14 @@ snapshots: strip-ansi: 6.0.1 wrap-ansi: 7.0.0 + clsx@2.1.1: {} + cluster-key-slot@1.1.2: {} + code-error-fragment@0.0.230: {} + + collapse-white-space@2.1.0: {} + color-convert@1.9.3: dependencies: color-name: 1.1.3 @@ -8904,10 +10330,22 @@ snapshots: color-name@1.1.4: {} + color-string@1.9.1: + dependencies: + color-name: 1.1.4 + simple-swizzle: 0.2.2 + color-support@1.1.3: {} + color@4.2.3: + dependencies: + color-convert: 2.0.1 + color-string: 1.9.1 + colord@2.9.3: {} + comma-separated-tokens@2.0.3: {} + commander@2.20.3: {} commander@4.1.1: {} @@ -8918,6 +10356,8 @@ snapshots: comment-parser@1.4.1: {} + common-ancestor-path@1.0.1: {} + commondir@1.0.1: {} compatx@0.1.8: {} @@ -8934,7 +10374,7 @@ snapshots: concat-map@0.0.1: {} - confbox@0.1.7: {} + confbox@0.1.8: {} consola@3.2.3: {} @@ -8944,13 +10384,15 @@ snapshots: cookie-es@1.2.2: {} + cookie@0.7.2: {} + copy-anything@3.0.5: dependencies: is-what: 4.1.16 core-js-compat@3.38.1: dependencies: - browserslist: 4.23.3 + browserslist: 4.24.0 core-util-is@1.0.3: {} @@ -8993,6 +10435,8 @@ snapshots: domutils: 3.1.0 nth-check: 2.1.1 + css-selector-parser@3.0.5: {} + css-tree@2.2.1: dependencies: mdn-data: 2.0.28 @@ -9001,7 +10445,7 @@ snapshots: css-tree@2.3.1: dependencies: mdn-data: 2.0.30 - source-map-js: 1.2.0 + source-map-js: 1.2.1 css-what@6.1.0: {} @@ -9095,14 +10539,20 @@ snapshots: dependencies: ms: 2.1.3 - debug@4.3.5: - dependencies: - ms: 2.1.2 - debug@4.3.7: dependencies: ms: 2.1.3 + decode-named-character-reference@1.0.2: + dependencies: + character-entities: 2.0.2 + + decompress-response@6.0.0: + dependencies: + mimic-response: 3.1.0 + + deep-extend@0.6.0: {} + deep-is@0.1.4: {} deepmerge@4.3.1: {} @@ -9138,6 +10588,8 @@ snapshots: depd@2.0.0: {} + dequal@2.0.3: {} + destr@2.0.3: {} destroy@1.2.0: {} @@ -9146,16 +10598,28 @@ snapshots: detect-libc@2.0.3: {} + deterministic-object-hash@2.0.2: + dependencies: + base-64: 1.0.0 + devalue@5.1.1: {} + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + didyoumean@1.2.2: {} + diff@5.2.0: {} + diff@7.0.0: {} dir-glob@3.0.1: dependencies: path-type: 4.0.0 + direction@2.0.1: {} + dlv@1.1.3: {} doctrine@2.1.0: @@ -9192,15 +10656,22 @@ snapshots: dotenv@16.4.5: {} + dset@3.1.4: {} + duplexer@0.1.2: {} eastasianwidth@0.2.0: {} ee-first@1.1.1: {} - electron-to-chromium@1.5.19: {} + electron-to-chromium@1.5.38: {} - electron-to-chromium@1.5.32: {} + emmet@2.4.11: + dependencies: + '@emmetio/abbreviation': 2.3.3 + '@emmetio/css-abbreviation': 2.1.8 + + emoji-regex@10.4.0: {} emoji-regex@8.0.0: {} @@ -9210,10 +10681,9 @@ snapshots: encodeurl@2.0.0: {} - enhanced-resolve@5.17.0: + end-of-stream@1.4.4: dependencies: - graceful-fs: 4.2.11 - tapable: 2.2.1 + once: 1.4.0 enhanced-resolve@5.17.1: dependencies: @@ -9268,7 +10738,7 @@ snapshots: object-inspect: 1.13.2 object-keys: 1.1.1 object.assign: 4.1.5 - regexp.prototype.flags: 1.5.2 + regexp.prototype.flags: 1.5.3 safe-array-concat: 1.1.2 safe-regex-test: 1.0.3 string.prototype.trim: 1.2.9 @@ -9309,6 +10779,20 @@ snapshots: is-date-object: 1.0.5 is-symbol: 1.0.4 + esast-util-from-estree@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + unist-util-position-from-estree: 2.0.0 + + esast-util-from-js@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + acorn: 8.13.0 + esast-util-from-estree: 2.0.0 + vfile-message: 4.0.2 + esbuild@0.20.2: optionalDependencies: '@esbuild/aix-ppc64': 0.20.2 @@ -9398,27 +10882,27 @@ snapshots: escape-string-regexp@5.0.0: {} - eslint-config-flat-gitignore@0.3.0(eslint@9.10.0(jiti@2.1.2)): + eslint-config-flat-gitignore@0.3.0(eslint@9.12.0(jiti@2.3.3)): dependencies: - '@eslint/compat': 1.1.1 - eslint: 9.10.0(jiti@2.1.2) + '@eslint/compat': 1.2.0(eslint@9.12.0(jiti@2.3.3)) + eslint: 9.12.0(jiti@2.3.3) find-up-simple: 1.0.0 - eslint-config-prettier@9.1.0(eslint@9.10.0(jiti@2.1.2)): + eslint-config-prettier@9.1.0(eslint@9.12.0(jiti@2.3.3)): dependencies: - eslint: 9.10.0(jiti@2.1.2) + eslint: 9.12.0(jiti@2.3.3) - eslint-config-standard@17.1.0(eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.1)(eslint@9.10.0(jiti@2.1.2)))(eslint-plugin-n@15.7.0(eslint@9.10.0(jiti@2.1.2)))(eslint-plugin-promise@6.4.0(eslint@9.10.0(jiti@2.1.2)))(eslint@9.10.0(jiti@2.1.2)): + eslint-config-standard@17.1.0(eslint-plugin-import@2.31.0)(eslint-plugin-n@15.7.0(eslint@9.12.0(jiti@2.3.3)))(eslint-plugin-promise@6.6.0(eslint@9.12.0(jiti@2.3.3)))(eslint@9.12.0(jiti@2.3.3)): dependencies: - eslint: 9.10.0(jiti@2.1.2) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.16.1(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint@9.10.0(jiti@2.1.2)) - eslint-plugin-n: 15.7.0(eslint@9.10.0(jiti@2.1.2)) - eslint-plugin-promise: 6.4.0(eslint@9.10.0(jiti@2.1.2)) + eslint: 9.12.0(jiti@2.3.3) + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.18.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.12.0(jiti@2.3.3)) + eslint-plugin-n: 15.7.0(eslint@9.12.0(jiti@2.3.3)) + eslint-plugin-promise: 6.6.0(eslint@9.12.0(jiti@2.3.3)) - eslint-config-turbo@2.1.1(eslint@9.10.0(jiti@2.1.2)): + eslint-config-turbo@2.1.3(eslint@9.12.0(jiti@2.3.3)): dependencies: - eslint: 9.10.0(jiti@2.1.2) - eslint-plugin-turbo: 2.1.1(eslint@9.10.0(jiti@2.1.2)) + eslint: 9.12.0(jiti@2.3.3) + eslint-plugin-turbo: 2.1.3(eslint@9.12.0(jiti@2.3.3)) eslint-flat-config-utils@0.4.0: dependencies: @@ -9427,198 +10911,214 @@ snapshots: eslint-import-resolver-node@0.3.9: dependencies: debug: 3.2.7 - is-core-module: 2.15.0 + is-core-module: 2.15.1 resolve: 1.22.8 transitivePeerDependencies: - supports-color - eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint-plugin-import@2.29.1)(eslint@9.10.0(jiti@2.1.2)): + eslint-import-resolver-typescript@3.6.3(@typescript-eslint/parser@6.21.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint-plugin-import-x@4.3.1(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint-plugin-import@2.31.0)(eslint@9.12.0(jiti@2.3.3)): dependencies: - debug: 4.3.5 - enhanced-resolve: 5.17.0 - eslint: 9.10.0(jiti@2.1.2) - eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint-plugin-import@2.29.1)(eslint@9.10.0(jiti@2.1.2)))(eslint@9.10.0(jiti@2.1.2)) - eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.16.1(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint@9.10.0(jiti@2.1.2)) + '@nolyfill/is-core-module': 1.0.39 + debug: 4.3.7 + enhanced-resolve: 5.17.1 + eslint: 9.12.0(jiti@2.3.3) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@6.21.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.12.0(jiti@2.3.3)) fast-glob: 3.3.2 - get-tsconfig: 4.7.5 - is-core-module: 2.15.0 + get-tsconfig: 4.8.1 + is-bun-module: 1.2.1 is-glob: 4.0.3 + optionalDependencies: + eslint-plugin-import: 2.31.0(@typescript-eslint/parser@7.18.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.12.0(jiti@2.3.3)) + eslint-plugin-import-x: 4.3.1(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) transitivePeerDependencies: - '@typescript-eslint/parser' - eslint-import-resolver-node - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint-plugin-import@2.29.1)(eslint@9.10.0(jiti@2.1.2)))(eslint@9.10.0(jiti@2.1.2)): + eslint-module-utils@2.12.0(@typescript-eslint/parser@6.21.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.3)(eslint@9.12.0(jiti@2.3.3)): dependencies: debug: 3.2.7 optionalDependencies: - '@typescript-eslint/parser': 6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) - eslint: 9.10.0(jiti@2.1.2) + '@typescript-eslint/parser': 6.21.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) + eslint: 9.12.0(jiti@2.3.3) eslint-import-resolver-node: 0.3.9 - eslint-import-resolver-typescript: 3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint-plugin-import@2.29.1)(eslint@9.10.0(jiti@2.1.2)) + eslint-import-resolver-typescript: 3.6.3(@typescript-eslint/parser@6.21.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint-plugin-import-x@4.3.1(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint-plugin-import@2.31.0)(eslint@9.12.0(jiti@2.3.3)) transitivePeerDependencies: - supports-color - eslint-plugin-es@3.0.1(eslint@9.10.0(jiti@2.1.2)): + eslint-module-utils@2.12.0(@typescript-eslint/parser@7.18.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint@9.12.0(jiti@2.3.3)): dependencies: - eslint: 9.10.0(jiti@2.1.2) + debug: 3.2.7 + optionalDependencies: + '@typescript-eslint/parser': 7.18.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) + eslint: 9.12.0(jiti@2.3.3) + eslint-import-resolver-node: 0.3.9 + transitivePeerDependencies: + - supports-color + + eslint-plugin-es@3.0.1(eslint@9.12.0(jiti@2.3.3)): + dependencies: + eslint: 9.12.0(jiti@2.3.3) eslint-utils: 2.1.0 regexpp: 3.2.0 - eslint-plugin-es@4.1.0(eslint@9.10.0(jiti@2.1.2)): + eslint-plugin-es@4.1.0(eslint@9.12.0(jiti@2.3.3)): dependencies: - eslint: 9.10.0(jiti@2.1.2) + eslint: 9.12.0(jiti@2.3.3) eslint-utils: 2.1.0 regexpp: 3.2.0 - eslint-plugin-import-x@4.2.1(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2): + eslint-plugin-import-x@4.3.1(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3): dependencies: - '@typescript-eslint/utils': 8.5.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) + '@typescript-eslint/utils': 8.9.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) debug: 4.3.7 doctrine: 3.0.0 - eslint: 9.10.0(jiti@2.1.2) + eslint: 9.12.0(jiti@2.3.3) eslint-import-resolver-node: 0.3.9 - get-tsconfig: 4.8.0 + get-tsconfig: 4.8.1 is-glob: 4.0.3 minimatch: 9.0.5 semver: 7.6.3 stable-hash: 0.0.4 - tslib: 2.7.0 + tslib: 2.8.0 transitivePeerDependencies: - supports-color - typescript - eslint-plugin-import@2.29.1(@typescript-eslint/parser@6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint-import-resolver-typescript@3.6.1)(eslint@9.10.0(jiti@2.1.2)): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@6.21.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint-import-resolver-typescript@3.6.3)(eslint@9.12.0(jiti@2.3.3)): dependencies: + '@rtsao/scc': 1.1.0 array-includes: 3.1.8 array.prototype.findlastindex: 1.2.5 array.prototype.flat: 1.3.2 array.prototype.flatmap: 1.3.2 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.10.0(jiti@2.1.2) + eslint: 9.12.0(jiti@2.3.3) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint-plugin-import@2.29.1)(eslint@9.10.0(jiti@2.1.2)))(eslint@9.10.0(jiti@2.1.2)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@7.18.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint@9.12.0(jiti@2.3.3)) hasown: 2.0.2 - is-core-module: 2.15.0 + is-core-module: 2.15.1 is-glob: 4.0.3 minimatch: 3.1.2 object.fromentries: 2.0.8 object.groupby: 1.0.3 object.values: 1.2.0 semver: 6.3.1 + string.prototype.trimend: 1.0.8 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) + '@typescript-eslint/parser': 6.21.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-import@2.29.1(@typescript-eslint/parser@7.16.1(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint@9.10.0(jiti@2.1.2)): + eslint-plugin-import@2.31.0(@typescript-eslint/parser@7.18.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint@9.12.0(jiti@2.3.3)): dependencies: + '@rtsao/scc': 1.1.0 array-includes: 3.1.8 array.prototype.findlastindex: 1.2.5 array.prototype.flat: 1.3.2 array.prototype.flatmap: 1.3.2 debug: 3.2.7 doctrine: 2.1.0 - eslint: 9.10.0(jiti@2.1.2) + eslint: 9.12.0(jiti@2.3.3) eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@6.21.0(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2))(eslint-plugin-import@2.29.1)(eslint@9.10.0(jiti@2.1.2)))(eslint@9.10.0(jiti@2.1.2)) + eslint-module-utils: 2.12.0(@typescript-eslint/parser@7.18.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3))(eslint-import-resolver-node@0.3.9)(eslint@9.12.0(jiti@2.3.3)) hasown: 2.0.2 - is-core-module: 2.15.0 + is-core-module: 2.15.1 is-glob: 4.0.3 minimatch: 3.1.2 object.fromentries: 2.0.8 object.groupby: 1.0.3 object.values: 1.2.0 semver: 6.3.1 + string.prototype.trimend: 1.0.8 tsconfig-paths: 3.15.0 optionalDependencies: - '@typescript-eslint/parser': 7.16.1(eslint@9.10.0(jiti@2.1.2))(typescript@5.6.2) + '@typescript-eslint/parser': 7.18.0(eslint@9.12.0(jiti@2.3.3))(typescript@5.6.3) transitivePeerDependencies: - eslint-import-resolver-typescript - eslint-import-resolver-webpack - supports-color - eslint-plugin-jsdoc@50.2.2(eslint@9.10.0(jiti@2.1.2)): + eslint-plugin-jsdoc@50.4.1(eslint@9.12.0(jiti@2.3.3)): dependencies: - '@es-joy/jsdoccomment': 0.48.0 + '@es-joy/jsdoccomment': 0.49.0 are-docs-informative: 0.0.2 comment-parser: 1.4.1 debug: 4.3.7 escape-string-regexp: 4.0.0 - eslint: 9.10.0(jiti@2.1.2) - espree: 10.1.0 + eslint: 9.12.0(jiti@2.3.3) + espree: 10.2.0 esquery: 1.6.0 - parse-imports: 2.1.1 + parse-imports: 2.2.1 semver: 7.6.3 spdx-expression-parse: 4.0.0 - synckit: 0.9.1 + synckit: 0.9.2 transitivePeerDependencies: - supports-color - eslint-plugin-n@15.7.0(eslint@9.10.0(jiti@2.1.2)): + eslint-plugin-n@15.7.0(eslint@9.12.0(jiti@2.3.3)): dependencies: builtins: 5.1.0 - eslint: 9.10.0(jiti@2.1.2) - eslint-plugin-es: 4.1.0(eslint@9.10.0(jiti@2.1.2)) - eslint-utils: 3.0.0(eslint@9.10.0(jiti@2.1.2)) + eslint: 9.12.0(jiti@2.3.3) + eslint-plugin-es: 4.1.0(eslint@9.12.0(jiti@2.3.3)) + eslint-utils: 3.0.0(eslint@9.12.0(jiti@2.3.3)) ignore: 5.3.2 - is-core-module: 2.15.0 + is-core-module: 2.15.1 minimatch: 3.1.2 resolve: 1.22.8 semver: 7.6.3 - eslint-plugin-node@11.1.0(eslint@9.10.0(jiti@2.1.2)): + eslint-plugin-node@11.1.0(eslint@9.12.0(jiti@2.3.3)): dependencies: - eslint: 9.10.0(jiti@2.1.2) - eslint-plugin-es: 3.0.1(eslint@9.10.0(jiti@2.1.2)) + eslint: 9.12.0(jiti@2.3.3) + eslint-plugin-es: 3.0.1(eslint@9.12.0(jiti@2.3.3)) eslint-utils: 2.1.0 ignore: 5.3.2 minimatch: 3.1.2 resolve: 1.22.8 semver: 6.3.1 - eslint-plugin-prettier@5.2.1(@types/eslint@9.6.1)(eslint-config-prettier@9.1.0(eslint@9.10.0(jiti@2.1.2)))(eslint@9.10.0(jiti@2.1.2))(prettier@3.3.3): + eslint-plugin-prettier@5.2.1(eslint-config-prettier@9.1.0(eslint@9.12.0(jiti@2.3.3)))(eslint@9.12.0(jiti@2.3.3))(prettier@3.3.3): dependencies: - eslint: 9.10.0(jiti@2.1.2) + eslint: 9.12.0(jiti@2.3.3) prettier: 3.3.3 prettier-linter-helpers: 1.0.0 - synckit: 0.9.1 + synckit: 0.9.2 optionalDependencies: - '@types/eslint': 9.6.1 - eslint-config-prettier: 9.1.0(eslint@9.10.0(jiti@2.1.2)) + eslint-config-prettier: 9.1.0(eslint@9.12.0(jiti@2.3.3)) - eslint-plugin-promise@6.4.0(eslint@9.10.0(jiti@2.1.2)): + eslint-plugin-promise@6.6.0(eslint@9.12.0(jiti@2.3.3)): dependencies: - eslint: 9.10.0(jiti@2.1.2) + eslint: 9.12.0(jiti@2.3.3) - eslint-plugin-regexp@2.6.0(eslint@9.10.0(jiti@2.1.2)): + eslint-plugin-regexp@2.6.0(eslint@9.12.0(jiti@2.3.3)): dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@9.10.0(jiti@2.1.2)) - '@eslint-community/regexpp': 4.11.0 + '@eslint-community/eslint-utils': 4.4.0(eslint@9.12.0(jiti@2.3.3)) + '@eslint-community/regexpp': 4.11.1 comment-parser: 1.4.1 - eslint: 9.10.0(jiti@2.1.2) + eslint: 9.12.0(jiti@2.3.3) jsdoc-type-pratt-parser: 4.1.0 refa: 0.12.1 regexp-ast-analysis: 0.7.1 scslre: 0.3.0 - eslint-plugin-turbo@2.1.1(eslint@9.10.0(jiti@2.1.2)): + eslint-plugin-turbo@2.1.3(eslint@9.12.0(jiti@2.3.3)): dependencies: dotenv: 16.0.3 - eslint: 9.10.0(jiti@2.1.2) + eslint: 9.12.0(jiti@2.3.3) - eslint-plugin-unicorn@44.0.2(eslint@9.10.0(jiti@2.1.2)): + eslint-plugin-unicorn@44.0.2(eslint@9.12.0(jiti@2.3.3)): dependencies: - '@babel/helper-validator-identifier': 7.24.7 + '@babel/helper-validator-identifier': 7.25.7 ci-info: 3.9.0 clean-regexp: 1.0.0 - eslint: 9.10.0(jiti@2.1.2) - eslint-utils: 3.0.0(eslint@9.10.0(jiti@2.1.2)) + eslint: 9.12.0(jiti@2.3.3) + eslint-utils: 3.0.0(eslint@9.12.0(jiti@2.3.3)) esquery: 1.6.0 indent-string: 4.0.0 is-builtin-module: 3.2.1 @@ -9630,15 +11130,15 @@ snapshots: semver: 7.6.3 strip-indent: 3.0.0 - eslint-plugin-unicorn@54.0.0(eslint@9.10.0(jiti@2.1.2)): + eslint-plugin-unicorn@54.0.0(eslint@9.12.0(jiti@2.3.3)): dependencies: - '@babel/helper-validator-identifier': 7.24.7 - '@eslint-community/eslint-utils': 4.4.0(eslint@9.10.0(jiti@2.1.2)) + '@babel/helper-validator-identifier': 7.25.7 + '@eslint-community/eslint-utils': 4.4.0(eslint@9.12.0(jiti@2.3.3)) '@eslint/eslintrc': 3.1.0 ci-info: 4.0.0 clean-regexp: 1.0.0 core-js-compat: 3.38.1 - eslint: 9.10.0(jiti@2.1.2) + eslint: 9.12.0(jiti@2.3.3) esquery: 1.6.0 indent-string: 4.0.0 is-builtin-module: 3.2.1 @@ -9652,16 +11152,16 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-unicorn@55.0.0(eslint@9.10.0(jiti@2.1.2)): + eslint-plugin-unicorn@55.0.0(eslint@9.12.0(jiti@2.3.3)): dependencies: - '@babel/helper-validator-identifier': 7.24.7 - '@eslint-community/eslint-utils': 4.4.0(eslint@9.10.0(jiti@2.1.2)) + '@babel/helper-validator-identifier': 7.25.7 + '@eslint-community/eslint-utils': 4.4.0(eslint@9.12.0(jiti@2.3.3)) ci-info: 4.0.0 clean-regexp: 1.0.0 core-js-compat: 3.38.1 - eslint: 9.10.0(jiti@2.1.2) + eslint: 9.12.0(jiti@2.3.3) esquery: 1.6.0 - globals: 15.9.0 + globals: 15.11.0 indent-string: 4.0.0 is-builtin-module: 3.2.1 jsesc: 3.0.2 @@ -9672,46 +11172,26 @@ snapshots: semver: 7.6.3 strip-indent: 3.0.0 - eslint-plugin-vue@9.27.0(eslint@9.10.0(jiti@2.1.2)): + eslint-plugin-vue@9.29.0(eslint@9.12.0(jiti@2.3.3)): dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@9.10.0(jiti@2.1.2)) - eslint: 9.10.0(jiti@2.1.2) - globals: 13.24.0 - natural-compare: 1.4.0 - nth-check: 2.1.1 - postcss-selector-parser: 6.1.1 - semver: 7.6.3 - vue-eslint-parser: 9.4.3(eslint@9.10.0(jiti@2.1.2)) - xml-name-validator: 4.0.0 - transitivePeerDependencies: - - supports-color - - eslint-plugin-vue@9.28.0(eslint@9.10.0(jiti@2.1.2)): - dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@9.10.0(jiti@2.1.2)) - eslint: 9.10.0(jiti@2.1.2) + '@eslint-community/eslint-utils': 4.4.0(eslint@9.12.0(jiti@2.3.3)) + eslint: 9.12.0(jiti@2.3.3) globals: 13.24.0 natural-compare: 1.4.0 nth-check: 2.1.1 postcss-selector-parser: 6.1.2 semver: 7.6.3 - vue-eslint-parser: 9.4.3(eslint@9.10.0(jiti@2.1.2)) + vue-eslint-parser: 9.4.3(eslint@9.12.0(jiti@2.3.3)) xml-name-validator: 4.0.0 transitivePeerDependencies: - supports-color - eslint-scope@5.1.1: - dependencies: - esrecurse: 4.3.0 - estraverse: 4.3.0 - optional: true - eslint-scope@7.2.2: dependencies: esrecurse: 4.3.0 estraverse: 5.3.0 - eslint-scope@8.0.2: + eslint-scope@8.1.0: dependencies: esrecurse: 4.3.0 estraverse: 5.3.0 @@ -9720,9 +11200,9 @@ snapshots: dependencies: eslint-visitor-keys: 1.3.0 - eslint-utils@3.0.0(eslint@9.10.0(jiti@2.1.2)): + eslint-utils@3.0.0(eslint@9.12.0(jiti@2.3.3)): dependencies: - eslint: 9.10.0(jiti@2.1.2) + eslint: 9.12.0(jiti@2.3.3) eslint-visitor-keys: 2.1.0 eslint-visitor-keys@1.3.0: {} @@ -9731,15 +11211,15 @@ snapshots: eslint-visitor-keys@3.4.3: {} - eslint-visitor-keys@4.0.0: {} + eslint-visitor-keys@4.1.0: {} - eslint@8.57.0: + eslint@8.57.1: dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.0) - '@eslint-community/regexpp': 4.11.0 + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) + '@eslint-community/regexpp': 4.11.1 '@eslint/eslintrc': 2.1.4 - '@eslint/js': 8.57.0 - '@humanwhocodes/config-array': 0.11.14 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 '@humanwhocodes/module-importer': 1.0.1 '@nodelib/fs.walk': 1.2.8 '@ungap/structured-clone': 1.2.0 @@ -9776,25 +11256,28 @@ snapshots: transitivePeerDependencies: - supports-color - eslint@9.10.0(jiti@2.1.2): + eslint@9.12.0(jiti@2.3.3): dependencies: - '@eslint-community/eslint-utils': 4.4.0(eslint@9.10.0(jiti@2.1.2)) - '@eslint-community/regexpp': 4.11.0 + '@eslint-community/eslint-utils': 4.4.0(eslint@9.12.0(jiti@2.3.3)) + '@eslint-community/regexpp': 4.11.1 '@eslint/config-array': 0.18.0 + '@eslint/core': 0.6.0 '@eslint/eslintrc': 3.1.0 - '@eslint/js': 9.10.0 - '@eslint/plugin-kit': 0.1.0 + '@eslint/js': 9.12.0 + '@eslint/plugin-kit': 0.2.0 + '@humanfs/node': 0.16.5 '@humanwhocodes/module-importer': 1.0.1 - '@humanwhocodes/retry': 0.3.0 - '@nodelib/fs.walk': 1.2.8 + '@humanwhocodes/retry': 0.3.1 + '@types/estree': 1.0.6 + '@types/json-schema': 7.0.15 ajv: 6.12.6 chalk: 4.1.2 cross-spawn: 7.0.3 debug: 4.3.7 escape-string-regexp: 4.0.0 - eslint-scope: 8.0.2 - eslint-visitor-keys: 4.0.0 - espree: 10.1.0 + eslint-scope: 8.1.0 + eslint-visitor-keys: 4.1.0 + espree: 10.2.0 esquery: 1.6.0 esutils: 2.0.3 fast-deep-equal: 3.1.3 @@ -9804,24 +11287,22 @@ snapshots: ignore: 5.3.2 imurmurhash: 0.1.4 is-glob: 4.0.3 - is-path-inside: 3.0.3 json-stable-stringify-without-jsonify: 1.0.1 lodash.merge: 4.6.2 minimatch: 3.1.2 natural-compare: 1.4.0 optionator: 0.9.4 - strip-ansi: 6.0.1 text-table: 0.2.0 optionalDependencies: - jiti: 2.1.2 + jiti: 2.3.3 transitivePeerDependencies: - supports-color - espree@10.1.0: + espree@10.2.0: dependencies: acorn: 8.12.1 acorn-jsx: 5.3.2(acorn@8.12.1) - eslint-visitor-keys: 4.0.0 + eslint-visitor-keys: 4.1.0 espree@9.6.1: dependencies: @@ -9829,6 +11310,8 @@ snapshots: acorn-jsx: 5.3.2(acorn@8.12.1) eslint-visitor-keys: 3.4.3 + esprima@4.0.1: {} + esquery@1.6.0: dependencies: estraverse: 5.3.0 @@ -9837,11 +11320,37 @@ snapshots: dependencies: estraverse: 5.3.0 - estraverse@4.3.0: - optional: true - estraverse@5.3.0: {} + estree-util-attach-comments@3.0.0: + dependencies: + '@types/estree': 1.0.6 + + estree-util-build-jsx@3.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + estree-walker: 3.0.3 + + estree-util-is-identifier-name@3.0.0: {} + + estree-util-scope@1.0.0: + dependencies: + '@types/estree': 1.0.6 + devlop: 1.1.0 + + estree-util-to-js@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + astring: 1.9.0 + source-map: 0.7.4 + + estree-util-visit@2.0.0: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/unist': 3.0.3 + estree-walker@2.0.2: {} estree-walker@3.0.3: @@ -9854,6 +11363,8 @@ snapshots: event-target-shim@5.0.1: {} + eventemitter3@5.0.1: {} + events@3.3.0: {} execa@7.2.0: @@ -9880,10 +11391,25 @@ snapshots: signal-exit: 4.1.0 strip-final-newline: 3.0.0 + expand-template@2.0.3: {} + + expressive-code@0.35.6: + dependencies: + '@expressive-code/core': 0.35.6 + '@expressive-code/plugin-frames': 0.35.6 + '@expressive-code/plugin-shiki': 0.35.6 + '@expressive-code/plugin-text-markers': 0.35.6 + + extend-shallow@2.0.1: + dependencies: + is-extendable: 0.1.1 + + extend@3.0.2: {} + externality@1.0.2: dependencies: enhanced-resolve: 5.17.1 - mlly: 1.7.1 + mlly: 1.7.2 pathe: 1.1.2 ufo: 1.5.4 @@ -9899,7 +11425,7 @@ snapshots: '@nodelib/fs.walk': 1.2.8 glob-parent: 5.1.2 merge2: 1.4.1 - micromatch: 4.0.7 + micromatch: 4.0.8 fast-json-stable-stringify@2.1.0: {} @@ -9907,6 +11433,8 @@ snapshots: fast-npm-meta@0.2.2: {} + fast-uri@3.0.3: {} + fastq@1.17.1: dependencies: reusify: 1.0.4 @@ -9943,6 +11471,11 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 + find-yarn-workspace-root2@1.2.16: + dependencies: + micromatch: 4.0.8 + pkg-dir: 4.2.0 + flat-cache@3.2.0: dependencies: flatted: 3.3.1 @@ -9956,27 +11489,29 @@ snapshots: flatted@3.3.1: {} - floating-vue@2.0.0-beta.20(vue@3.5.11(typescript@5.6.2)): + flattie@1.1.1: {} + + floating-vue@2.0.0-beta.20(vue@3.5.12(typescript@5.6.3)): dependencies: '@floating-ui/dom': 0.1.10 - vue: 3.5.11(typescript@5.6.2) - vue-resize: 2.0.0-alpha.1(vue@3.5.11(typescript@5.6.2)) + vue: 3.5.12(typescript@5.6.3) + vue-resize: 2.0.0-alpha.1(vue@3.5.12(typescript@5.6.3)) - floating-vue@2.0.0-beta.24(@nuxt/kit@3.13.2(magicast@0.3.5)(webpack-sources@3.2.3))(vue@3.5.4(typescript@5.6.2)): + floating-vue@2.0.0-beta.24(@nuxt/kit@3.13.2(magicast@0.3.5))(vue@3.5.12(typescript@5.6.3)): dependencies: '@floating-ui/dom': 1.1.1 - vue: 3.5.4(typescript@5.6.2) - vue-resize: 2.0.0-alpha.1(vue@3.5.4(typescript@5.6.2)) + vue: 3.5.12(typescript@5.6.3) + vue-resize: 2.0.0-alpha.1(vue@3.5.12(typescript@5.6.3)) optionalDependencies: - '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.24.0)(webpack-sources@3.2.3) + '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.24.0) - floating-vue@5.2.2(@nuxt/kit@3.13.2(magicast@0.3.5))(vue@3.5.4(typescript@5.6.2)): + floating-vue@5.2.2(@nuxt/kit@3.13.2(magicast@0.3.5))(vue@3.5.12(typescript@5.6.3)): dependencies: '@floating-ui/dom': 1.1.1 - vue: 3.5.4(typescript@5.6.2) - vue-resize: 2.0.0-alpha.1(vue@3.5.4(typescript@5.6.2)) + vue: 3.5.12(typescript@5.6.3) + vue-resize: 2.0.0-alpha.1(vue@3.5.12(typescript@5.6.3)) optionalDependencies: - '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.24.0)(webpack-sources@3.2.3) + '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.24.0) for-each@0.3.3: dependencies: @@ -9991,6 +11526,8 @@ snapshots: fresh@0.5.2: {} + fs-constants@1.0.0: {} + fs-extra@10.1.0: dependencies: graceful-fs: 4.2.11 @@ -10041,6 +11578,8 @@ snapshots: get-caller-file@2.0.5: {} + get-east-asian-width@1.3.0: {} + get-intrinsic@1.2.4: dependencies: es-errors: 1.3.0 @@ -10061,11 +11600,7 @@ snapshots: es-errors: 1.3.0 get-intrinsic: 1.2.4 - get-tsconfig@4.7.5: - dependencies: - resolve-pkg-maps: 1.0.0 - - get-tsconfig@4.8.0: + get-tsconfig@4.8.1: dependencies: resolve-pkg-maps: 1.0.0 @@ -10091,6 +11626,10 @@ snapshots: dependencies: git-up: 7.0.0 + github-from-package@0.0.0: {} + + github-slugger@2.0.0: {} + glob-parent@5.1.2: dependencies: is-glob: 4.0.3 @@ -10099,16 +11638,13 @@ snapshots: dependencies: is-glob: 4.0.3 - glob-to-regexp@0.4.1: - optional: true - glob@10.4.5: dependencies: foreground-child: 3.3.0 jackspeak: 3.4.3 minimatch: 9.0.5 minipass: 7.1.2 - package-json-from-dist: 1.0.0 + package-json-from-dist: 1.0.1 path-scurry: 1.11.1 glob@7.2.3: @@ -10140,7 +11676,7 @@ snapshots: globals@14.0.0: {} - globals@15.9.0: {} + globals@15.11.0: {} globalthis@1.0.4: dependencies: @@ -10171,8 +11707,17 @@ snapshots: graceful-fs@4.2.11: {} + grapheme-splitter@1.0.4: {} + graphemer@1.4.0: {} + gray-matter@4.0.3: + dependencies: + js-yaml: 3.14.1 + kind-of: 6.0.3 + section-matter: 1.0.0 + strip-bom-string: 1.0.0 + gzip-size@7.0.0: dependencies: duplexer: 0.1.2 @@ -10216,6 +11761,203 @@ snapshots: dependencies: function-bind: 1.1.2 + hast-util-embedded@3.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-is-element: 3.0.0 + + hast-util-format@1.1.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-embedded: 3.0.0 + hast-util-minify-whitespace: 1.0.1 + hast-util-phrasing: 3.0.1 + hast-util-whitespace: 3.0.0 + html-whitespace-sensitive-tag-names: 3.0.1 + unist-util-visit-parents: 6.0.1 + + hast-util-from-html@2.0.3: + dependencies: + '@types/hast': 3.0.4 + devlop: 1.1.0 + hast-util-from-parse5: 8.0.1 + parse5: 7.2.0 + vfile: 6.0.3 + vfile-message: 4.0.2 + + hast-util-from-parse5@8.0.1: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + hastscript: 8.0.0 + property-information: 6.5.0 + vfile: 6.0.3 + vfile-location: 5.0.3 + web-namespaces: 2.0.1 + + hast-util-has-property@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-is-body-ok-link@3.0.1: + dependencies: + '@types/hast': 3.0.4 + + hast-util-is-element@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-minify-whitespace@1.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-embedded: 3.0.0 + hast-util-is-element: 3.0.0 + hast-util-whitespace: 3.0.0 + unist-util-is: 6.0.0 + + hast-util-parse-selector@4.0.0: + dependencies: + '@types/hast': 3.0.4 + + hast-util-phrasing@3.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-embedded: 3.0.0 + hast-util-has-property: 3.0.0 + hast-util-is-body-ok-link: 3.0.1 + hast-util-is-element: 3.0.0 + + hast-util-raw@9.0.4: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + '@ungap/structured-clone': 1.2.0 + hast-util-from-parse5: 8.0.1 + hast-util-to-parse5: 8.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.0 + parse5: 7.2.0 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-select@6.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + bcp-47-match: 2.0.3 + comma-separated-tokens: 2.0.3 + css-selector-parser: 3.0.5 + devlop: 1.1.0 + direction: 2.0.1 + hast-util-has-property: 3.0.0 + hast-util-to-string: 3.0.1 + hast-util-whitespace: 3.0.0 + nth-check: 2.1.1 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + unist-util-visit: 5.0.0 + zwitch: 2.0.4 + + hast-util-to-estree@3.1.0: + dependencies: + '@types/estree': 1.0.6 + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-attach-comments: 3.0.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.1.3 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + style-to-object: 0.4.4 + unist-util-position: 5.0.0 + zwitch: 2.0.4 + transitivePeerDependencies: + - supports-color + + hast-util-to-html@9.0.3: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.0 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-to-jsx-runtime@2.3.2: + dependencies: + '@types/estree': 1.0.6 + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + hast-util-whitespace: 3.0.0 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.1.3 + mdast-util-mdxjs-esm: 2.0.1 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + style-to-object: 1.0.8 + unist-util-position: 5.0.0 + vfile-message: 4.0.2 + transitivePeerDependencies: + - supports-color + + hast-util-to-parse5@8.0.0: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + devlop: 1.1.0 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + web-namespaces: 2.0.1 + zwitch: 2.0.4 + + hast-util-to-string@3.0.1: + dependencies: + '@types/hast': 3.0.4 + + hast-util-to-text@4.0.2: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + hast-util-is-element: 3.0.0 + unist-util-find-after: 5.0.0 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hastscript@8.0.0: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + + hastscript@9.0.0: + dependencies: + '@types/hast': 3.0.4 + comma-separated-tokens: 2.0.3 + hast-util-parse-selector: 4.0.0 + property-information: 6.5.0 + space-separated-tokens: 2.0.2 + he@1.2.0: {} highlight.js@11.10.0: {} @@ -10224,8 +11966,16 @@ snapshots: hosted-git-info@2.8.9: {} + html-escaper@3.0.3: {} + html-tags@3.3.1: {} + html-void-elements@3.0.0: {} + + html-whitespace-sensitive-tag-names@3.0.1: {} + + http-cache-semantics@4.1.1: {} + http-errors@2.0.0: dependencies: depd: 2.0.0 @@ -10251,15 +12001,13 @@ snapshots: ieee754@1.2.1: {} - ignore@5.3.1: {} - ignore@5.3.2: {} image-meta@0.2.1: {} immediate@3.0.6: {} - immutable@4.3.6: {} + immutable@4.3.7: {} import-fresh@3.3.0: dependencies: @@ -10268,13 +12016,13 @@ snapshots: import-meta-resolve@4.1.0: {} - impound@0.1.0(rollup@4.24.0)(webpack-sources@3.2.3): + impound@0.1.0(rollup@4.24.0): dependencies: '@rollup/pluginutils': 5.1.2(rollup@4.24.0) - mlly: 1.7.1 + mlly: 1.7.2 pathe: 1.1.2 unenv: 1.10.0 - unplugin: 1.14.1(webpack-sources@3.2.3) + unplugin: 1.14.1 transitivePeerDependencies: - rollup - webpack-sources @@ -10294,18 +12042,22 @@ snapshots: ini@4.1.1: {} + inline-style-parser@0.1.1: {} + + inline-style-parser@0.2.4: {} + internal-slot@1.0.7: dependencies: es-errors: 1.3.0 hasown: 2.0.2 side-channel: 1.0.6 - intl-messageformat@10.5.14: + intl-messageformat@10.7.0: dependencies: - '@formatjs/ecma402-abstract': 2.0.0 - '@formatjs/fast-memoize': 2.2.0 - '@formatjs/icu-messageformat-parser': 2.7.8 - tslib: 2.6.3 + '@formatjs/ecma402-abstract': 2.2.0 + '@formatjs/fast-memoize': 2.2.1 + '@formatjs/icu-messageformat-parser': 2.7.10 + tslib: 2.8.0 ioredis@5.4.1: dependencies: @@ -10323,6 +12075,13 @@ snapshots: iron-webcrypto@1.2.1: {} + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + is-array-buffer@3.0.4: dependencies: call-bind: 1.0.7 @@ -10330,6 +12089,8 @@ snapshots: is-arrayish@0.2.1: {} + is-arrayish@0.3.2: {} + is-bigint@1.0.4: dependencies: has-bigints: 1.0.2 @@ -10347,13 +12108,13 @@ snapshots: dependencies: builtin-modules: 3.3.0 + is-bun-module@1.2.1: + dependencies: + semver: 7.6.3 + is-callable@1.2.7: {} - is-core-module@2.14.0: - dependencies: - hasown: 2.0.2 - - is-core-module@2.15.0: + is-core-module@2.15.1: dependencies: hasown: 2.0.2 @@ -10365,10 +12126,14 @@ snapshots: dependencies: has-tostringtag: 1.0.2 + is-decimal@2.0.1: {} + is-docker@2.2.1: {} is-docker@3.0.0: {} + is-extendable@0.1.1: {} + is-extglob@2.1.1: {} is-fullwidth-code-point@3.0.0: {} @@ -10377,6 +12142,8 @@ snapshots: dependencies: is-extglob: 2.1.1 + is-hexadecimal@2.0.1: {} + is-inside-container@1.0.0: dependencies: is-docker: 3.0.0 @@ -10386,6 +12153,8 @@ snapshots: global-directory: 4.0.1 is-path-inside: 4.0.0 + is-interactive@2.0.0: {} + is-module@1.0.0: {} is-negative-zero@2.0.3: {} @@ -10400,6 +12169,8 @@ snapshots: is-path-inside@4.0.0: {} + is-plain-obj@4.1.0: {} + is-reference@1.2.1: dependencies: '@types/estree': 1.0.6 @@ -10433,6 +12204,10 @@ snapshots: dependencies: which-typed-array: 1.1.15 + is-unicode-supported@1.3.0: {} + + is-unicode-supported@2.1.0: {} + is-weakref@1.0.2: dependencies: call-bind: 1.0.7 @@ -10465,21 +12240,19 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 - jest-worker@27.5.1: - dependencies: - '@types/node': 22.7.4 - merge-stream: 2.0.0 - supports-color: 8.1.1 - optional: true - jiti@1.21.6: {} - jiti@2.1.2: {} + jiti@2.3.3: {} js-tokens@4.0.0: {} js-tokens@9.0.0: {} + js-yaml@3.14.1: + dependencies: + argparse: 1.0.10 + esprima: 4.0.1 + js-yaml@4.1.0: dependencies: argparse: 2.0.1 @@ -10488,8 +12261,6 @@ snapshots: jsesc@0.5.0: {} - jsesc@2.5.2: {} - jsesc@3.0.2: {} json-buffer@3.0.1: {} @@ -10498,6 +12269,8 @@ snapshots: json-schema-traverse@0.4.1: {} + json-schema-traverse@1.0.0: {} + json-stable-stringify-without-jsonify@1.0.1: {} json-stable-stringify@1.1.1: @@ -10507,12 +12280,21 @@ snapshots: jsonify: 0.0.1 object-keys: 1.1.1 + json-to-ast@2.1.0: + dependencies: + code-error-fragment: 0.0.230 + grapheme-splitter: 1.0.4 + json5@1.0.2: dependencies: minimist: 1.2.8 json5@2.2.3: {} + jsonc-parser@2.3.1: {} + + jsonc-parser@3.3.1: {} + jsonfile@6.1.0: dependencies: universalify: 2.0.1 @@ -10521,6 +12303,8 @@ snapshots: jsonify@0.0.1: {} + jsonpointer@5.0.1: {} + jszip@3.10.1: dependencies: lie: 3.3.0 @@ -10532,8 +12316,12 @@ snapshots: dependencies: json-buffer: 3.0.1 + kind-of@6.0.3: {} + kleur@3.0.3: {} + kleur@4.1.5: {} + klona@2.0.6: {} knitwork@1.1.0: {} @@ -10549,6 +12337,8 @@ snapshots: dependencies: readable-stream: 2.3.8(patch_hash=h52dazg37p4h3yox67pw36akse) + leven@3.1.0: {} + levn@0.4.1: dependencies: prelude-ls: 1.2.1 @@ -10584,8 +12374,8 @@ snapshots: get-port-please: 3.1.2 h3: 1.13.0 http-shutdown: 1.2.2 - jiti: 2.1.2 - mlly: 1.7.1 + jiti: 2.3.3 + mlly: 1.7.2 node-forge: 1.3.1 pathe: 1.1.2 std-env: 3.7.0 @@ -10595,15 +12385,19 @@ snapshots: transitivePeerDependencies: - uWebSockets.js - loader-runner@4.3.0: - optional: true + load-yaml-file@0.2.0: + dependencies: + graceful-fs: 4.2.11 + js-yaml: 3.14.1 + pify: 4.0.1 + strip-bom: 3.0.0 local-pkg@0.4.3: {} local-pkg@0.5.0: dependencies: - mlly: 1.7.1 - pkg-types: 1.2.0 + mlly: 1.7.2 + pkg-types: 1.2.1 locate-path@5.0.0: dependencies: @@ -10625,6 +12419,13 @@ snapshots: lodash@4.17.21: {} + log-symbols@6.0.0: + dependencies: + chalk: 5.3.0 + is-unicode-supported: 1.3.0 + + longest-streak@3.1.0: {} + loud-rejection@2.2.0: dependencies: currently-unhandled: 0.4.1 @@ -10638,22 +12439,24 @@ snapshots: magic-string-ast@0.6.2: dependencies: - magic-string: 0.30.11 + magic-string: 0.30.12 - magic-string@0.30.11: + magic-string@0.30.12: dependencies: '@jridgewell/sourcemap-codec': 1.5.0 magicast@0.3.5: dependencies: - '@babel/parser': 7.25.7 - '@babel/types': 7.25.7 + '@babel/parser': 7.25.8 + '@babel/types': 7.25.8 source-map-js: 1.2.1 make-dir@3.1.0: dependencies: semver: 6.3.1 + markdown-extensions@2.0.0: {} + markdown-it@13.0.2: dependencies: argparse: 2.0.1 @@ -10671,6 +12474,189 @@ snapshots: punycode.js: 2.3.1 uc.micro: 2.1.0 + markdown-table@3.0.3: {} + + mdast-util-definitions@6.0.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + unist-util-visit: 5.0.0 + + mdast-util-directive@3.0.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.1 + mdast-util-to-markdown: 2.1.0 + parse-entities: 4.0.1 + stringify-entities: 4.0.4 + unist-util-visit-parents: 6.0.1 + transitivePeerDependencies: + - supports-color + + mdast-util-find-and-replace@3.0.1: + dependencies: + '@types/mdast': 4.0.4 + escape-string-regexp: 5.0.0 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.1 + + mdast-util-from-markdown@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + decode-named-character-reference: 1.0.2 + devlop: 1.1.0 + mdast-util-to-string: 4.0.0 + micromark: 4.0.0 + micromark-util-decode-numeric-character-reference: 2.0.1 + micromark-util-decode-string: 2.0.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + unist-util-stringify-position: 4.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-autolink-literal@2.0.1: + dependencies: + '@types/mdast': 4.0.4 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-find-and-replace: 3.0.1 + micromark-util-character: 2.1.0 + + mdast-util-gfm-footnote@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.1 + mdast-util-to-markdown: 2.1.0 + micromark-util-normalize-identifier: 2.0.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-strikethrough@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.1 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-table@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + markdown-table: 3.0.3 + mdast-util-from-markdown: 2.0.1 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm-task-list-item@2.0.0: + dependencies: + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.1 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-gfm@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.1 + mdast-util-gfm-autolink-literal: 2.0.1 + mdast-util-gfm-footnote: 2.0.0 + mdast-util-gfm-strikethrough: 2.0.0 + mdast-util-gfm-table: 2.0.0 + mdast-util-gfm-task-list-item: 2.0.0 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-expression@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.1 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx-jsx@3.1.3: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.1 + mdast-util-to-markdown: 2.1.0 + parse-entities: 4.0.1 + stringify-entities: 4.0.4 + unist-util-stringify-position: 4.0.0 + vfile-message: 4.0.2 + transitivePeerDependencies: + - supports-color + + mdast-util-mdx@3.0.0: + dependencies: + mdast-util-from-markdown: 2.0.1 + mdast-util-mdx-expression: 2.0.1 + mdast-util-mdx-jsx: 3.1.3 + mdast-util-mdxjs-esm: 2.0.1 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-mdxjs-esm@2.0.1: + dependencies: + '@types/estree-jsx': 1.0.5 + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + devlop: 1.1.0 + mdast-util-from-markdown: 2.0.1 + mdast-util-to-markdown: 2.1.0 + transitivePeerDependencies: + - supports-color + + mdast-util-phrasing@4.1.0: + dependencies: + '@types/mdast': 4.0.4 + unist-util-is: 6.0.0 + + mdast-util-to-hast@13.2.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.2.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.0 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + + mdast-util-to-markdown@2.1.0: + dependencies: + '@types/mdast': 4.0.4 + '@types/unist': 3.0.3 + longest-streak: 3.1.0 + mdast-util-phrasing: 4.1.0 + mdast-util-to-string: 4.0.0 + micromark-util-decode-string: 2.0.0 + unist-util-visit: 5.0.0 + zwitch: 2.0.4 + + mdast-util-to-string@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdn-data@2.0.28: {} mdn-data@2.0.30: {} @@ -10683,24 +12669,287 @@ snapshots: merge2@1.4.1: {} - micromatch@4.0.7: + micromark-core-commonmark@2.0.1: dependencies: - braces: 3.0.3 - picomatch: 2.3.1 + decode-named-character-reference: 1.0.2 + devlop: 1.1.0 + micromark-factory-destination: 2.0.0 + micromark-factory-label: 2.0.0 + micromark-factory-space: 2.0.0 + micromark-factory-title: 2.0.0 + micromark-factory-whitespace: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-classify-character: 2.0.0 + micromark-util-html-tag-name: 2.0.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-resolve-all: 2.0.0 + micromark-util-subtokenize: 2.0.1 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-directive@3.0.2: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.0 + micromark-factory-whitespace: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + parse-entities: 4.0.1 + + micromark-extension-gfm-autolink-literal@2.1.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-sanitize-uri: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-footnote@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-core-commonmark: 2.0.1 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-sanitize-uri: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-strikethrough@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-classify-character: 2.0.0 + micromark-util-resolve-all: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-table@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm-tagfilter@2.0.0: + dependencies: + micromark-util-types: 2.0.0 + + micromark-extension-gfm-task-list-item@2.1.0: + dependencies: + devlop: 1.1.0 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-gfm@3.0.0: + dependencies: + micromark-extension-gfm-autolink-literal: 2.1.0 + micromark-extension-gfm-footnote: 2.1.0 + micromark-extension-gfm-strikethrough: 2.1.0 + micromark-extension-gfm-table: 2.1.0 + micromark-extension-gfm-tagfilter: 2.0.0 + micromark-extension-gfm-task-list-item: 2.1.0 + micromark-util-combine-extensions: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-mdx-expression@3.0.0: + dependencies: + '@types/estree': 1.0.6 + devlop: 1.1.0 + micromark-factory-mdx-expression: 2.0.2 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-events-to-acorn: 2.0.2 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-extension-mdx-jsx@3.0.1: + dependencies: + '@types/acorn': 4.0.6 + '@types/estree': 1.0.6 + devlop: 1.1.0 + estree-util-is-identifier-name: 3.0.0 + micromark-factory-mdx-expression: 2.0.2 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-events-to-acorn: 2.0.2 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + vfile-message: 4.0.2 + + micromark-extension-mdx-md@2.0.0: + dependencies: + micromark-util-types: 2.0.0 + + micromark-extension-mdxjs-esm@3.0.0: + dependencies: + '@types/estree': 1.0.6 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.1 + micromark-util-character: 2.1.0 + micromark-util-events-to-acorn: 2.0.2 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.2 + + micromark-extension-mdxjs@3.0.0: + dependencies: + acorn: 8.13.0 + acorn-jsx: 5.3.2(acorn@8.13.0) + micromark-extension-mdx-expression: 3.0.0 + micromark-extension-mdx-jsx: 3.0.1 + micromark-extension-mdx-md: 2.0.0 + micromark-extension-mdxjs-esm: 3.0.0 + micromark-util-combine-extensions: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-destination@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-label@2.0.0: + dependencies: + devlop: 1.1.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-mdx-expression@2.0.2: + dependencies: + '@types/estree': 1.0.6 + devlop: 1.1.0 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-events-to-acorn: 2.0.2 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + unist-util-position-from-estree: 2.0.0 + vfile-message: 4.0.2 + + micromark-factory-space@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-types: 2.0.0 + + micromark-factory-title@2.0.0: + dependencies: + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-factory-whitespace@2.0.0: + dependencies: + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-character@2.1.0: + dependencies: + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-chunked@2.0.0: + dependencies: + micromark-util-symbol: 2.0.0 + + micromark-util-classify-character@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-combine-extensions@2.0.0: + dependencies: + micromark-util-chunked: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-decode-numeric-character-reference@2.0.1: + dependencies: + micromark-util-symbol: 2.0.0 + + micromark-util-decode-string@2.0.0: + dependencies: + decode-named-character-reference: 1.0.2 + micromark-util-character: 2.1.0 + micromark-util-decode-numeric-character-reference: 2.0.1 + micromark-util-symbol: 2.0.0 + + micromark-util-encode@2.0.0: {} + + micromark-util-events-to-acorn@2.0.2: + dependencies: + '@types/acorn': 4.0.6 + '@types/estree': 1.0.6 + '@types/unist': 3.0.3 + devlop: 1.1.0 + estree-util-visit: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + vfile-message: 4.0.2 + + micromark-util-html-tag-name@2.0.0: {} + + micromark-util-normalize-identifier@2.0.0: + dependencies: + micromark-util-symbol: 2.0.0 + + micromark-util-resolve-all@2.0.0: + dependencies: + micromark-util-types: 2.0.0 + + micromark-util-sanitize-uri@2.0.0: + dependencies: + micromark-util-character: 2.1.0 + micromark-util-encode: 2.0.0 + micromark-util-symbol: 2.0.0 + + micromark-util-subtokenize@2.0.1: + dependencies: + devlop: 1.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + + micromark-util-symbol@2.0.0: {} + + micromark-util-types@2.0.0: {} + + micromark@4.0.0: + dependencies: + '@types/debug': 4.1.12 + debug: 4.3.7 + decode-named-character-reference: 1.0.2 + devlop: 1.1.0 + micromark-core-commonmark: 2.0.1 + micromark-factory-space: 2.0.0 + micromark-util-character: 2.1.0 + micromark-util-chunked: 2.0.0 + micromark-util-combine-extensions: 2.0.0 + micromark-util-decode-numeric-character-reference: 2.0.1 + micromark-util-encode: 2.0.0 + micromark-util-normalize-identifier: 2.0.0 + micromark-util-resolve-all: 2.0.0 + micromark-util-sanitize-uri: 2.0.0 + micromark-util-subtokenize: 2.0.1 + micromark-util-symbol: 2.0.0 + micromark-util-types: 2.0.0 + transitivePeerDependencies: + - supports-color micromatch@4.0.8: dependencies: braces: 3.0.3 picomatch: 2.3.1 - mime-db@1.52.0: - optional: true - - mime-types@2.1.35: - dependencies: - mime-db: 1.52.0 - optional: true - mime@1.6.0: {} mime@3.0.0: {} @@ -10709,6 +12958,10 @@ snapshots: mimic-fn@4.0.0: {} + mimic-function@5.0.1: {} + + mimic-response@3.1.0: {} + min-indent@1.0.1: {} minimatch@3.1.2: @@ -10746,13 +12999,15 @@ snapshots: mitt@3.0.1: {} + mkdirp-classic@0.5.3: {} + mkdirp@1.0.4: {} - mlly@1.7.1: + mlly@1.7.2: dependencies: acorn: 8.12.1 pathe: 1.1.2 - pkg-types: 1.2.0 + pkg-types: 1.2.1 ufo: 1.5.4 mri@1.2.0: {} @@ -10761,8 +13016,6 @@ snapshots: ms@2.0.0: {} - ms@2.1.2: {} - ms@2.1.3: {} muggle-string@0.4.1: {} @@ -10779,12 +13032,13 @@ snapshots: nanotar@0.1.1: {} + napi-build-utils@1.0.2: {} + natural-compare@1.4.0: {} - neo-async@2.6.2: - optional: true + neotraverse@0.6.18: {} - nitropack@2.9.7(magicast@0.3.5)(webpack-sources@3.2.3): + nitropack@2.9.7(magicast@0.3.5): dependencies: '@cloudflare/kv-asset-handler': 0.3.4 '@netlify/functions': 2.8.2 @@ -10825,17 +13079,17 @@ snapshots: klona: 2.0.6 knitwork: 1.1.0 listhen: 1.9.0 - magic-string: 0.30.11 + magic-string: 0.30.12 mime: 4.0.4 - mlly: 1.7.1 + mlly: 1.7.2 mri: 1.2.0 node-fetch-native: 1.6.4 - ofetch: 1.4.0 + ofetch: 1.4.1 ohash: 1.1.4 openapi-typescript: 6.7.6 pathe: 1.1.2 perfect-debounce: 1.0.0 - pkg-types: 1.2.0 + pkg-types: 1.2.1 pretty-bytes: 6.1.1 radix3: 1.1.2 rollup: 4.24.0 @@ -10847,11 +13101,11 @@ snapshots: std-env: 3.7.0 ufo: 1.5.4 uncrypto: 0.1.3 - unctx: 2.3.1(webpack-sources@3.2.3) + unctx: 2.3.1 unenv: 1.10.0 - unimport: 3.13.1(rollup@4.24.0)(webpack-sources@3.2.3) + unimport: 3.13.1(rollup@4.24.0) unstorage: 1.12.0(ioredis@5.4.1) - unwasm: 0.3.9(webpack-sources@3.2.3) + unwasm: 0.3.9 transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -10874,6 +13128,16 @@ snapshots: - uWebSockets.js - webpack-sources + nlcst-to-string@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + + node-abi@3.71.0: + dependencies: + semver: 7.6.3 + + node-addon-api@6.1.0: {} + node-addon-api@7.1.1: {} node-fetch-native@1.6.4: {} @@ -10924,19 +13188,19 @@ snapshots: nuxi@3.14.0: {} - nuxt@3.13.2(@parcel/watcher@2.4.1)(@types/node@20.16.5)(eslint@8.57.0)(ioredis@5.4.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.24.0)(sass@1.78.0)(terser@5.34.1)(typescript@5.6.2)(vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1))(vue-tsc@2.1.6(typescript@5.6.2))(webpack-sources@3.2.3): + nuxt@3.13.2(@parcel/watcher@2.4.1)(@types/node@20.16.11)(eslint@8.57.1)(ioredis@5.4.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.24.0)(sass@1.79.5)(terser@5.34.1)(typescript@5.6.3)(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1))(vue-tsc@2.1.6(typescript@5.6.3)): dependencies: '@nuxt/devalue': 2.0.2 - '@nuxt/devtools': 1.5.2(rollup@4.24.0)(vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1))(vue@3.5.11(typescript@5.6.2))(webpack-sources@3.2.3) - '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.24.0)(webpack-sources@3.2.3) - '@nuxt/schema': 3.13.2(rollup@4.24.0)(webpack-sources@3.2.3) - '@nuxt/telemetry': 2.6.0(magicast@0.3.5)(rollup@4.24.0)(webpack-sources@3.2.3) - '@nuxt/vite-builder': 3.13.2(@types/node@20.16.5)(eslint@8.57.0)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.24.0)(sass@1.78.0)(terser@5.34.1)(typescript@5.6.2)(vue-tsc@2.1.6(typescript@5.6.2))(vue@3.5.11(typescript@5.6.2))(webpack-sources@3.2.3) - '@unhead/dom': 1.11.7 - '@unhead/shared': 1.11.7 - '@unhead/ssr': 1.11.7 - '@unhead/vue': 1.11.7(vue@3.5.11(typescript@5.6.2)) - '@vue/shared': 3.5.11 + '@nuxt/devtools': 1.6.0(rollup@4.24.0)(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1))(vue@3.5.12(typescript@5.6.3)) + '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.24.0) + '@nuxt/schema': 3.13.2(rollup@4.24.0) + '@nuxt/telemetry': 2.6.0(magicast@0.3.5)(rollup@4.24.0) + '@nuxt/vite-builder': 3.13.2(@types/node@20.16.11)(eslint@8.57.1)(magicast@0.3.5)(optionator@0.9.4)(rollup@4.24.0)(sass@1.79.5)(terser@5.34.1)(typescript@5.6.3)(vue-tsc@2.1.6(typescript@5.6.3))(vue@3.5.12(typescript@5.6.3)) + '@unhead/dom': 1.11.9 + '@unhead/shared': 1.11.9 + '@unhead/ssr': 1.11.9 + '@unhead/vue': 1.11.9(vue@3.5.12(typescript@5.6.3)) + '@vue/shared': 3.5.12 acorn: 8.12.1 c12: 1.11.2(magicast@0.3.5) chokidar: 3.6.0 @@ -10954,21 +13218,21 @@ snapshots: h3: 1.13.0 hookable: 5.5.3 ignore: 5.3.2 - impound: 0.1.0(rollup@4.24.0)(webpack-sources@3.2.3) + impound: 0.1.0(rollup@4.24.0) jiti: 1.21.6 klona: 2.0.6 knitwork: 1.1.0 - magic-string: 0.30.11 - mlly: 1.7.1 + magic-string: 0.30.12 + mlly: 1.7.2 nanotar: 0.1.1 - nitropack: 2.9.7(magicast@0.3.5)(webpack-sources@3.2.3) + nitropack: 2.9.7(magicast@0.3.5) nuxi: 3.14.0 nypm: 0.3.12 - ofetch: 1.4.0 + ofetch: 1.4.1 ohash: 1.1.4 pathe: 1.1.2 perfect-debounce: 1.0.0 - pkg-types: 1.2.0 + pkg-types: 1.2.1 radix3: 1.1.2 scule: 1.3.0 semver: 7.6.3 @@ -10978,21 +13242,21 @@ snapshots: ufo: 1.5.4 ultrahtml: 1.5.3 uncrypto: 0.1.3 - unctx: 2.3.1(webpack-sources@3.2.3) + unctx: 2.3.1 unenv: 1.10.0 - unhead: 1.11.7 - unimport: 3.13.1(rollup@4.24.0)(webpack-sources@3.2.3) - unplugin: 1.14.1(webpack-sources@3.2.3) - unplugin-vue-router: 0.10.8(rollup@4.24.0)(vue-router@4.4.5(vue@3.5.11(typescript@5.6.2)))(vue@3.5.11(typescript@5.6.2))(webpack-sources@3.2.3) + unhead: 1.11.9 + unimport: 3.13.1(rollup@4.24.0) + unplugin: 1.14.1 + unplugin-vue-router: 0.10.8(rollup@4.24.0)(vue-router@4.4.5(vue@3.5.12(typescript@5.6.3)))(vue@3.5.12(typescript@5.6.3)) unstorage: 1.12.0(ioredis@5.4.1) - untyped: 1.5.0 - vue: 3.5.11(typescript@5.6.2) + untyped: 1.5.1 + vue: 3.5.12(typescript@5.6.3) vue-bundle-renderer: 2.1.1 vue-devtools-stub: 0.1.0 - vue-router: 4.4.5(vue@3.5.11(typescript@5.6.2)) + vue-router: 4.4.5(vue@3.5.12(typescript@5.6.3)) optionalDependencies: '@parcel/watcher': 2.4.1 - '@types/node': 20.16.5 + '@types/node': 20.16.11 transitivePeerDependencies: - '@azure/app-configuration' - '@azure/cosmos' @@ -11043,7 +13307,7 @@ snapshots: consola: 3.2.3 execa: 8.0.1 pathe: 1.1.2 - pkg-types: 1.2.0 + pkg-types: 1.2.1 ufo: 1.5.4 object-assign@4.1.1: {} @@ -11080,13 +13344,7 @@ snapshots: define-properties: 1.2.1 es-object-atoms: 1.0.0 - ofetch@1.3.4: - dependencies: - destr: 2.0.3 - node-fetch-native: 1.6.4 - ufo: 1.5.3 - - ofetch@1.4.0: + ofetch@1.4.1: dependencies: destr: 2.0.3 node-fetch-native: 1.6.4 @@ -11106,6 +13364,14 @@ snapshots: dependencies: mimic-fn: 4.0.0 + onetime@7.0.0: + dependencies: + mimic-function: 5.0.1 + + oniguruma-to-js@0.4.3: + dependencies: + regex: 4.3.3 + open@10.1.0: dependencies: default-browser: 5.2.1 @@ -11119,6 +13385,8 @@ snapshots: is-docker: 2.2.1 is-wsl: 2.2.0 + openapi-types@12.1.3: {} + openapi-typescript@6.7.6: dependencies: ansi-colors: 4.1.3 @@ -11137,6 +13405,18 @@ snapshots: type-check: 0.4.0 word-wrap: 1.2.5 + ora@8.1.0: + dependencies: + chalk: 5.3.0 + cli-cursor: 5.0.0 + cli-spinners: 2.9.2 + is-interactive: 2.0.0 + is-unicode-supported: 2.1.0 + log-symbols: 6.0.0 + stdin-discarder: 0.2.2 + string-width: 7.2.0 + strip-ansi: 7.1.0 + p-limit@2.3.0: dependencies: p-try: 2.2.0 @@ -11145,6 +13425,10 @@ snapshots: dependencies: yocto-queue: 0.1.0 + p-limit@6.1.0: + dependencies: + yocto-queue: 1.1.1 + p-locate@4.1.0: dependencies: p-limit: 2.3.0 @@ -11153,11 +13437,26 @@ snapshots: dependencies: p-limit: 3.1.0 + p-queue@8.0.1: + dependencies: + eventemitter3: 5.0.1 + p-timeout: 6.1.3 + + p-timeout@6.1.3: {} + p-try@2.2.0: {} - package-json-from-dist@1.0.0: {} + package-json-from-dist@1.0.1: {} - package-manager-detector@0.2.0: {} + package-manager-detector@0.2.2: {} + + pagefind@1.1.1: + optionalDependencies: + '@pagefind/darwin-arm64': 1.1.1 + '@pagefind/darwin-x64': 1.1.1 + '@pagefind/linux-arm64': 1.1.1 + '@pagefind/linux-x64': 1.1.1 + '@pagefind/windows-x64': 1.1.1 pako@1.0.11: {} @@ -11165,23 +13464,43 @@ snapshots: dependencies: callsites: 3.1.0 + parse-entities@4.0.1: + dependencies: + '@types/unist': 2.0.11 + character-entities: 2.0.2 + character-entities-legacy: 3.0.0 + character-reference-invalid: 2.0.1 + decode-named-character-reference: 1.0.2 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + is-hexadecimal: 2.0.1 + parse-git-config@3.0.0: dependencies: git-config-path: 2.0.0 ini: 1.3.8 - parse-imports@2.1.1: + parse-imports@2.2.1: dependencies: es-module-lexer: 1.5.4 slashes: 3.0.12 parse-json@5.2.0: dependencies: - '@babel/code-frame': 7.24.7 + '@babel/code-frame': 7.25.7 error-ex: 1.3.2 json-parse-even-better-errors: 2.3.1 lines-and-columns: 1.2.4 + parse-latin@7.0.0: + dependencies: + '@types/nlcst': 2.0.3 + '@types/unist': 3.0.3 + nlcst-to-string: 4.0.0 + unist-util-modify-children: 4.0.0 + unist-util-visit-children: 3.0.0 + vfile: 6.0.3 + parse-path@7.0.0: dependencies: protocols: 2.0.1 @@ -11190,6 +13509,10 @@ snapshots: dependencies: parse-path: 7.0.0 + parse5@7.2.0: + dependencies: + entities: 4.5.0 + parseurl@1.3.3: {} path-browserify@1.0.1: {} @@ -11217,30 +13540,36 @@ snapshots: perfect-debounce@1.0.0: {} - picocolors@1.0.1: {} - picocolors@1.1.0: {} + picocolors@1.1.1: {} + picomatch@2.3.1: {} picomatch@4.0.2: {} pify@2.3.0: {} - pinia@2.2.2(typescript@5.6.2)(vue@3.5.4(typescript@5.6.2)): + pify@4.0.1: {} + + pinia@2.2.4(typescript@5.6.3)(vue@3.5.12(typescript@5.6.3)): dependencies: - '@vue/devtools-api': 6.6.3 - vue: 3.5.4(typescript@5.6.2) - vue-demi: 0.14.10(vue@3.5.4(typescript@5.6.2)) + '@vue/devtools-api': 6.6.4 + vue: 3.5.12(typescript@5.6.3) + vue-demi: 0.14.10(vue@3.5.12(typescript@5.6.3)) optionalDependencies: - typescript: 5.6.2 + typescript: 5.6.3 pirates@4.0.6: {} - pkg-types@1.2.0: + pkg-dir@4.2.0: dependencies: - confbox: 0.1.7 - mlly: 1.7.1 + find-up: 4.1.0 + + pkg-types@1.2.1: + dependencies: + confbox: 0.1.8 + mlly: 1.7.2 pathe: 1.1.2 pluralize@8.0.0: {} @@ -11284,24 +13613,24 @@ snapshots: dependencies: postcss: 8.4.47 - postcss-import@15.1.0(postcss@8.4.45): + postcss-import@15.1.0(postcss@8.4.47): dependencies: - postcss: 8.4.45 + postcss: 8.4.47 postcss-value-parser: 4.2.0 read-cache: 1.0.0 resolve: 1.22.8 - postcss-js@4.0.1(postcss@8.4.45): + postcss-js@4.0.1(postcss@8.4.47): dependencies: camelcase-css: 2.0.1 - postcss: 8.4.45 + postcss: 8.4.47 - postcss-load-config@4.0.2(postcss@8.4.45): + postcss-load-config@4.0.2(postcss@8.4.47): dependencies: lilconfig: 3.1.2 - yaml: 2.4.5 + yaml: 2.6.0 optionalDependencies: - postcss: 8.4.45 + postcss: 8.4.47 postcss-merge-longhand@7.0.4(postcss@8.4.47): dependencies: @@ -11342,10 +13671,10 @@ snapshots: postcss: 8.4.47 postcss-selector-parser: 6.1.2 - postcss-nested@6.0.1(postcss@8.4.45): + postcss-nested@6.2.0(postcss@8.4.47): dependencies: - postcss: 8.4.45 - postcss-selector-parser: 6.1.0 + postcss: 8.4.47 + postcss-selector-parser: 6.1.2 postcss-normalize-charset@7.0.0(postcss@8.4.47): dependencies: @@ -11409,16 +13738,6 @@ snapshots: postcss: 8.4.47 postcss-value-parser: 4.2.0 - postcss-selector-parser@6.1.0: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - - postcss-selector-parser@6.1.1: - dependencies: - cssesc: 3.0.0 - util-deprecate: 1.0.2 - postcss-selector-parser@6.1.2: dependencies: cssesc: 3.0.0 @@ -11437,25 +13756,40 @@ snapshots: postcss-value-parser@4.2.0: {} - postcss@8.4.45: - dependencies: - nanoid: 3.3.7 - picocolors: 1.0.1 - source-map-js: 1.2.0 - postcss@8.4.47: dependencies: nanoid: 3.3.7 picocolors: 1.1.0 source-map-js: 1.2.1 - posthog-js@1.161.3: + posthog-js@1.169.0: dependencies: fflate: 0.4.8 - preact: 10.23.2 + preact: 10.24.3 web-vitals: 4.2.3 - preact@10.23.2: {} + preact@10.24.3: {} + + prebuild-install@7.1.2: + dependencies: + detect-libc: 2.0.3 + expand-template: 2.0.3 + github-from-package: 0.0.0 + minimist: 1.2.8 + mkdirp-classic: 0.5.3 + napi-build-utils: 1.0.2 + node-abi: 3.71.0 + pump: 3.0.2 + rc: 1.2.8 + simple-get: 4.0.1 + tar-fs: 2.1.1 + tunnel-agent: 0.6.0 + + preferred-pm@4.0.0: + dependencies: + find-up-simple: 1.0.0 + find-yarn-workspace-root2: 1.2.16 + which-pm: 3.0.0 prelude-ls@1.2.1: {} @@ -11463,14 +13797,19 @@ snapshots: dependencies: fast-diff: 1.3.0 - prettier-plugin-tailwindcss@0.6.6(prettier@3.3.3): + prettier-plugin-tailwindcss@0.6.8(prettier@3.3.3): dependencies: prettier: 3.3.3 + prettier@2.8.7: + optional: true + prettier@3.3.3: {} pretty-bytes@6.1.1: {} + prismjs@1.29.0: {} + process-nextick-args@2.0.1: {} process@0.11.10: {} @@ -11480,19 +13819,22 @@ snapshots: kleur: 3.0.3 sisteransi: 1.0.5 + property-information@6.5.0: {} + protocols@2.0.1: {} + pump@3.0.2: + dependencies: + end-of-stream: 1.4.4 + once: 1.4.0 + punycode.js@2.3.1: {} punycode@2.3.1: {} - qrcode.vue@3.4.1(vue@3.5.11(typescript@5.6.2)): + qrcode.vue@3.5.0(vue@3.5.12(typescript@5.6.3)): dependencies: - vue: 3.5.11(typescript@5.6.2) - - qrcode.vue@3.4.1(vue@3.5.4(typescript@5.6.2)): - dependencies: - vue: 3.5.4(typescript@5.6.2) + vue: 3.5.12(typescript@5.6.3) queue-microtask@1.2.3: {} @@ -11511,6 +13853,13 @@ snapshots: defu: 6.1.4 destr: 2.0.3 + rc@1.2.8: + dependencies: + deep-extend: 0.6.0 + ini: 1.3.8 + minimist: 1.2.8 + strip-json-comments: 2.0.1 + read-cache@1.0.0: dependencies: pify: 2.3.0 @@ -11560,6 +13909,38 @@ snapshots: dependencies: picomatch: 2.3.1 + readdirp@4.0.2: {} + + recma-build-jsx@1.0.0: + dependencies: + '@types/estree': 1.0.6 + estree-util-build-jsx: 3.0.1 + vfile: 6.0.3 + + recma-jsx@1.0.0(acorn@8.13.0): + dependencies: + acorn-jsx: 5.3.2(acorn@8.13.0) + estree-util-to-js: 2.0.0 + recma-parse: 1.0.0 + recma-stringify: 1.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - acorn + + recma-parse@1.0.0: + dependencies: + '@types/estree': 1.0.6 + esast-util-from-js: 2.0.1 + unified: 11.0.5 + vfile: 6.0.3 + + recma-stringify@1.0.0: + dependencies: + '@types/estree': 1.0.6 + estree-util-to-js: 2.0.0 + unified: 11.0.5 + vfile: 6.0.3 + redis-errors@1.2.0: {} redis-parser@3.0.0: @@ -11568,16 +13949,20 @@ snapshots: refa@0.12.1: dependencies: - '@eslint-community/regexpp': 4.11.0 + '@eslint-community/regexpp': 4.11.1 + + regenerator-runtime@0.14.1: {} + + regex@4.3.3: {} regexp-ast-analysis@0.7.1: dependencies: - '@eslint-community/regexpp': 4.11.0 + '@eslint-community/regexpp': 4.11.1 refa: 0.12.1 regexp-tree@0.1.27: {} - regexp.prototype.flags@1.5.2: + regexp.prototype.flags@1.5.3: dependencies: call-bind: 1.0.7 define-properties: 1.2.1 @@ -11590,8 +13975,113 @@ snapshots: dependencies: jsesc: 0.5.0 + rehype-expressive-code@0.35.6: + dependencies: + expressive-code: 0.35.6 + + rehype-format@5.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-format: 1.1.0 + + rehype-parse@9.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-from-html: 2.0.3 + unified: 11.0.5 + + rehype-raw@7.0.0: + dependencies: + '@types/hast': 3.0.4 + hast-util-raw: 9.0.4 + vfile: 6.0.3 + + rehype-recma@1.0.0: + dependencies: + '@types/estree': 1.0.6 + '@types/hast': 3.0.4 + hast-util-to-estree: 3.1.0 + transitivePeerDependencies: + - supports-color + + rehype-stringify@10.0.1: + dependencies: + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.3 + unified: 11.0.5 + + rehype@13.0.2: + dependencies: + '@types/hast': 3.0.4 + rehype-parse: 9.0.1 + rehype-stringify: 10.0.1 + unified: 11.0.5 + + remark-directive@3.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-directive: 3.0.0 + micromark-extension-directive: 3.0.2 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-gfm@4.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-gfm: 3.0.0 + micromark-extension-gfm: 3.0.0 + remark-parse: 11.0.0 + remark-stringify: 11.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-mdx@3.1.0: + dependencies: + mdast-util-mdx: 3.0.0 + micromark-extension-mdxjs: 3.0.0 + transitivePeerDependencies: + - supports-color + + remark-parse@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-from-markdown: 2.0.1 + micromark-util-types: 2.0.0 + unified: 11.0.5 + transitivePeerDependencies: + - supports-color + + remark-rehype@11.1.1: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + mdast-util-to-hast: 13.2.0 + unified: 11.0.5 + vfile: 6.0.3 + + remark-smartypants@3.0.2: + dependencies: + retext: 9.0.0 + retext-smartypants: 6.2.0 + unified: 11.0.5 + unist-util-visit: 5.0.0 + + remark-stringify@11.0.0: + dependencies: + '@types/mdast': 4.0.4 + mdast-util-to-markdown: 2.1.0 + unified: 11.0.5 + + request-light@0.5.8: {} + + request-light@0.7.0: {} + require-directory@2.1.1: {} + require-from-string@2.0.2: {} + resolve-from@4.0.0: {} resolve-from@5.0.0: {} @@ -11600,10 +14090,40 @@ snapshots: resolve@1.22.8: dependencies: - is-core-module: 2.14.0 + is-core-module: 2.15.1 path-parse: 1.0.7 supports-preserve-symlinks-flag: 1.0.0 + restore-cursor@5.1.0: + dependencies: + onetime: 7.0.0 + signal-exit: 4.1.0 + + retext-latin@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + parse-latin: 7.0.0 + unified: 11.0.5 + + retext-smartypants@6.2.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unist-util-visit: 5.0.0 + + retext-stringify@4.0.0: + dependencies: + '@types/nlcst': 2.0.3 + nlcst-to-string: 4.0.0 + unified: 11.0.5 + + retext@9.0.0: + dependencies: + '@types/nlcst': 2.0.3 + retext-latin: 4.0.0 + retext-stringify: 4.0.0 + unified: 11.0.5 + reusify@1.0.4: {} rfdc@1.4.1: {} @@ -11670,27 +14190,28 @@ snapshots: dependencies: regexp-tree: 0.1.27 - sass@1.78.0: + sass@1.79.5: dependencies: - chokidar: 3.6.0 - immutable: 4.3.6 - source-map-js: 1.2.0 + '@parcel/watcher': 2.4.1 + chokidar: 4.0.1 + immutable: 4.3.7 + source-map-js: 1.2.1 - schema-utils@3.3.0: - dependencies: - '@types/json-schema': 7.0.15 - ajv: 6.12.6 - ajv-keywords: 3.5.2(ajv@6.12.6) - optional: true + sax@1.4.1: {} scslre@0.3.0: dependencies: - '@eslint-community/regexpp': 4.11.0 + '@eslint-community/regexpp': 4.11.1 refa: 0.12.1 regexp-ast-analysis: 0.7.1 scule@1.3.0: {} + section-matter@1.0.0: + dependencies: + extend-shallow: 2.0.1 + kind-of: 6.0.3 + semver@5.7.2: {} semver@6.3.1: {} @@ -11754,6 +14275,44 @@ snapshots: setprototypeof@1.2.0: {} + sharp@0.32.6: + dependencies: + color: 4.2.3 + detect-libc: 2.0.3 + node-addon-api: 6.1.0 + prebuild-install: 7.1.2 + semver: 7.6.3 + simple-get: 4.0.1 + tar-fs: 3.0.6 + tunnel-agent: 0.6.0 + + sharp@0.33.5: + dependencies: + color: 4.2.3 + detect-libc: 2.0.3 + semver: 7.6.3 + optionalDependencies: + '@img/sharp-darwin-arm64': 0.33.5 + '@img/sharp-darwin-x64': 0.33.5 + '@img/sharp-libvips-darwin-arm64': 1.0.4 + '@img/sharp-libvips-darwin-x64': 1.0.4 + '@img/sharp-libvips-linux-arm': 1.0.5 + '@img/sharp-libvips-linux-arm64': 1.0.4 + '@img/sharp-libvips-linux-s390x': 1.0.4 + '@img/sharp-libvips-linux-x64': 1.0.4 + '@img/sharp-libvips-linuxmusl-arm64': 1.0.4 + '@img/sharp-libvips-linuxmusl-x64': 1.0.4 + '@img/sharp-linux-arm': 0.33.5 + '@img/sharp-linux-arm64': 0.33.5 + '@img/sharp-linux-s390x': 0.33.5 + '@img/sharp-linux-x64': 0.33.5 + '@img/sharp-linuxmusl-arm64': 0.33.5 + '@img/sharp-linuxmusl-x64': 0.33.5 + '@img/sharp-wasm32': 0.33.5 + '@img/sharp-win32-ia32': 0.33.5 + '@img/sharp-win32-x64': 0.33.5 + optional: true + shebang-command@2.0.0: dependencies: shebang-regex: 3.0.0 @@ -11762,6 +14321,15 @@ snapshots: shell-quote@1.8.1: {} + shiki@1.22.0: + dependencies: + '@shikijs/core': 1.22.0 + '@shikijs/engine-javascript': 1.22.0 + '@shikijs/engine-oniguruma': 1.22.0 + '@shikijs/types': 1.22.0 + '@shikijs/vscode-textmate': 9.3.0 + '@types/hast': 3.0.4 + side-channel@1.0.6: dependencies: call-bind: 1.0.7 @@ -11773,6 +14341,14 @@ snapshots: signal-exit@4.1.0: {} + simple-concat@1.0.1: {} + + simple-get@4.0.1: + dependencies: + decompress-response: 6.0.0 + once: 1.4.0 + simple-concat: 1.0.1 + simple-git@3.27.0: dependencies: '@kwsites/file-exists': 1.1.1 @@ -11781,6 +14357,10 @@ snapshots: transitivePeerDependencies: - supports-color + simple-swizzle@0.2.2: + dependencies: + is-arrayish: 0.3.2 + sirv@2.0.4: dependencies: '@polka/url': 1.0.0-next.28 @@ -11789,6 +14369,13 @@ snapshots: sisteransi@1.0.5: {} + sitemap@8.0.0: + dependencies: + '@types/node': 17.0.45 + '@types/sax': 1.2.7 + arg: 5.0.2 + sax: 1.4.1 + slash@3.0.0: {} slash@5.1.0: {} @@ -11797,8 +14384,6 @@ snapshots: smob@1.5.0: {} - source-map-js@1.2.0: {} - source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -11810,6 +14395,8 @@ snapshots: source-map@0.7.4: {} + space-separated-tokens@2.0.2: {} + spdx-correct@3.2.0: dependencies: spdx-expression-parse: 3.0.1 @@ -11831,19 +14418,35 @@ snapshots: speakingurl@14.0.1: {} + sprintf-js@1.0.3: {} + stable-hash@0.0.4: {} standard-as-callback@2.1.0: {} + starlight-openapi@0.7.0(@astrojs/markdown-remark@5.3.0)(@astrojs/starlight@0.26.4(astro@4.16.6(@types/node@20.16.11)(rollup@4.24.0)(sass@1.79.5)(terser@5.34.1)(typescript@5.6.3)))(astro@4.16.6(@types/node@20.16.11)(rollup@4.24.0)(sass@1.79.5)(terser@5.34.1)(typescript@5.6.3))(openapi-types@12.1.3): + dependencies: + '@astrojs/markdown-remark': 5.3.0 + '@astrojs/starlight': 0.26.4(astro@4.16.6(@types/node@20.16.11)(rollup@4.24.0)(sass@1.79.5)(terser@5.34.1)(typescript@5.6.3)) + '@readme/openapi-parser': 2.5.0(openapi-types@12.1.3) + astro: 4.16.6(@types/node@20.16.11)(rollup@4.24.0)(sass@1.79.5)(terser@5.34.1)(typescript@5.6.3) + github-slugger: 2.0.0 + transitivePeerDependencies: + - openapi-types + statuses@2.0.1: {} std-env@3.7.0: {} + stdin-discarder@0.2.2: {} + + stream-replace-string@2.0.0: {} + streamx@2.20.1: dependencies: fast-fifo: 1.3.2 queue-tick: 1.0.1 - text-decoder: 1.2.0 + text-decoder: 1.2.1 optionalDependencies: bare-events: 2.5.0 @@ -11859,6 +14462,12 @@ snapshots: emoji-regex: 9.2.2 strip-ansi: 7.1.0 + string-width@7.2.0: + dependencies: + emoji-regex: 10.4.0 + get-east-asian-width: 1.3.0 + strip-ansi: 7.1.0 + string.prototype.trim@1.2.9: dependencies: call-bind: 1.0.7 @@ -11886,6 +14495,11 @@ snapshots: dependencies: safe-buffer: 5.2.1 + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + strip-ansi@6.0.1: dependencies: ansi-regex: 5.0.1 @@ -11894,6 +14508,8 @@ snapshots: dependencies: ansi-regex: 6.1.0 + strip-bom-string@1.0.0: {} + strip-bom@3.0.0: {} strip-final-newline@3.0.0: {} @@ -11902,6 +14518,8 @@ snapshots: dependencies: min-indent: 1.0.1 + strip-json-comments@2.0.1: {} + strip-json-comments@3.1.1: {} strip-literal@2.1.0: @@ -11910,6 +14528,14 @@ snapshots: style-mod@4.1.2: {} + style-to-object@0.4.4: + dependencies: + inline-style-parser: 0.1.1 + + style-to-object@1.0.8: + dependencies: + inline-style-parser: 0.2.4 + stylehacks@7.0.4(postcss@8.4.47): dependencies: browserslist: 4.24.0 @@ -11938,11 +14564,6 @@ snapshots: dependencies: has-flag: 4.0.0 - supports-color@8.1.1: - dependencies: - has-flag: 4.0.0 - optional: true - supports-color@9.4.0: {} supports-preserve-symlinks-flag@1.0.0: {} @@ -11988,16 +14609,16 @@ snapshots: css-tree: 2.3.1 css-what: 6.1.0 csso: 5.0.5 - picocolors: 1.0.1 + picocolors: 1.1.0 - synckit@0.9.1: + synckit@0.9.2: dependencies: '@pkgr/core': 0.1.1 - tslib: 2.6.3 + tslib: 2.8.0 system-architecture@0.1.0: {} - tailwindcss@3.4.11: + tailwindcss@3.4.14: dependencies: '@alloc/quick-lru': 5.2.0 arg: 5.0.2 @@ -12009,16 +14630,16 @@ snapshots: is-glob: 4.0.3 jiti: 1.21.6 lilconfig: 2.1.0 - micromatch: 4.0.7 + micromatch: 4.0.8 normalize-path: 3.0.0 object-hash: 3.0.0 - picocolors: 1.0.1 - postcss: 8.4.45 - postcss-import: 15.1.0(postcss@8.4.45) - postcss-js: 4.0.1(postcss@8.4.45) - postcss-load-config: 4.0.2(postcss@8.4.45) - postcss-nested: 6.0.1(postcss@8.4.45) - postcss-selector-parser: 6.1.0 + picocolors: 1.1.0 + postcss: 8.4.47 + postcss-import: 15.1.0(postcss@8.4.47) + postcss-js: 4.0.1(postcss@8.4.47) + postcss-load-config: 4.0.2(postcss@8.4.47) + postcss-nested: 6.2.0(postcss@8.4.47) + postcss-selector-parser: 6.1.2 resolve: 1.22.8 sucrase: 3.35.0 transitivePeerDependencies: @@ -12026,6 +14647,29 @@ snapshots: tapable@2.2.1: {} + tar-fs@2.1.1: + dependencies: + chownr: 1.1.4 + mkdirp-classic: 0.5.3 + pump: 3.0.2 + tar-stream: 2.2.0 + + tar-fs@3.0.6: + dependencies: + pump: 3.0.2 + tar-stream: 3.1.7 + optionalDependencies: + bare-fs: 2.3.5 + bare-path: 2.1.3 + + tar-stream@2.2.0: + dependencies: + bl: 4.1.0 + end-of-stream: 1.4.4 + fs-constants: 1.0.0 + inherits: 2.0.4 + readable-stream: 3.6.2 + tar-stream@3.1.7: dependencies: b4a: 1.6.7 @@ -12041,16 +14685,6 @@ snapshots: mkdirp: 1.0.4 yallist: 4.0.0 - terser-webpack-plugin@5.3.10(webpack@5.94.0): - dependencies: - '@jridgewell/trace-mapping': 0.3.25 - jest-worker: 27.5.1 - schema-utils: 3.3.0 - serialize-javascript: 6.0.2 - terser: 5.34.1 - webpack: 5.94.0 - optional: true - terser@5.34.1: dependencies: '@jridgewell/source-map': 0.3.6 @@ -12058,9 +14692,7 @@ snapshots: commander: 2.20.3 source-map-support: 0.5.21 - text-decoder@1.2.0: - dependencies: - b4a: 1.6.7 + text-decoder@1.2.1: {} text-table@0.2.0: {} @@ -12074,6 +14706,8 @@ snapshots: tiny-invariant@1.3.3: {} + tinyexec@0.3.1: {} + tinyglobby@0.2.6: dependencies: fdir: 6.4.0(picomatch@4.0.2) @@ -12096,12 +14730,20 @@ snapshots: tr46@0.0.3: {} - ts-api-utils@1.3.0(typescript@5.6.2): + trim-lines@3.0.1: {} + + trough@2.2.0: {} + + ts-api-utils@1.3.0(typescript@5.6.3): dependencies: - typescript: 5.6.2 + typescript: 5.6.3 ts-interface-checker@0.1.13: {} + tsconfck@3.1.4(typescript@5.6.3): + optionalDependencies: + typescript: 5.6.3 + tsconfig-paths@3.15.0: dependencies: '@types/json5': 0.0.29 @@ -12109,36 +14751,38 @@ snapshots: minimist: 1.2.8 strip-bom: 3.0.0 - tslib@2.6.3: {} + tslib@2.8.0: {} - tslib@2.7.0: {} + tunnel-agent@0.6.0: + dependencies: + safe-buffer: 5.2.1 - turbo-darwin-64@2.1.1: + turbo-darwin-64@2.2.1: optional: true - turbo-darwin-arm64@2.1.1: + turbo-darwin-arm64@2.2.1: optional: true - turbo-linux-64@2.1.1: + turbo-linux-64@2.2.1: optional: true - turbo-linux-arm64@2.1.1: + turbo-linux-arm64@2.2.1: optional: true - turbo-windows-64@2.1.1: + turbo-windows-64@2.2.1: optional: true - turbo-windows-arm64@2.1.1: + turbo-windows-arm64@2.2.1: optional: true - turbo@2.1.1: + turbo@2.2.1: optionalDependencies: - turbo-darwin-64: 2.1.1 - turbo-darwin-arm64: 2.1.1 - turbo-linux-64: 2.1.1 - turbo-linux-arm64: 2.1.1 - turbo-windows-64: 2.1.1 - turbo-windows-arm64: 2.1.1 + turbo-darwin-64: 2.2.1 + turbo-darwin-arm64: 2.2.1 + turbo-linux-64: 2.2.1 + turbo-linux-arm64: 2.2.1 + turbo-windows-64: 2.2.1 + turbo-windows-arm64: 2.2.1 type-check@0.4.0: dependencies: @@ -12154,6 +14798,8 @@ snapshots: type-fest@3.13.1: {} + type-fest@4.26.1: {} + typed-array-buffer@1.0.2: dependencies: call-bind: 1.0.7 @@ -12186,14 +14832,18 @@ snapshots: is-typed-array: 1.1.13 possible-typed-array-names: 1.0.0 - typescript@5.6.2: {} + typesafe-path@0.2.2: {} + + typescript-auto-import-cache@0.3.3: + dependencies: + semver: 7.6.3 + + typescript@5.6.3: {} uc.micro@1.0.6: {} uc.micro@2.1.0: {} - ufo@1.5.3: {} - ufo@1.5.4: {} ultrahtml@1.5.3: {} @@ -12207,15 +14857,17 @@ snapshots: uncrypto@0.1.3: {} - unctx@2.3.1(webpack-sources@3.2.3): + unctx@2.3.1: dependencies: acorn: 8.12.1 estree-walker: 3.0.3 - magic-string: 0.30.11 - unplugin: 1.14.1(webpack-sources@3.2.3) + magic-string: 0.30.12 + unplugin: 1.14.1 transitivePeerDependencies: - webpack-sources + undici-types@5.26.5: {} + undici-types@6.19.8: {} undici@5.28.4: @@ -12230,35 +14882,26 @@ snapshots: node-fetch-native: 1.6.4 pathe: 1.1.2 - unhead@1.11.7: + unhead@1.11.9: dependencies: - '@unhead/dom': 1.11.7 - '@unhead/schema': 1.11.7 - '@unhead/shared': 1.11.7 + '@unhead/dom': 1.11.9 + '@unhead/schema': 1.11.9 + '@unhead/shared': 1.11.9 hookable: 5.5.3 unicorn-magic@0.1.0: {} - unimport@3.11.1(rollup@4.24.0)(webpack-sources@3.2.3): + unified@11.0.5: dependencies: - '@rollup/pluginutils': 5.1.0(rollup@4.24.0) - acorn: 8.12.1 - escape-string-regexp: 5.0.0 - estree-walker: 3.0.3 - fast-glob: 3.3.2 - local-pkg: 0.5.0 - magic-string: 0.30.11 - mlly: 1.7.1 - pathe: 1.1.2 - pkg-types: 1.2.0 - scule: 1.3.0 - strip-literal: 2.1.0 - unplugin: 1.14.1(webpack-sources@3.2.3) - transitivePeerDependencies: - - rollup - - webpack-sources + '@types/unist': 3.0.3 + bail: 2.0.2 + devlop: 1.1.0 + extend: 3.0.2 + is-plain-obj: 4.1.0 + trough: 2.2.0 + vfile: 6.0.3 - unimport@3.13.1(rollup@4.24.0)(webpack-sources@3.2.3): + unimport@3.13.1(rollup@4.24.0): dependencies: '@rollup/pluginutils': 5.1.2(rollup@4.24.0) acorn: 8.12.1 @@ -12266,48 +14909,92 @@ snapshots: estree-walker: 3.0.3 fast-glob: 3.3.2 local-pkg: 0.5.0 - magic-string: 0.30.11 - mlly: 1.7.1 + magic-string: 0.30.12 + mlly: 1.7.2 pathe: 1.1.2 - pkg-types: 1.2.0 + pkg-types: 1.2.1 scule: 1.3.0 strip-literal: 2.1.0 - unplugin: 1.14.1(webpack-sources@3.2.3) + unplugin: 1.14.1 transitivePeerDependencies: - rollup - webpack-sources + unist-util-find-after@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.0 + + unist-util-is@6.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-modify-children@4.0.0: + dependencies: + '@types/unist': 3.0.3 + array-iterate: 2.0.1 + + unist-util-position-from-estree@2.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-remove-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-visit: 5.0.0 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-children@3.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.1: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.0 + + unist-util-visit@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.1 + universalify@2.0.1: {} - unplugin-vue-router@0.10.8(rollup@4.24.0)(vue-router@4.4.5(vue@3.5.11(typescript@5.6.2)))(vue@3.5.11(typescript@5.6.2))(webpack-sources@3.2.3): + unplugin-vue-router@0.10.8(rollup@4.24.0)(vue-router@4.4.5(vue@3.5.12(typescript@5.6.3)))(vue@3.5.12(typescript@5.6.3)): dependencies: - '@babel/types': 7.25.7 + '@babel/types': 7.25.8 '@rollup/pluginutils': 5.1.2(rollup@4.24.0) - '@vue-macros/common': 1.14.0(rollup@4.24.0)(vue@3.5.11(typescript@5.6.2)) + '@vue-macros/common': 1.15.0(rollup@4.24.0)(vue@3.5.12(typescript@5.6.3)) ast-walker-scope: 0.6.2 chokidar: 3.6.0 fast-glob: 3.3.2 json5: 2.2.3 local-pkg: 0.5.0 - magic-string: 0.30.11 - mlly: 1.7.1 + magic-string: 0.30.12 + mlly: 1.7.2 pathe: 1.1.2 scule: 1.3.0 - unplugin: 1.14.1(webpack-sources@3.2.3) - yaml: 2.5.1 + unplugin: 1.14.1 + yaml: 2.6.0 optionalDependencies: - vue-router: 4.4.5(vue@3.5.11(typescript@5.6.2)) + vue-router: 4.4.5(vue@3.5.12(typescript@5.6.3)) transitivePeerDependencies: - rollup - vue - webpack-sources - unplugin@1.14.1(webpack-sources@3.2.3): + unplugin@1.14.1: dependencies: acorn: 8.12.1 webpack-virtual-modules: 0.6.2 - optionalDependencies: - webpack-sources: 3.2.3 unstorage@1.12.0(ioredis@5.4.1): dependencies: @@ -12319,7 +15006,7 @@ snapshots: lru-cache: 10.4.3 mri: 1.2.0 node-fetch-native: 1.6.4 - ofetch: 1.4.0 + ofetch: 1.4.1 ufo: 1.5.4 optionalDependencies: ioredis: 5.4.1 @@ -12332,47 +15019,29 @@ snapshots: consola: 3.2.3 pathe: 1.1.2 - untyped@1.4.2: + untyped@1.5.1: dependencies: - '@babel/core': 7.25.2 - '@babel/standalone': 7.24.7 - '@babel/types': 7.25.6 + '@babel/core': 7.25.8 + '@babel/standalone': 7.25.8 + '@babel/types': 7.25.8 defu: 6.1.4 - jiti: 1.21.6 + jiti: 2.3.3 mri: 1.2.0 scule: 1.3.0 transitivePeerDependencies: - supports-color - untyped@1.5.0: - dependencies: - '@babel/core': 7.25.7 - '@babel/standalone': 7.25.7 - '@babel/types': 7.25.7 - defu: 6.1.4 - jiti: 2.1.2 - mri: 1.2.0 - scule: 1.3.0 - transitivePeerDependencies: - - supports-color - - unwasm@0.3.9(webpack-sources@3.2.3): + unwasm@0.3.9: dependencies: knitwork: 1.1.0 - magic-string: 0.30.11 - mlly: 1.7.1 + magic-string: 0.30.12 + mlly: 1.7.2 pathe: 1.1.2 - pkg-types: 1.2.0 - unplugin: 1.14.1(webpack-sources@3.2.3) + pkg-types: 1.2.1 + unplugin: 1.14.1 transitivePeerDependencies: - webpack-sources - update-browserslist-db@1.1.0(browserslist@4.23.3): - dependencies: - browserslist: 4.23.3 - escalade: 3.2.0 - picocolors: 1.0.1 - update-browserslist-db@1.1.1(browserslist@4.24.0): dependencies: browserslist: 4.24.0 @@ -12394,16 +15063,31 @@ snapshots: spdx-correct: 3.2.0 spdx-expression-parse: 3.0.1 - vite-hot-client@0.2.3(vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1)): + vfile-location@5.0.3: dependencies: - vite: 5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1) + '@types/unist': 3.0.3 + vfile: 6.0.3 - vite-node@2.1.2(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1): + vfile-message@4.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.2 + + vite-hot-client@0.2.3(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1)): + dependencies: + vite: 5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1) + + vite-node@2.1.3(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1): dependencies: cac: 6.7.14 debug: 4.3.7 pathe: 1.1.2 - vite: 5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1) + vite: 5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1) transitivePeerDependencies: - '@types/node' - less @@ -12415,7 +15099,7 @@ snapshots: - supports-color - terser - vite-plugin-checker@0.8.0(eslint@8.57.0)(optionator@0.9.4)(typescript@5.6.2)(vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1))(vue-tsc@2.1.6(typescript@5.6.2)): + vite-plugin-checker@0.8.0(eslint@8.57.1)(optionator@0.9.4)(typescript@5.6.3)(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1))(vue-tsc@2.1.6(typescript@5.6.3)): dependencies: '@babel/code-frame': 7.25.7 ansi-escapes: 4.3.2 @@ -12427,18 +15111,18 @@ snapshots: npm-run-path: 4.0.1 strip-ansi: 6.0.1 tiny-invariant: 1.3.3 - vite: 5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1) + vite: 5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1) vscode-languageclient: 7.0.0 vscode-languageserver: 7.0.0 vscode-languageserver-textdocument: 1.0.12 vscode-uri: 3.0.8 optionalDependencies: - eslint: 8.57.0 + eslint: 8.57.1 optionator: 0.9.4 - typescript: 5.6.2 - vue-tsc: 2.1.6(typescript@5.6.2) + typescript: 5.6.3 + vue-tsc: 2.1.6(typescript@5.6.3) - vite-plugin-inspect@0.8.7(@nuxt/kit@3.13.2(magicast@0.3.5)(rollup@4.24.0)(webpack-sources@3.2.3))(rollup@4.24.0)(vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1)): + vite-plugin-inspect@0.8.7(@nuxt/kit@3.13.2(magicast@0.3.5)(rollup@4.24.0))(rollup@4.24.0)(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1)): dependencies: '@antfu/utils': 0.7.10 '@rollup/pluginutils': 5.1.2(rollup@4.24.0) @@ -12449,62 +15133,130 @@ snapshots: perfect-debounce: 1.0.0 picocolors: 1.1.0 sirv: 2.0.4 - vite: 5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1) + vite: 5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1) optionalDependencies: - '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.24.0)(webpack-sources@3.2.3) + '@nuxt/kit': 3.13.2(magicast@0.3.5)(rollup@4.24.0) transitivePeerDependencies: - rollup - supports-color - vite-plugin-vue-inspector@5.1.3(vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1)): + vite-plugin-vue-inspector@5.1.3(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1)): dependencies: - '@babel/core': 7.25.7 - '@babel/plugin-proposal-decorators': 7.25.7(@babel/core@7.25.7) - '@babel/plugin-syntax-import-attributes': 7.25.7(@babel/core@7.25.7) - '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.25.7) - '@babel/plugin-transform-typescript': 7.25.7(@babel/core@7.25.7) - '@vue/babel-plugin-jsx': 1.2.5(@babel/core@7.25.7) - '@vue/compiler-dom': 3.5.11 + '@babel/core': 7.25.8 + '@babel/plugin-proposal-decorators': 7.25.7(@babel/core@7.25.8) + '@babel/plugin-syntax-import-attributes': 7.25.7(@babel/core@7.25.8) + '@babel/plugin-syntax-import-meta': 7.10.4(@babel/core@7.25.8) + '@babel/plugin-transform-typescript': 7.25.7(@babel/core@7.25.8) + '@vue/babel-plugin-jsx': 1.2.5(@babel/core@7.25.8) + '@vue/compiler-dom': 3.5.12 kolorist: 1.8.0 - magic-string: 0.30.11 - vite: 5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1) + magic-string: 0.30.12 + vite: 5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1) transitivePeerDependencies: - supports-color - vite-svg-loader@5.1.0(vue@3.5.11(typescript@5.6.2)): + vite-svg-loader@5.1.0(vue@3.5.12(typescript@5.6.3)): dependencies: svgo: 3.3.2 - vue: 3.5.11(typescript@5.6.2) + vue: 3.5.12(typescript@5.6.3) - vite-svg-loader@5.1.0(vue@3.5.4(typescript@5.6.2)): - dependencies: - svgo: 3.3.2 - vue: 3.5.4(typescript@5.6.2) - - vite@5.4.8(@types/node@20.16.5)(sass@1.78.0)(terser@5.34.1): + vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1): dependencies: esbuild: 0.21.5 - postcss: 8.4.45 + postcss: 8.4.47 rollup: 4.24.0 optionalDependencies: - '@types/node': 20.16.5 + '@types/node': 20.16.11 fsevents: 2.3.3 - sass: 1.78.0 + sass: 1.79.5 terser: 5.34.1 - vite@5.4.8(@types/node@22.7.4)(sass@1.78.0)(terser@5.34.1): - dependencies: - esbuild: 0.21.5 - postcss: 8.4.45 - rollup: 4.24.0 + vitefu@1.0.3(vite@5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1)): optionalDependencies: - '@types/node': 22.7.4 - fsevents: 2.3.3 - sass: 1.78.0 - terser: 5.34.1 + vite: 5.4.9(@types/node@20.16.11)(sass@1.79.5)(terser@5.34.1) + + volar-service-css@0.0.61(@volar/language-service@2.4.6): + dependencies: + vscode-css-languageservice: 6.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.0.8 + optionalDependencies: + '@volar/language-service': 2.4.6 + + volar-service-emmet@0.0.61(@volar/language-service@2.4.6): + dependencies: + '@emmetio/css-parser': 0.4.0 + '@emmetio/html-matcher': 1.3.0 + '@vscode/emmet-helper': 2.9.3 + vscode-uri: 3.0.8 + optionalDependencies: + '@volar/language-service': 2.4.6 + + volar-service-html@0.0.61(@volar/language-service@2.4.6): + dependencies: + vscode-html-languageservice: 5.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-uri: 3.0.8 + optionalDependencies: + '@volar/language-service': 2.4.6 + + volar-service-prettier@0.0.61(@volar/language-service@2.4.6)(prettier@3.3.3): + dependencies: + vscode-uri: 3.0.8 + optionalDependencies: + '@volar/language-service': 2.4.6 + prettier: 3.3.3 + + volar-service-typescript-twoslash-queries@0.0.61(@volar/language-service@2.4.6): + dependencies: + vscode-uri: 3.0.8 + optionalDependencies: + '@volar/language-service': 2.4.6 + + volar-service-typescript@0.0.61(@volar/language-service@2.4.6): + dependencies: + path-browserify: 1.0.1 + semver: 7.6.3 + typescript-auto-import-cache: 0.3.3 + vscode-languageserver-textdocument: 1.0.12 + vscode-nls: 5.2.0 + vscode-uri: 3.0.8 + optionalDependencies: + '@volar/language-service': 2.4.6 + + volar-service-yaml@0.0.61(@volar/language-service@2.4.6): + dependencies: + vscode-uri: 3.0.8 + yaml-language-server: 1.15.0 + optionalDependencies: + '@volar/language-service': 2.4.6 + + vscode-css-languageservice@6.3.1: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.0.8 + + vscode-html-languageservice@5.3.1: + dependencies: + '@vscode/l10n': 0.0.18 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-uri: 3.0.8 + + vscode-json-languageservice@4.1.8: + dependencies: + jsonc-parser: 3.3.1 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-nls: 5.2.0 + vscode-uri: 3.0.8 vscode-jsonrpc@6.0.0: {} + vscode-jsonrpc@8.2.0: {} + vscode-languageclient@7.0.0: dependencies: minimatch: 3.1.2 @@ -12516,30 +15268,45 @@ snapshots: vscode-jsonrpc: 6.0.0 vscode-languageserver-types: 3.16.0 + vscode-languageserver-protocol@3.17.5: + dependencies: + vscode-jsonrpc: 8.2.0 + vscode-languageserver-types: 3.17.5 + vscode-languageserver-textdocument@1.0.12: {} vscode-languageserver-types@3.16.0: {} + vscode-languageserver-types@3.17.5: {} + vscode-languageserver@7.0.0: dependencies: vscode-languageserver-protocol: 3.16.0 + vscode-languageserver@9.0.1: + dependencies: + vscode-languageserver-protocol: 3.17.5 + + vscode-nls@5.2.0: {} + + vscode-uri@2.1.2: {} + vscode-uri@3.0.8: {} vue-bundle-renderer@2.1.1: dependencies: ufo: 1.5.4 - vue-demi@0.14.10(vue@3.5.4(typescript@5.6.2)): + vue-demi@0.14.10(vue@3.5.12(typescript@5.6.3)): dependencies: - vue: 3.5.4(typescript@5.6.2) + vue: 3.5.12(typescript@5.6.3) vue-devtools-stub@0.1.0: {} - vue-eslint-parser@9.4.3(eslint@9.10.0(jiti@2.1.2)): + vue-eslint-parser@9.4.3(eslint@9.12.0(jiti@2.3.3)): dependencies: - debug: 4.3.5 - eslint: 9.10.0(jiti@2.1.2) + debug: 4.3.7 + eslint: 9.12.0(jiti@2.3.3) eslint-scope: 7.2.2 eslint-visitor-keys: 3.4.3 espree: 9.6.1 @@ -12551,124 +15318,67 @@ snapshots: vue-multiselect@3.0.0: {} - vue-observe-visibility@2.0.0-alpha.1(vue@3.5.4(typescript@5.6.2)): + vue-observe-visibility@2.0.0-alpha.1(vue@3.5.12(typescript@5.6.3)): dependencies: - vue: 3.5.4(typescript@5.6.2) + vue: 3.5.12(typescript@5.6.3) - vue-resize@2.0.0-alpha.1(vue@3.5.11(typescript@5.6.2)): + vue-resize@2.0.0-alpha.1(vue@3.5.12(typescript@5.6.3)): dependencies: - vue: 3.5.11(typescript@5.6.2) + vue: 3.5.12(typescript@5.6.3) - vue-resize@2.0.0-alpha.1(vue@3.5.4(typescript@5.6.2)): - dependencies: - vue: 3.5.4(typescript@5.6.2) - - vue-router@4.3.0(vue@3.5.4(typescript@5.6.2)): + vue-router@4.3.0(vue@3.5.12(typescript@5.6.3)): dependencies: '@vue/devtools-api': 6.6.4 - vue: 3.5.4(typescript@5.6.2) + vue: 3.5.12(typescript@5.6.3) - vue-router@4.4.5(vue@3.5.11(typescript@5.6.2)): + vue-router@4.4.5(vue@3.5.12(typescript@5.6.3)): dependencies: '@vue/devtools-api': 6.6.4 - vue: 3.5.11(typescript@5.6.2) + vue: 3.5.12(typescript@5.6.3) - vue-select@4.0.0-beta.6(vue@3.5.4(typescript@5.6.2)): + vue-select@4.0.0-beta.6(vue@3.5.12(typescript@5.6.3)): dependencies: - vue: 3.5.4(typescript@5.6.2) + vue: 3.5.12(typescript@5.6.3) - vue-tsc@2.1.6(typescript@5.6.2): + vue-tsc@2.1.6(typescript@5.6.3): dependencies: - '@volar/typescript': 2.4.4 - '@vue/language-core': 2.1.6(typescript@5.6.2) + '@volar/typescript': 2.4.6 + '@vue/language-core': 2.1.6(typescript@5.6.3) semver: 7.6.3 - typescript: 5.6.2 + typescript: 5.6.3 - vue-virtual-scroller@2.0.0-beta.8(vue@3.5.4(typescript@5.6.2)): + vue-virtual-scroller@2.0.0-beta.8(vue@3.5.12(typescript@5.6.3)): dependencies: mitt: 2.1.0 - vue: 3.5.4(typescript@5.6.2) - vue-observe-visibility: 2.0.0-alpha.1(vue@3.5.4(typescript@5.6.2)) - vue-resize: 2.0.0-alpha.1(vue@3.5.4(typescript@5.6.2)) + vue: 3.5.12(typescript@5.6.3) + vue-observe-visibility: 2.0.0-alpha.1(vue@3.5.12(typescript@5.6.3)) + vue-resize: 2.0.0-alpha.1(vue@3.5.12(typescript@5.6.3)) - vue3-apexcharts@1.6.0(apexcharts@3.53.0)(vue@3.5.11(typescript@5.6.2)): + vue3-apexcharts@1.7.0(apexcharts@3.54.1)(vue@3.5.12(typescript@5.6.3)): dependencies: - apexcharts: 3.53.0 - vue: 3.5.11(typescript@5.6.2) + apexcharts: 3.54.1 + vue: 3.5.12(typescript@5.6.3) - vue3-apexcharts@1.6.0(apexcharts@3.53.0)(vue@3.5.4(typescript@5.6.2)): + vue@3.5.12(typescript@5.6.3): dependencies: - apexcharts: 3.53.0 - vue: 3.5.4(typescript@5.6.2) - - vue@3.5.11(typescript@5.6.2): - dependencies: - '@vue/compiler-dom': 3.5.11 - '@vue/compiler-sfc': 3.5.11 - '@vue/runtime-dom': 3.5.11 - '@vue/server-renderer': 3.5.11(vue@3.5.11(typescript@5.6.2)) - '@vue/shared': 3.5.11 + '@vue/compiler-dom': 3.5.12 + '@vue/compiler-sfc': 3.5.12 + '@vue/runtime-dom': 3.5.12 + '@vue/server-renderer': 3.5.12(vue@3.5.12(typescript@5.6.3)) + '@vue/shared': 3.5.12 optionalDependencies: - typescript: 5.6.2 - - vue@3.5.4(typescript@5.6.2): - dependencies: - '@vue/compiler-dom': 3.5.4 - '@vue/compiler-sfc': 3.5.4 - '@vue/runtime-dom': 3.5.4 - '@vue/server-renderer': 3.5.4(vue@3.5.4(typescript@5.6.2)) - '@vue/shared': 3.5.4 - optionalDependencies: - typescript: 5.6.2 + typescript: 5.6.3 w3c-keyname@2.2.8: {} - watchpack@2.4.2: - dependencies: - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - optional: true + web-namespaces@2.0.1: {} web-vitals@4.2.3: {} webidl-conversions@3.0.1: {} - webpack-sources@3.2.3: - optional: true - webpack-virtual-modules@0.6.2: {} - webpack@5.94.0: - dependencies: - '@types/estree': 1.0.6 - '@webassemblyjs/ast': 1.12.1 - '@webassemblyjs/wasm-edit': 1.12.1 - '@webassemblyjs/wasm-parser': 1.12.1 - acorn: 8.12.1 - acorn-import-attributes: 1.9.5(acorn@8.12.1) - browserslist: 4.24.0 - chrome-trace-event: 1.0.4 - enhanced-resolve: 5.17.1 - es-module-lexer: 1.5.4 - eslint-scope: 5.1.1 - events: 3.3.0 - glob-to-regexp: 0.4.1 - graceful-fs: 4.2.11 - json-parse-even-better-errors: 2.3.1 - loader-runner: 4.3.0 - mime-types: 2.1.35 - neo-async: 2.6.2 - schema-utils: 3.3.0 - tapable: 2.2.1 - terser-webpack-plugin: 5.3.10(webpack@5.94.0) - watchpack: 2.4.2 - webpack-sources: 3.2.3 - transitivePeerDependencies: - - '@swc/core' - - esbuild - - uglify-js - optional: true - whatwg-url@5.0.0: dependencies: tr46: 0.0.3 @@ -12682,6 +15392,12 @@ snapshots: is-string: 1.0.7 is-symbol: 1.0.4 + which-pm-runs@1.1.0: {} + + which-pm@3.0.0: + dependencies: + load-yaml-file: 0.2.0 + which-typed-array@1.1.15: dependencies: available-typed-arrays: 1.0.7 @@ -12702,6 +15418,10 @@ snapshots: dependencies: string-width: 4.2.3 + widest-line@5.0.0: + dependencies: + string-width: 7.2.0 + word-wrap@1.2.5: {} wrap-ansi@7.0.0: @@ -12716,6 +15436,12 @@ snapshots: string-width: 5.1.2 strip-ansi: 7.1.0 + wrap-ansi@9.0.0: + dependencies: + ansi-styles: 6.2.1 + string-width: 7.2.0 + strip-ansi: 7.1.0 + wrappy@1.0.2: {} ws@8.18.0: {} @@ -12727,15 +15453,32 @@ snapshots: commander: 2.20.3 cssfilter: 0.0.10 + xxhash-wasm@1.0.2: {} + y18n@5.0.8: {} yallist@3.1.1: {} yallist@4.0.0: {} - yaml@2.4.5: {} + yaml-language-server@1.15.0: + dependencies: + ajv: 8.17.1 + lodash: 4.17.21 + request-light: 0.5.8 + vscode-json-languageservice: 4.1.8 + vscode-languageserver: 7.0.0 + vscode-languageserver-textdocument: 1.0.12 + vscode-languageserver-types: 3.17.5 + vscode-nls: 5.2.0 + vscode-uri: 3.0.8 + yaml: 2.2.2 + optionalDependencies: + prettier: 2.8.7 - yaml@2.5.1: {} + yaml@2.2.2: {} + + yaml@2.6.0: {} yargs-parser@21.1.1: {} @@ -12751,6 +15494,8 @@ snapshots: yocto-queue@0.1.0: {} + yocto-queue@1.1.1: {} + zhead@2.2.4: {} zip-stream@6.0.1: @@ -12758,3 +15503,16 @@ snapshots: archiver-utils: 5.0.2 compress-commons: 6.0.2 readable-stream: 4.5.2 + + zod-to-json-schema@3.23.3(zod@3.23.8): + dependencies: + zod: 3.23.8 + + zod-to-ts@1.2.0(typescript@5.6.3)(zod@3.23.8): + dependencies: + typescript: 5.6.3 + zod: 3.23.8 + + zod@3.23.8: {} + + zwitch@2.0.4: {} diff --git a/turbo.json b/turbo.json index f76adcfd..73dd66fc 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 }