diff --git a/.env.example b/.env.example index cf5308e8..272f7798 100644 --- a/.env.example +++ b/.env.example @@ -1,11 +1,17 @@ # Set to 1 to run using 1 thread APP_DEBUG=0 # Used for URL generation in downloads -APP_URL=http://localhost -FRONT_URL=http://localhost +APP_URL=http://localhost:3000 +FRONT_URL=http://localhost:5173 DATABASE_URL=postgres://user:password@localhost/schema PORT=8080 +# Logging (see tracing/tracing-subscriber docs) +# RUST_LOG controls verbosity, e.g. "info", "debug", "info,sqlx=warn", "geode_index=debug" +RUST_LOG=info +# LOG_FORMAT: "text" (default, human-readable) or "json" (structured, for log aggregation) +LOG_FORMAT=text + # GitHub GITHUB_CLIENT_ID= @@ -14,6 +20,7 @@ GITHUB_CLIENT_SECRET= # Discord DISCORD_WEBHOOK_URL= +INDEX_ADMIN_DISCORD_WEBHOOK_URL= # Config @@ -22,9 +29,13 @@ MAX_MOD_FILESIZE_MB=250 # Globally disables download counting, in the event of abuse DISABLE_DOWNLOAD_COUNTS=0 +# Path where uploaded files are stored on disk (default: ./storage) +# The reverse proxy must serve {STORAGE_PATH}/submission_attachments/ at /storage/submission-attachments/ +STORAGE_PATH=./storage + # Used for S3-compatible APIs (R2). Automatically disabled if S3_ENDPOINT_URL is not set. S3_ENDPOINT_URL= S3_BUCKET_NAME= S3_REGION= AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= +AWS_SECRET_ACCESS_KEY= \ No newline at end of file diff --git a/.github/workflows/deploy.yaml b/.github/workflows/deploy.yaml index 3b0a7b18..e0909803 100644 --- a/.github/workflows/deploy.yaml +++ b/.github/workflows/deploy.yaml @@ -1,57 +1,23 @@ name: Deploy Index on: - release: - types: [published] + workflow_run: + workflows: ["Publish Docker Image"] + types: [completed] workflow_dispatch: jobs: deploy: + if: ${{ github.event.workflow_run.conclusion == 'success' || github.event_name == 'workflow_dispatch' }} runs-on: ubuntu-24.04 steps: - - name: Checkout repository - uses: actions/checkout@v4 - - - name: Get release tag - id: get_tag - run: | - if [ "${{ github.event_name }}" == "release" ]; then - echo "tag=${{ github.event.release.tag_name }}" >> $GITHUB_OUTPUT - else - # For workflow_dispatch, get the latest release tag - echo "tag=$(gh release list --limit 1 --json tagName --jq '.[0].tagName')" >> $GITHUB_OUTPUT - fi - env: - GH_TOKEN: ${{ github.token }} - - - name: Download release assets - run: | - mkdir -p ./artifacts/x86_64 ./artifacts/aarch64 - gh release download ${{ steps.get_tag.outputs.tag }} \ - --pattern "geode-index-${{ steps.get_tag.outputs.tag }}-linux-x86_64" \ - --dir ./artifacts/x86_64 - gh release download ${{ steps.get_tag.outputs.tag }} \ - --pattern "geode-index-${{ steps.get_tag.outputs.tag }}-linux-aarch64" \ - --dir ./artifacts/aarch64 - chmod +x ./artifacts/x86_64/geode-index-${{ steps.get_tag.outputs.tag }}-linux-x86_64 - chmod +x ./artifacts/aarch64/geode-index-${{ steps.get_tag.outputs.tag }}-linux-aarch64 - env: - GH_TOKEN: ${{ github.token }} - - name: Create ssh key run: | install -m 600 -D /dev/null ~/.ssh/id_rsa echo "${{ secrets.PRIVATE_KEY }}" > ~/.ssh/id_rsa ssh-keyscan -p ${{ secrets.INDEX_SSH_PORT }} -H ${{ secrets.INDEX_SERVER }} > ~/.ssh/known_hosts - - name: Upload target to server - id: upload - run: | - rsync -avz -e "ssh -p ${{ secrets.INDEX_SSH_PORT }} -i ~/.ssh/id_rsa" --owner --group --chmod=775 --chown=${{ secrets.INDEX_RUN_USER }}:${{ secrets.INDEX_RUN_GROUP }} ./artifacts/${{ secrets.INDEX_DEPLOY_ARCH }}/geode-index-${{ steps.get_tag.outputs.tag }}-linux-${{ secrets.INDEX_DEPLOY_ARCH }} ${{ secrets.INDEX_USER }}@${{ secrets.INDEX_SERVER }}:${{ secrets.INDEX_COPY_LOCATION }} - rsync -avz -e "ssh -p ${{ secrets.INDEX_SSH_PORT }} -i ~/.ssh/id_rsa" --owner --group --chmod=775 --chown=${{ secrets.INDEX_RUN_USER }}:${{ secrets.INDEX_RUN_GROUP }} ./migrations/* ${{ secrets.INDEX_USER }}@${{ secrets.INDEX_SERVER }}:${{ secrets.MIGRATIONS_COPY_LOCATION }} - rsync -avz -e "ssh -p ${{ secrets.INDEX_SSH_PORT }} -i ~/.ssh/id_rsa" --owner --group --chmod=775 --chown=${{ secrets.INDEX_RUN_USER }}:${{ secrets.INDEX_RUN_GROUP }} ./storage/static ${{ secrets.INDEX_USER }}@${{ secrets.INDEX_SERVER }}:${{ secrets.STORAGE_STATIC_COPY_LOCATION }} - - - name: Run update script + - name: Deploy run: | - ssh -p ${{ secrets.INDEX_SSH_PORT }} ${{ secrets.INDEX_USER }}@${{ secrets.INDEX_SERVER }} "cd ${{ secrets.INDEX_UPDATE_SCRIPT_PATH }} && ./update.sh" - if: steps.upload.outcome == 'success' + ssh -p ${{ secrets.INDEX_SSH_PORT }} -i ~/.ssh/id_rsa ${{ secrets.INDEX_USER }}@${{ secrets.INDEX_SERVER }} \ + "cd ${{ secrets.INDEX_DIR }} && docker compose pull && docker compose up -d --wait" diff --git a/.github/workflows/publish-docker-image.yaml b/.github/workflows/publish-docker-image.yaml index 994f85c3..44f69719 100644 --- a/.github/workflows/publish-docker-image.yaml +++ b/.github/workflows/publish-docker-image.yaml @@ -83,6 +83,12 @@ jobs: username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }} + - name: Resolve build metadata + id: meta + run: | + echo "date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> "$GITHUB_OUTPUT" + echo "revision=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Build and push by digest id: build uses: docker/build-push-action@v6 @@ -90,7 +96,11 @@ jobs: context: . push: true platforms: ${{ matrix.platform }} - build-args: LIBC=${{ matrix.libc }} + build-args: | + LIBC=${{ matrix.libc }} + VERSION=${{ needs.resolve-tag.outputs.tag }} + REVISION=${{ steps.meta.outputs.revision }} + BUILD_DATE=${{ steps.meta.outputs.date }} # Push to a digest-only ref (no tag) so the merge job can assemble # the final multi-arch manifest cleanly outputs: type=image,name=${{ env.REGISTRY }}/${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true diff --git a/.sqlx/query-0411ef36ab03892ea4fcf4bb8e7ac1a75139bf9937a76d542e0fc3361fe156d1.json b/.sqlx/query-0411ef36ab03892ea4fcf4bb8e7ac1a75139bf9937a76d542e0fc3361fe156d1.json new file mode 100644 index 00000000..0bb81ef3 --- /dev/null +++ b/.sqlx/query-0411ef36ab03892ea4fcf4bb8e7ac1a75139bf9937a76d542e0fc3361fe156d1.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM mod_version_submission_comments WHERE submission_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + null + ] + }, + "hash": "0411ef36ab03892ea4fcf4bb8e7ac1a75139bf9937a76d542e0fc3361fe156d1" +} diff --git a/.sqlx/query-0519d0d7c6d97ffbff763ba2d1c85b2bc796c64f42f15f078a5639f717aa21b9.json b/.sqlx/query-0519d0d7c6d97ffbff763ba2d1c85b2bc796c64f42f15f078a5639f717aa21b9.json new file mode 100644 index 00000000..0ceae2aa --- /dev/null +++ b/.sqlx/query-0519d0d7c6d97ffbff763ba2d1c85b2bc796c64f42f15f078a5639f717aa21b9.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, comment_id, filename, created_at\n FROM mod_version_submission_comment_attachments\n WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "comment_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "filename", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "0519d0d7c6d97ffbff763ba2d1c85b2bc796c64f42f15f078a5639f717aa21b9" +} diff --git a/.sqlx/query-08d95a8c03eb7561a59d21554abfc578f6b28b7713207b17310ba98ce8fb4004.json b/.sqlx/query-08d95a8c03eb7561a59d21554abfc578f6b28b7713207b17310ba98ce8fb4004.json new file mode 100644 index 00000000..e701d96a --- /dev/null +++ b/.sqlx/query-08d95a8c03eb7561a59d21554abfc578f6b28b7713207b17310ba98ce8fb4004.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO mod_version_submission_comment_audit (comment_id, action, details, performed_by)VALUES ($1, $2, $3, $4)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8", + { + "Custom": { + "name": "audit_action", + "kind": { + "Enum": [ + "created", + "updated", + "deleted", + "restored" + ] + } + } + }, + "Text", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "08d95a8c03eb7561a59d21554abfc578f6b28b7713207b17310ba98ce8fb4004" +} diff --git a/.sqlx/query-1ee95124185a80b20fffcc2ca64084ef1440d4a166a1296bedf9529395272486.json b/.sqlx/query-1ee95124185a80b20fffcc2ca64084ef1440d4a166a1296bedf9529395272486.json new file mode 100644 index 00000000..5b67d135 --- /dev/null +++ b/.sqlx/query-1ee95124185a80b20fffcc2ca64084ef1440d4a166a1296bedf9529395272486.json @@ -0,0 +1,54 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n id, submission_id, comment, author_id,\n created_at, updated_at\n FROM mod_version_submission_comments\n WHERE submission_id = $1\n ORDER BY id DESC\n LIMIT $2 OFFSET $3", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "submission_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "comment", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "author_id", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int4", + "Int8", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true + ] + }, + "hash": "1ee95124185a80b20fffcc2ca64084ef1440d4a166a1296bedf9529395272486" +} diff --git a/.sqlx/query-27c67f136d2f37a916b0bfb8d4966304005a571cd8871cc84b2010315c7d9692.json b/.sqlx/query-27c67f136d2f37a916b0bfb8d4966304005a571cd8871cc84b2010315c7d9692.json new file mode 100644 index 00000000..a117b30e --- /dev/null +++ b/.sqlx/query-27c67f136d2f37a916b0bfb8d4966304005a571cd8871cc84b2010315c7d9692.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT mod_versions.id\n FROM mod_versions\n INNER JOIN mods ON mods.id = mod_versions.mod_id\n INNER JOIN mods_developers ON mods.id = mods_developers.mod_id\n INNER JOIN mod_version_statuses ON mod_version_statuses.id = mod_versions.status_id\n WHERE mod_version_statuses.status = 'accepted'\n AND mods_developers.developer_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false + ] + }, + "hash": "27c67f136d2f37a916b0bfb8d4966304005a571cd8871cc84b2010315c7d9692" +} diff --git a/.sqlx/query-3a9e0f2e4c810e63811251c2da5a387a44c47030bcac9aba859ed5080dcc165e.json b/.sqlx/query-3a9e0f2e4c810e63811251c2da5a387a44c47030bcac9aba859ed5080dcc165e.json new file mode 100644 index 00000000..04a238dd --- /dev/null +++ b/.sqlx/query-3a9e0f2e4c810e63811251c2da5a387a44c47030bcac9aba859ed5080dcc165e.json @@ -0,0 +1,70 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE mod_version_submissions\n SET lock = $1, locked_by = $2, updated_at = NOW()\n WHERE mod_version_id = $3\n RETURNING mod_version_id, lock as \"lock: _\", locked_by, created_at, updated_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_version_id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "lock: _", + "type_info": { + "Custom": { + "name": "submission_lock", + "kind": { + "Enum": [ + "none", + "internal", + "locked" + ] + } + } + } + }, + { + "ordinal": 2, + "name": "locked_by", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + { + "Custom": { + "name": "submission_lock", + "kind": { + "Enum": [ + "none", + "internal", + "locked" + ] + } + } + }, + "Int4", + "Int4" + ] + }, + "nullable": [ + false, + false, + true, + false, + false + ] + }, + "hash": "3a9e0f2e4c810e63811251c2da5a387a44c47030bcac9aba859ed5080dcc165e" +} diff --git a/.sqlx/query-3e911a3e12f4d239c0d1a3185c7f54d36ff0819639c15a2e70d32dce65a39803.json b/.sqlx/query-3e911a3e12f4d239c0d1a3185c7f54d36ff0819639c15a2e70d32dce65a39803.json new file mode 100644 index 00000000..6ea2de4f --- /dev/null +++ b/.sqlx/query-3e911a3e12f4d239c0d1a3185c7f54d36ff0819639c15a2e70d32dce65a39803.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, comment_id, filename, created_at\n FROM mod_version_submission_comment_attachments\n WHERE comment_id = ANY($1)\n ORDER BY id ASC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "comment_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "filename", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8Array" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "3e911a3e12f4d239c0d1a3185c7f54d36ff0819639c15a2e70d32dce65a39803" +} diff --git a/.sqlx/query-4ad2b61d2fa7a7edc9ec5ec68eaed41b993407ae32bf0e90fbdd2c8ca3ea9d22.json b/.sqlx/query-4ad2b61d2fa7a7edc9ec5ec68eaed41b993407ae32bf0e90fbdd2c8ca3ea9d22.json new file mode 100644 index 00000000..4d84f0d3 --- /dev/null +++ b/.sqlx/query-4ad2b61d2fa7a7edc9ec5ec68eaed41b993407ae32bf0e90fbdd2c8ca3ea9d22.json @@ -0,0 +1,41 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO mod_version_submission_comment_attachments (comment_id, filename)\n VALUES ($1, $2)\n RETURNING id, comment_id, filename, created_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "comment_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "filename", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "4ad2b61d2fa7a7edc9ec5ec68eaed41b993407ae32bf0e90fbdd2c8ca3ea9d22" +} diff --git a/.sqlx/query-5687bf51d606c491a75a05ffed72bd71dcb15053d899b6a8cd6cc75327d52bff.json b/.sqlx/query-5687bf51d606c491a75a05ffed72bd71dcb15053d899b6a8cd6cc75327d52bff.json new file mode 100644 index 00000000..606c83e6 --- /dev/null +++ b/.sqlx/query-5687bf51d606c491a75a05ffed72bd71dcb15053d899b6a8cd6cc75327d52bff.json @@ -0,0 +1,57 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n mod_version_id, lock as \"lock: _\", locked_by,\n created_at, updated_at\n FROM mod_version_submissions\n WHERE mod_version_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_version_id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "lock: _", + "type_info": { + "Custom": { + "name": "submission_lock", + "kind": { + "Enum": [ + "none", + "internal", + "locked" + ] + } + } + } + }, + { + "ordinal": 2, + "name": "locked_by", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + false, + true, + false, + false + ] + }, + "hash": "5687bf51d606c491a75a05ffed72bd71dcb15053d899b6a8cd6cc75327d52bff" +} diff --git a/.sqlx/query-6be04be9bbf53814144d17355ba47ba12f948e60e815ad917846d7ff6733245a.json b/.sqlx/query-6be04be9bbf53814144d17355ba47ba12f948e60e815ad917846d7ff6733245a.json new file mode 100644 index 00000000..3df40f25 --- /dev/null +++ b/.sqlx/query-6be04be9bbf53814144d17355ba47ba12f948e60e815ad917846d7ff6733245a.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n action as \"action: _\", details, performed_by, performed_at\n FROM mod_version_submissions_audit\n WHERE submission_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "action: _", + "type_info": { + "Custom": { + "name": "audit_action", + "kind": { + "Enum": [ + "created", + "updated", + "deleted", + "restored" + ] + } + } + } + }, + { + "ordinal": 1, + "name": "details", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "performed_by", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "performed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + true, + true, + false + ] + }, + "hash": "6be04be9bbf53814144d17355ba47ba12f948e60e815ad917846d7ff6733245a" +} diff --git a/.sqlx/query-75645a63e093cdaadb0ba84844e6299e3e19eee7b530be498c609e6cd6fc2b33.json b/.sqlx/query-75645a63e093cdaadb0ba84844e6299e3e19eee7b530be498c609e6cd6fc2b33.json new file mode 100644 index 00000000..0b4f3933 --- /dev/null +++ b/.sqlx/query-75645a63e093cdaadb0ba84844e6299e3e19eee7b530be498c609e6cd6fc2b33.json @@ -0,0 +1,40 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, comment_id, filename, created_at\n FROM mod_version_submission_comment_attachments\n WHERE comment_id = $1\n ORDER BY id ASC", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "comment_id", + "type_info": "Int8" + }, + { + "ordinal": 2, + "name": "filename", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false + ] + }, + "hash": "75645a63e093cdaadb0ba84844e6299e3e19eee7b530be498c609e6cd6fc2b33" +} diff --git a/.sqlx/query-7a9ffa7b59f3550e2bd38c756b9cc3566512dc2273dc3b72ba1ba5c2d281aeb9.json b/.sqlx/query-7a9ffa7b59f3550e2bd38c756b9cc3566512dc2273dc3b72ba1ba5c2d281aeb9.json new file mode 100644 index 00000000..568b54b1 --- /dev/null +++ b/.sqlx/query-7a9ffa7b59f3550e2bd38c756b9cc3566512dc2273dc3b72ba1ba5c2d281aeb9.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM mod_version_submission_comment_attachments WHERE filename = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "7a9ffa7b59f3550e2bd38c756b9cc3566512dc2273dc3b72ba1ba5c2d281aeb9" +} diff --git a/.sqlx/query-8affc6971c1e33b14f3696d0bd9daf4b82d907eba99837083e21909ea9c195a7.json b/.sqlx/query-8affc6971c1e33b14f3696d0bd9daf4b82d907eba99837083e21909ea9c195a7.json new file mode 100644 index 00000000..2ca4f752 --- /dev/null +++ b/.sqlx/query-8affc6971c1e33b14f3696d0bd9daf4b82d907eba99837083e21909ea9c195a7.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n id,\n username,\n display_name,\n verified,\n admin,\n github_user_id as github_id\n FROM developers\n WHERE id = ANY($1)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "username", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "display_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "verified", + "type_info": "Bool" + }, + { + "ordinal": 4, + "name": "admin", + "type_info": "Bool" + }, + { + "ordinal": 5, + "name": "github_id", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int4Array" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "8affc6971c1e33b14f3696d0bd9daf4b82d907eba99837083e21909ea9c195a7" +} diff --git a/.sqlx/query-8c1f2dd19652bb6c393d83c6443bc4cda1f108a749cc953adb9d25a2fffa4072.json b/.sqlx/query-8c1f2dd19652bb6c393d83c6443bc4cda1f108a749cc953adb9d25a2fffa4072.json new file mode 100644 index 00000000..4f049ef8 --- /dev/null +++ b/.sqlx/query-8c1f2dd19652bb6c393d83c6443bc4cda1f108a749cc953adb9d25a2fffa4072.json @@ -0,0 +1,53 @@ +{ + "db_name": "PostgreSQL", + "query": "UPDATE mod_version_submission_comments\n SET comment = $1, updated_at = NOW()\n WHERE id = $2\n RETURNING id, submission_id, comment, author_id, created_at, updated_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "submission_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "comment", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "author_id", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text", + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true + ] + }, + "hash": "8c1f2dd19652bb6c393d83c6443bc4cda1f108a749cc953adb9d25a2fffa4072" +} diff --git a/.sqlx/query-91bc425cc6b35915fc00d39b1f127118f0a35841049fc0914add705241d471f6.json b/.sqlx/query-91bc425cc6b35915fc00d39b1f127118f0a35841049fc0914add705241d471f6.json new file mode 100644 index 00000000..4f45f9bb --- /dev/null +++ b/.sqlx/query-91bc425cc6b35915fc00d39b1f127118f0a35841049fc0914add705241d471f6.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT COUNT(*) FROM mod_version_submission_comment_attachments WHERE comment_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + null + ] + }, + "hash": "91bc425cc6b35915fc00d39b1f127118f0a35841049fc0914add705241d471f6" +} diff --git a/.sqlx/query-9a23bc60988017bdb2d3770f0cbe4e806b378409a148cb0728c03c70470954f5.json b/.sqlx/query-9a23bc60988017bdb2d3770f0cbe4e806b378409a148cb0728c03c70470954f5.json new file mode 100644 index 00000000..05d0e991 --- /dev/null +++ b/.sqlx/query-9a23bc60988017bdb2d3770f0cbe4e806b378409a148cb0728c03c70470954f5.json @@ -0,0 +1,57 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO mod_version_submissions (mod_version_id)\n VALUES ($1)\n RETURNING mod_version_id, lock as \"lock: _\", locked_by, created_at, updated_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "mod_version_id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "lock: _", + "type_info": { + "Custom": { + "name": "submission_lock", + "kind": { + "Enum": [ + "none", + "internal", + "locked" + ] + } + } + } + }, + { + "ordinal": 2, + "name": "locked_by", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 4, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + false, + true, + false, + false + ] + }, + "hash": "9a23bc60988017bdb2d3770f0cbe4e806b378409a148cb0728c03c70470954f5" +} diff --git a/.sqlx/query-9eebe36dd30154f9ca5943404eff10f6cbb224587556161b25507d5e56cdbb46.json b/.sqlx/query-9eebe36dd30154f9ca5943404eff10f6cbb224587556161b25507d5e56cdbb46.json new file mode 100644 index 00000000..5b672920 --- /dev/null +++ b/.sqlx/query-9eebe36dd30154f9ca5943404eff10f6cbb224587556161b25507d5e56cdbb46.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM mod_version_submission_comment_attachments WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "9eebe36dd30154f9ca5943404eff10f6cbb224587556161b25507d5e56cdbb46" +} diff --git a/.sqlx/query-b248e326b0e2d3c803bedffef28fa22a00f19dca10f573b3f9331a830b0884d7.json b/.sqlx/query-b248e326b0e2d3c803bedffef28fa22a00f19dca10f573b3f9331a830b0884d7.json new file mode 100644 index 00000000..ff275ba1 --- /dev/null +++ b/.sqlx/query-b248e326b0e2d3c803bedffef28fa22a00f19dca10f573b3f9331a830b0884d7.json @@ -0,0 +1,28 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n filename, COUNT(*) as count\n FROM mod_version_submission_comment_attachments\n WHERE filename = ANY($1)\n GROUP BY filename", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "filename", + "type_info": "Text" + }, + { + "ordinal": 1, + "name": "count", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "TextArray" + ] + }, + "nullable": [ + false, + null + ] + }, + "hash": "b248e326b0e2d3c803bedffef28fa22a00f19dca10f573b3f9331a830b0884d7" +} diff --git a/.sqlx/query-bad99b175dfe2ffccfc71b69175b2593d77d4d24d6e2d9ba052ba4bf1cc6489d.json b/.sqlx/query-bad99b175dfe2ffccfc71b69175b2593d77d4d24d6e2d9ba052ba4bf1cc6489d.json new file mode 100644 index 00000000..f983d45f --- /dev/null +++ b/.sqlx/query-bad99b175dfe2ffccfc71b69175b2593d77d4d24d6e2d9ba052ba4bf1cc6489d.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT id, submission_id, comment, author_id, created_at, updated_at\n FROM mod_version_submission_comments\n WHERE id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "submission_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "comment", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "author_id", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true + ] + }, + "hash": "bad99b175dfe2ffccfc71b69175b2593d77d4d24d6e2d9ba052ba4bf1cc6489d" +} diff --git a/.sqlx/query-ddcfdb20c8c93a41da225a937478a56617000567bfd04ed8f9be0e501f438937.json b/.sqlx/query-ddcfdb20c8c93a41da225a937478a56617000567bfd04ed8f9be0e501f438937.json new file mode 100644 index 00000000..644bfe33 --- /dev/null +++ b/.sqlx/query-ddcfdb20c8c93a41da225a937478a56617000567bfd04ed8f9be0e501f438937.json @@ -0,0 +1,54 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO mod_version_submission_comments (submission_id, author_id, comment)\n VALUES ($1, $2, $3)\n RETURNING id, submission_id, comment, author_id, created_at, updated_at", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Int8" + }, + { + "ordinal": 1, + "name": "submission_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "comment", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "author_id", + "type_info": "Int4" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int4", + "Int4", + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + true + ] + }, + "hash": "ddcfdb20c8c93a41da225a937478a56617000567bfd04ed8f9be0e501f438937" +} diff --git a/.sqlx/query-dfe205a13013db7ee5dbeb0ea3011a909895fdc0e98541bed9816362862968f7.json b/.sqlx/query-dfe205a13013db7ee5dbeb0ea3011a909895fdc0e98541bed9816362862968f7.json new file mode 100644 index 00000000..a2e157fb --- /dev/null +++ b/.sqlx/query-dfe205a13013db7ee5dbeb0ea3011a909895fdc0e98541bed9816362862968f7.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT\n action as \"action: _\", details, performed_by, performed_at\n FROM mod_version_submission_comment_audit\n WHERE comment_id = $1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "action: _", + "type_info": { + "Custom": { + "name": "audit_action", + "kind": { + "Enum": [ + "created", + "updated", + "deleted", + "restored" + ] + } + } + } + }, + { + "ordinal": 1, + "name": "details", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "performed_by", + "type_info": "Int4" + }, + { + "ordinal": 3, + "name": "performed_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [ + false, + true, + true, + false + ] + }, + "hash": "dfe205a13013db7ee5dbeb0ea3011a909895fdc0e98541bed9816362862968f7" +} diff --git a/.sqlx/query-ea5cf95e89891cf0650305824775fa8cffd29bd82f9ee848b95b11b1f9f03777.json b/.sqlx/query-ea5cf95e89891cf0650305824775fa8cffd29bd82f9ee848b95b11b1f9f03777.json new file mode 100644 index 00000000..ad6b709b --- /dev/null +++ b/.sqlx/query-ea5cf95e89891cf0650305824775fa8cffd29bd82f9ee848b95b11b1f9f03777.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "DELETE FROM mod_version_submission_comments WHERE id = $1", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int8" + ] + }, + "nullable": [] + }, + "hash": "ea5cf95e89891cf0650305824775fa8cffd29bd82f9ee848b95b11b1f9f03777" +} diff --git a/.sqlx/query-f2c57d8bc7752c080fc45a6dd5b1fcc9f5ebe72d4948e0403ef5d488a9c8a370.json b/.sqlx/query-f2c57d8bc7752c080fc45a6dd5b1fcc9f5ebe72d4948e0403ef5d488a9c8a370.json new file mode 100644 index 00000000..b232b577 --- /dev/null +++ b/.sqlx/query-f2c57d8bc7752c080fc45a6dd5b1fcc9f5ebe72d4948e0403ef5d488a9c8a370.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT mods.id FROM mods\n INNER JOIN mod_versions ON mods.id = mod_versions.mod_id\n INNER JOIN mod_version_statuses ON mod_version_statuses.id = mod_versions.status_id\n INNER JOIN mods_developers ON mods.id = mods_developers.mod_id\n WHERE mods_developers.developer_id = $1\n AND mod_version_statuses.status = 'accepted'\n LIMIT 1", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false + ] + }, + "hash": "f2c57d8bc7752c080fc45a6dd5b1fcc9f5ebe72d4948e0403ef5d488a9c8a370" +} diff --git a/.sqlx/query-f3f632c6f1aa43789e299dc3a9f16d4e4b91d9ae80380a3191a14e49c629bf7d.json b/.sqlx/query-f3f632c6f1aa43789e299dc3a9f16d4e4b91d9ae80380a3191a14e49c629bf7d.json new file mode 100644 index 00000000..dee34896 --- /dev/null +++ b/.sqlx/query-f3f632c6f1aa43789e299dc3a9f16d4e4b91d9ae80380a3191a14e49c629bf7d.json @@ -0,0 +1,29 @@ +{ + "db_name": "PostgreSQL", + "query": "INSERT INTO mod_version_submissions_audit (submission_id, action, details, performed_by)\n VALUES ($1, $2, $3, $4)", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + { + "Custom": { + "name": "audit_action", + "kind": { + "Enum": [ + "created", + "updated", + "deleted", + "restored" + ] + } + } + }, + "Text", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "f3f632c6f1aa43789e299dc3a9f16d4e4b91d9ae80380a3191a14e49c629bf7d" +} diff --git a/Cargo.lock b/Cargo.lock index 70055ca3..44a4423d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -34,11 +34,34 @@ dependencies = [ "smallvec", ] +[[package]] +name = "actix-files" +version = "0.6.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8c4f30e3272d7c345f88ae0aac3848507ef5ba871f9cc2a41c8085a0f0523b" +dependencies = [ + "actix-http", + "actix-service", + "actix-utils", + "actix-web", + "bitflags", + "bytes", + "derive_more", + "futures-core", + "http-range", + "log", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "v_htmlescape", +] + [[package]] name = "actix-http" -version = "3.12.1" +version = "3.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "93acb4a42f64936f9b8cae4a433b237599dd6eb6ed06124eb67132ef8cc90662" +checksum = "48e2faa3e7418ed780cca54829d32782a4008a077230f67457caa063415e99c2" dependencies = [ "actix-codec", "actix-rt", @@ -52,7 +75,7 @@ dependencies = [ "derive_more", "encoding_rs", "flate2", - "foldhash 0.1.5", + "foldhash 0.2.0", "futures-core", "h2 0.3.27", "http 0.2.12", @@ -64,7 +87,7 @@ dependencies = [ "mime", "percent-encoding", "pin-project-lite", - "rand 0.10.1", + "rand 0.10.2", "sha1 0.11.0", "smallvec", "tokio", @@ -83,6 +106,44 @@ dependencies = [ "syn", ] +[[package]] +name = "actix-multipart" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "560e3dd4eae03837f86d1b6bf6222c508568eff36845ef5ebb3a0dff480e3f64" +dependencies = [ + "actix-multipart-derive", + "actix-utils", + "actix-web", + "derive_more", + "futures-core", + "futures-util", + "httparse", + "local-waker", + "log", + "memchr", + "mime", + "rand 0.10.2", + "serde", + "serde_json", + "serde_plain", + "tempfile", + "tokio", +] + +[[package]] +name = "actix-multipart-derive" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8720bceaa6797fd8b2deab968d52e1120b2a8c30950939f6c8cdb42a910bc885" +dependencies = [ + "bytesize", + "darling", + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "actix-router" version = "0.5.4" @@ -147,9 +208,9 @@ dependencies = [ [[package]] name = "actix-web" -version = "4.13.0" +version = "4.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ff87453bc3b56e9b2b23c1cc0b1be8797184accf51d2abe0f8a33ec275d316bf" +checksum = "df09e2d9239703dd64056359c920c7f3fba6535ec61a0059e0f44e095ffe02b4" dependencies = [ "actix-codec", "actix-http", @@ -166,7 +227,7 @@ dependencies = [ "cookie", "derive_more", "encoding_rs", - "foldhash 0.1.5", + "foldhash 0.2.0", "futures-core", "futures-util", "impl-more", @@ -182,7 +243,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2 0.6.3", + "socket2 0.6.4", "time", "tracing", "url", @@ -208,13 +269,13 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aes" -version = "0.8.4" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +checksum = "f1fc76eaeac4c9164506c466d4ffdd8ec9d0c5bf57ee97177c4d8eceb3a0e138" dependencies = [ - "cfg-if", "cipher", - "cpufeatures 0.2.17", + "cpubits", + "cpufeatures 0.3.0", ] [[package]] @@ -252,9 +313,9 @@ checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" [[package]] name = "alloc-stdlib" -version = "0.2.2" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +checksum = "0e76a019e91224d279006ff972f1e984179a6e9feb050adba6ce8274aef23195" dependencies = [ "alloc-no-stdlib", ] @@ -265,6 +326,18 @@ version = "0.2.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" +[[package]] +name = "ammonia" +version = "4.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68b9d3370580a12f4b7a10fdcc18b28942c083ba570e3d954fe59d10951b85a2" +dependencies = [ + "cssparser", + "html5ever", + "maplit", + "url", +] + [[package]] name = "android_system_properties" version = "0.1.5" @@ -326,9 +399,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3" [[package]] name = "arbitrary" @@ -339,15 +412,6 @@ dependencies = [ "derive_arbitrary", ] -[[package]] -name = "arc-swap" -version = "1.9.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a3a1fd6f75306b68087b831f025c712524bcb19aad54e557b1129cfa0a2b207" -dependencies = [ - "rustversion", -] - [[package]] name = "arg_enum_proc_macro" version = "0.3.4" @@ -361,9 +425,9 @@ dependencies = [ [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "as-slice" @@ -425,9 +489,9 @@ checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" [[package]] name = "autocfg" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" [[package]] name = "av-scenechange" @@ -444,7 +508,7 @@ dependencies = [ "num-traits", "pastey", "rayon", - "thiserror 2.0.18", + "thiserror", "v_frame", "y4m", ] @@ -465,9 +529,9 @@ dependencies = [ [[package]] name = "avif-serialize" -version = "0.8.8" +version = "0.8.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "375082f007bd67184fb9c0374614b29f9aaa604ec301635f72338bb65386a53d" +checksum = "e7178fe5f7d460b13895ebb9dcb28a3a6216d2df2574a0806cb51b555d297f38" dependencies = [ "arrayvec", ] @@ -517,9 +581,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.16.3" +version = "1.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ec6fb3fe69024a75fa7e1bfb48aa6cf59706a101658ea01bfd33b2b248a038f" +checksum = "4342d8937fc7e5dd9b1c60292261c0670c882a2cd1719cfc11b1af41731e32ad" dependencies = [ "aws-lc-sys", "zeroize", @@ -527,14 +591,15 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.40.0" +version = "0.42.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f50037ee5e1e41e7b8f9d161680a725bd1626cb6f8c7e901f91f942850852fe7" +checksum = "6d9ceb1da931507a12f4fccea479dccd00da1943e1b4ae72d8e502d707361444" dependencies = [ "cc", "cmake", "dunce", "fs_extra", + "pkg-config", ] [[package]] @@ -981,9 +1046,9 @@ checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" dependencies = [ "serde_core", ] @@ -1008,18 +1073,19 @@ dependencies = [ [[package]] name = "block-buffer" -version = "0.12.0" +version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" dependencies = [ "hybrid-array", + "zeroize", ] [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "5cc91aac060a7a1e25823bdccbfb6af1875b88f17c6daac97894eed8207166b3" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -1028,9 +1094,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "3a32acac15fe1967bc3986b2a6347dffc965602354ea6f450ad07e8bfd253583" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -1038,21 +1104,21 @@ dependencies = [ [[package]] name = "built" -version = "0.8.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4ad8f11f288f48ca24471bbd51ac257aaeaaa07adae295591266b792902ae64" +checksum = "5c0e531d93d39c34eef561e929e8a7f86d77a5af08aac4f6d6e39976c51858e9" [[package]] name = "bumpalo" -version = "3.20.2" +version = "3.20.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "d6aedf8ae72766347502cf3cb4f41cf5e9cc37d28bee90f1fdaaae15f9cf9424" [[package]] name = "byteorder" @@ -1068,9 +1134,15 @@ checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "bytesize" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7c8918969267b2932ffd5655509bbbea0833823058c378876953217f5fc50e" [[package]] name = "bytes-utils" @@ -1084,9 +1156,9 @@ dependencies = [ [[package]] name = "bytestring" -version = "1.5.0" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "113b4343b5f6617e7ad401ced8de3cc8b012e73a594347c307b90db3e9271289" +checksum = "86566c496f2f47d9b8147a4c8b02ffdb69c919fe0c2b2e7195d22cbba0e635c9" dependencies = [ "bytes", ] @@ -1102,9 +1174,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.61" +version = "1.2.66" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d16d90359e986641506914ba71350897565610e87ce0ad9e6f28569db3dd5c6d" +checksum = "f5d6cac793997bd970000024b2934968efe83b382de4fdcf4fcb46b6ee4ad996" dependencies = [ "find-msvc-tools", "jobserver", @@ -1112,12 +1184,6 @@ dependencies = [ "shlex", ] -[[package]] -name = "cesu8" -version = "1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" - [[package]] name = "cfg-if" version = "1.0.4" @@ -1132,9 +1198,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chacha20" -version = "0.10.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" dependencies = [ "cfg-if", "cpufeatures 0.3.0", @@ -1143,9 +1209,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -1157,11 +1223,11 @@ dependencies = [ [[package]] name = "cipher" -version = "0.4.4" +version = "0.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" dependencies = [ - "crypto-common 0.1.7", + "crypto-common 0.2.2", "inout", ] @@ -1282,9 +1348,9 @@ checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" [[package]] name = "constant_time_eq" -version = "0.3.1" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c74b8349d32d297c9134b8c88677813a227df8f779daa29bfc29c183fe3dca6" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" [[package]] name = "convert_case" @@ -1322,6 +1388,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1342,9 +1414,9 @@ dependencies = [ [[package]] name = "crc" -version = "3.3.0" +version = "3.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9710d3b3739c2e349eb44fe848ad0b7c8cb1e42bd87ee49371df2f7acaf3e675" +checksum = "5eb8a2a1cd12ab0d987a5d5e825195d372001a4094a0376319d5a0ad71c1ba0d" dependencies = [ "crc-catalog", ] @@ -1376,18 +1448,18 @@ dependencies = [ [[package]] name = "crossbeam-channel" -version = "0.5.15" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-deque" -version = "0.8.6" +version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" dependencies = [ "crossbeam-epoch", "crossbeam-utils", @@ -1395,27 +1467,27 @@ dependencies = [ [[package]] name = "crossbeam-epoch" -version = "0.9.18" +version = "0.9.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b82ac4a3c2ca9c3460964f020e1402edd5753411d7737aa39c3714ad1b5420e" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.22" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" [[package]] name = "crunchy" @@ -1447,13 +1519,24 @@ dependencies = [ [[package]] name = "crypto-common" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710" +checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453" dependencies = [ "hybrid-array", ] +[[package]] +name = "cssparser" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98" +dependencies = [ + "dtoa-short", + "itoa", + "smallvec", +] + [[package]] name = "ctutils" version = "0.4.2" @@ -1465,9 +1548,9 @@ dependencies = [ [[package]] name = "darling" -version = "0.20.11" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" dependencies = [ "darling_core", "darling_macro", @@ -1475,11 +1558,10 @@ dependencies = [ [[package]] name = "darling_core" -version = "0.20.11" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" dependencies = [ - "fnv", "ident_case", "proc-macro2", "quote", @@ -1489,9 +1571,9 @@ dependencies = [ [[package]] name = "darling_macro" -version = "0.20.11" +version = "0.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" dependencies = [ "darling_core", "quote", @@ -1520,9 +1602,6 @@ name = "deranged" version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" -dependencies = [ - "powerfmt", -] [[package]] name = "derive_arbitrary" @@ -1558,12 +1637,6 @@ dependencies = [ "unicode-xid", ] -[[package]] -name = "destructure_traitobject" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c877555693c14d2f84191cfd3ad8582790fc52b5e2274b40b59cf5f5cea25c7" - [[package]] name = "digest" version = "0.10.7" @@ -1578,21 +1651,22 @@ dependencies = [ [[package]] name = "digest" -version = "0.11.2" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4850db49bf08e663084f7fb5c87d202ef91a3907271aff24a94eb97ff039153c" +checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ - "block-buffer 0.12.0", + "block-buffer 0.12.1", "const-oid 0.10.2", - "crypto-common 0.2.1", + "crypto-common 0.2.2", "ctutils", + "zeroize", ] [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -1605,6 +1679,21 @@ version = "0.15.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b" +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + [[package]] name = "dunce" version = "1.0.5" @@ -1627,9 +1716,9 @@ dependencies = [ [[package]] name = "either" -version = "1.15.0" +version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "48c757948c5ede0e46177b7add2e67155f70e33c07fea8284df6576da70b3719" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" dependencies = [ "serde", ] @@ -1733,14 +1822,16 @@ dependencies = [ [[package]] name = "exr" -version = "1.74.0" +version = "1.74.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4300e043a56aa2cb633c01af81ca8f699a321879a7854d3896a0ba89056363be" +checksum = "711fe42c9964295e01ee3fba3f9fe0e1d24b98886950d68efe81b1c76e21adf3" dependencies = [ "bit_field", "half", "lebe", "miniz_oxide", + "num-complex", + "pulp", "rayon-core", "smallvec", "zune-inflate", @@ -1754,23 +1845,9 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "fax" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05de7d48f37cd6730705cbca900770cab77a89f413d23e100ad7fad7795a0ab" -dependencies = [ - "fax_derive", -] - -[[package]] -name = "fax_derive" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" [[package]] name = "fdeflate" @@ -1964,11 +2041,15 @@ dependencies = [ [[package]] name = "geode-index" -version = "0.53.2" +version = "0.55.1" dependencies = [ "actix-cors", + "actix-files", + "actix-multipart", "actix-web", + "ammonia", "anyhow", + "bytes", "aws-config", "aws-sdk-s3", "chrono", @@ -1976,8 +2057,6 @@ dependencies = [ "dotenvy", "futures", "image", - "log", - "log4rs", "lzma-rust2", "moka", "regex", @@ -1987,14 +2066,17 @@ dependencies = [ "serde_json", "sha256", "sqlx", - "thiserror 2.0.18", + "thiserror", "tokio", + "tracing", + "tracing-actix-web", + "tracing-subscriber", "urlencoding", "utoipa", "utoipa-swagger-ui", "uuid", "validator", - "zip 7.2.0", + "zip 8.6.0", ] [[package]] @@ -2017,25 +2099,23 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi 5.3.0", "wasip2", - "wasm-bindgen", ] [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 6.0.0", "rand_core 0.10.1", - "wasip2", - "wasip3", + "wasm-bindgen", ] [[package]] @@ -2121,20 +2201,9 @@ dependencies = [ [[package]] name = "hashbrown" -version = "0.16.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" -dependencies = [ - "allocator-api2", - "equivalent", - "foldhash 0.2.0", -] - -[[package]] -name = "hashbrown" -version = "0.17.0" +version = "0.17.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" @@ -2181,7 +2250,7 @@ version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f" dependencies = [ - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -2193,6 +2262,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "html5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8" +dependencies = [ + "log", + "markup5ever", +] + [[package]] name = "http" version = "0.2.12" @@ -2206,9 +2285,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" dependencies = [ "bytes", "itoa", @@ -2232,7 +2311,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" dependencies = [ "bytes", - "http 1.4.0", + "http 1.4.2", ] [[package]] @@ -2243,11 +2322,17 @@ checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" dependencies = [ "bytes", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http 1.4.2", + "http-body", "pin-project-lite", ] +[[package]] +name = "http-range" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21dec9db110f5f872ed9699c3ecf50cf16f423502706ba5c72462e28d3157573" + [[package]] name = "httparse" version = "1.10.1" @@ -2260,58 +2345,27 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" -[[package]] -name = "humantime" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" - [[package]] name = "hybrid-array" -version = "0.4.11" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5" +checksum = "818356c5132c1fede50f837ca96afbe78ff42413047f4abb886217845e1b6c8c" dependencies = [ "typenum", ] [[package]] name = "hyper" -version = "0.14.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" -dependencies = [ - "bytes", - "futures-channel", - "futures-core", - "futures-util", - "h2 0.3.27", - "http 0.2.12", - "http-body 0.4.6", - "httparse", - "httpdate", - "itoa", - "pin-project-lite", - "socket2 0.5.10", - "tokio", - "tower-service", - "tracing", - "want", -] - -[[package]] -name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", "futures-channel", "futures-core", - "h2 0.4.15", - "http 1.4.0", - "http-body 1.0.1", + "http 1.4.2", + "http-body", "httparse", "itoa", "pin-project-lite", @@ -2341,8 +2395,8 @@ version = "0.27.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" dependencies = [ - "http 1.4.0", - "hyper 1.9.0", + "http 1.4.2", + "hyper", "hyper-util", "rustls 0.23.39", "rustls-native-certs", @@ -2361,14 +2415,14 @@ dependencies = [ "bytes", "futures-channel", "futures-util", - "http 1.4.0", - "http-body 1.0.1", - "hyper 1.9.0", + "http 1.4.2", + "http-body", + "hyper", "ipnet", "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.3", + "socket2 0.6.4", "tokio", "tower-service", "tracing", @@ -2480,12 +2534,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -2505,9 +2553,9 @@ dependencies = [ [[package]] name = "idna_adapter" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" dependencies = [ "icu_normalizer", "icu_properties", @@ -2549,15 +2597,15 @@ dependencies = [ [[package]] name = "imgref" -version = "1.12.0" +version = "1.12.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e7c5cedc30da3a610cac6b4ba17597bdf7152cf974e8aab3afb3d54455e371c8" +checksum = "89194689a993ab15268672e99e7b0e19da2da3268ac682e8f02d29d4d1434cd7" [[package]] name = "impl-more" -version = "0.1.9" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8a5a9a0ff0086c7a148acb942baaabeadf9504d10400b5a05645853729b9cd2" +checksum = "35a84fd5aa25fae5c0f4a33d9cac2ca017fc622cbd089be2229993514990f870" [[package]] name = "indexmap" @@ -2566,18 +2614,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "serde", "serde_core", ] [[package]] name = "inout" -version = "0.1.4" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" dependencies = [ - "generic-array", + "hybrid-array", ] [[package]] @@ -2606,16 +2654,6 @@ dependencies = [ "serde", ] -[[package]] -name = "iri-string" -version = "0.7.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" -dependencies = [ - "memchr", - "serde", -] - [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -2639,27 +2677,32 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jni" -version = "0.21.1" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" dependencies = [ - "cesu8", "cfg-if", "combine", - "jni-sys 0.3.1", + "jni-macros", + "jni-sys", "log", - "thiserror 1.0.69", + "simd_cesu8", + "thiserror", "walkdir", - "windows-sys 0.45.0", + "windows-link", ] [[package]] -name = "jni-sys" -version = "0.3.1" +name = "jni-macros" +version = "0.22.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" dependencies = [ - "jni-sys 0.4.1", + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn", ] [[package]] @@ -2683,23 +2726,22 @@ dependencies = [ [[package]] name = "jobserver" -version = "0.1.34" +version = "0.1.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" dependencies = [ - "getrandom 0.3.4", + "getrandom 0.4.3", "libc", ] [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -2718,12 +2760,6 @@ dependencies = [ "spin 0.9.8", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "lebe" version = "0.5.3" @@ -2732,9 +2768,9 @@ checksum = "7a79a3332a6609480d7d0c9eab957bca6b455b91bb84e66d19f5ff66294b85b8" [[package]] name = "libbz2-rs-sys" -version = "0.2.3" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3a6a8c165077efc8f3a971534c50ea6a1a18b329ef4a66e897a7e3a1494565f" +checksum = "34b357333733e8260735ba5894eb928c02ecc69c78715f01a8019e7fa7f2db4c" [[package]] name = "libc" @@ -2744,9 +2780,9 @@ checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libfuzzer-sys" -version = "0.4.12" +version = "0.4.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f12a681b7dd8ce12bff52488013ba614b869148d54dd79836ab85aafdd53f08d" +checksum = "a9fd2f41a1cba099f79a0b6b6c35656cf7c03351a7bae8ff0f28f25270f929d2" dependencies = [ "arbitrary", "cc", @@ -2760,14 +2796,14 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "c943259e342f1e06ff2da7a83eabdfe7f92ce10262688dbf1895ff0b3e6e4652" dependencies = [ "bitflags", "libc", "plain", - "redox_syscall 0.7.4", + "redox_syscall 0.9.0", ] [[package]] @@ -2780,6 +2816,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + [[package]] name = "litemap" version = "0.8.2" @@ -2814,51 +2856,13 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" -dependencies = [ - "serde_core", -] +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" [[package]] -name = "log-mdc" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a94d21414c1f4a51209ad204c1776a3d0765002c76c6abcb602a6f09f1e881c7" - -[[package]] -name = "log4rs" -version = "1.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e947bb896e702c711fccc2bf02ab2abb6072910693818d1d6b07ee2b9dfd86c" -dependencies = [ - "anyhow", - "arc-swap", - "chrono", - "derive_more", - "fnv", - "humantime", - "libc", - "log", - "log-mdc", - "mock_instant", - "parking_lot", - "rand 0.9.4", - "serde", - "serde-value", - "serde_json", - "serde_yaml", - "thiserror 2.0.18", - "thread-id", - "typemap-ors", - "unicode-segmentation", - "winapi", -] - -[[package]] -name = "loop9" -version = "0.1.5" +name = "loop9" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0fae87c125b03c1d2c0150c90365d7d6bcc53fb73a9acaef207d2d065860f062" dependencies = [ @@ -2882,12 +2886,37 @@ checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" [[package]] name = "lzma-rust2" -version = "0.15.7" +version = "0.16.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1670343e58806300d87950e3401e820b519b9384281bbabfb15e3636689ffd69" +checksum = "ca93e534d1142d1d0dcca6d25fe302508a5dfb40b302802904577725ea0b695b" dependencies = [ - "crc", - "sha2 0.10.9", + "sha2 0.11.0", +] + +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + +[[package]] +name = "markup5ever" +version = "0.39.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de" +dependencies = [ + "log", + "tendril", + "web_atoms", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", ] [[package]] @@ -2922,9 +2951,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "mime" @@ -2954,9 +2983,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "log", @@ -2964,12 +2993,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "mock_instant" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dce6dd36094cac388f119d2e9dc82dc730ef91c32a6222170d630e5414b956e6" - [[package]] name = "moka" version = "0.12.15" @@ -3000,6 +3023,12 @@ dependencies = [ "pxfm", ] +[[package]] +name = "mutually_exclusive_features" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e94e1e6445d314f972ff7395df2de295fe51b71821694f0b0e1e79c4f12c8577" + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -3008,9 +3037,9 @@ checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" [[package]] name = "no_std_io2" -version = "0.9.3" +version = "0.9.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b51ed7824b6e07d354605f4abb3d9d300350701299da96642ee084f5ce631550" +checksum = "418abd1b6d34fbf6cae440dc874771b0525a604428704c76e48b29a5e67b8003" dependencies = [ "memchr", ] @@ -3030,11 +3059,20 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -3056,11 +3094,21 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "bytemuck", + "num-traits", +] + [[package]] name = "num-conv" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-derive" @@ -3084,11 +3132,10 @@ dependencies = [ [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -3132,33 +3179,6 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" -[[package]] -name = "ordered-float" -version = "2.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68f19d67e5a2795c94e73e0bb1cc1a7edeb2e28efd39e2e1c9b7a40c1108b11c" -dependencies = [ - "num-traits", -] - -[[package]] -name = "outref" -version = "0.5.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" - -[[package]] -name = "p256" -version = "0.13.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b" -dependencies = [ - "ecdsa", - "elliptic-curve", - "primeorder", - "sha2 0.10.9", -] - [[package]] name = "parking" version = "2.2.1" @@ -3202,12 +3222,12 @@ checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" [[package]] name = "pbkdf2" -version = "0.12.2" +version = "0.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +checksum = "112d82ceb8c5bf524d9af484d4e4970c9fd5a0cc15ba14ad93dccd28873b0629" dependencies = [ - "digest 0.10.7", - "hmac 0.12.1", + "digest 0.11.3", + "hmac 0.13.0", ] [[package]] @@ -3225,6 +3245,65 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_shared", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator", + "phf_shared", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "pin-project-lite" version = "0.2.17" @@ -3320,41 +3399,28 @@ dependencies = [ ] [[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn", -] - -[[package]] -name = "primeorder" -version = "0.13.6" +name = "precomputed-hash" +version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6" -dependencies = [ - "elliptic-curve", -] +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" [[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" +name = "proc-macro-error-attr3" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" +checksum = "34e4dd828515431dd6c4a030d26f7eaed7dd4778226e9d2bb968d65ca4ec3d4d" dependencies = [ "proc-macro2", "quote", ] [[package]] -name = "proc-macro-error2" -version = "2.0.1" +name = "proc-macro-error3" +version = "3.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" +checksum = "5ee475e440453418ff1335189eddf7101ba502cd818ab7ae04209bc83aa925aa" dependencies = [ - "proc-macro-error-attr2", + "proc-macro-error-attr3", "proc-macro2", "quote", "syn", @@ -3371,28 +3437,51 @@ dependencies = [ [[package]] name = "profiling" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3eb8486b569e12e2c32ad3e204dbaba5e4b5b216e9367044f25f1dba42341773" +checksum = "3d595e54a326bc53c1c197b32d295e14b169e3cfeaa8dc82b529f947fba6bcf5" dependencies = [ "profiling-procmacros", ] [[package]] name = "profiling-procmacros" -version = "1.0.17" +version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" +checksum = "4488a4a36b9a4ba6b9334a32a39971f77c1436ec82c38707bce707699cc3bbcb" dependencies = [ "quote", "syn", ] +[[package]] +name = "pulp" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "046aa45b989642ec2e4717c8e72d677b13edd831a4d3b6cf37d9a3e54912496a" +dependencies = [ + "bytemuck", + "cfg-if", + "libm", + "num-complex", + "paste", + "pulp-wasm-simd-flag", + "raw-cpuid", + "reborrow", + "version_check", +] + +[[package]] +name = "pulp-wasm-simd-flag" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d8f70e07b9c3962945a74e59ca1c511bba65b6419468acc217c457d93f3c740" + [[package]] name = "pxfm" -version = "0.1.29" +version = "0.1.30" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0c5ccf5294c6ccd63a74f1565028353830a9c2f5eb0c682c355c471726a6e3f" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" [[package]] name = "qoi" @@ -3411,9 +3500,9 @@ checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" [[package]] name = "quinn" -version = "0.11.9" +version = "0.11.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" dependencies = [ "bytes", "cfg_aliases", @@ -3421,9 +3510,9 @@ dependencies = [ "quinn-proto", "quinn-udp", "rustc-hash", - "rustls 0.23.39", - "socket2 0.6.3", - "thiserror 2.0.18", + "rustls", + "socket2 0.6.4", + "thiserror", "tokio", "tracing", "web-time", @@ -3431,21 +3520,22 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.14" +version = "0.11.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.3", "lru-slab", - "rand 0.9.4", + "rand 0.10.2", + "rand_pcg", "ring", "rustc-hash", "rustls 0.23.39", "rustls-pki-types", "slab", - "thiserror 2.0.18", + "thiserror", "tinyvec", "tracing", "web-time", @@ -3453,23 +3543,23 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.14" +version = "0.5.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.3", + "socket2 0.6.4", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368" dependencies = [ "proc-macro2", ] @@ -3509,12 +3599,12 @@ dependencies = [ [[package]] name = "rand" -version = "0.10.1" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" dependencies = [ "chacha20", - "getrandom 0.4.2", + "getrandom 0.4.3", "rand_core 0.10.1", ] @@ -3562,6 +3652,15 @@ version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "rav1e" version = "0.8.1" @@ -3592,7 +3691,7 @@ dependencies = [ "rand 0.9.4", "rand_chacha 0.9.0", "simd_helpers", - "thiserror 2.0.18", + "thiserror", "v_frame", "wasm-bindgen", ] @@ -3612,6 +3711,15 @@ dependencies = [ "rgb", ] +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags", +] + [[package]] name = "rayon" version = "1.12.0" @@ -3632,6 +3740,12 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "reborrow" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03251193000f4bd3b042892be858ee50e8b3719f2b08e5833ac4353724632430" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -3643,18 +3757,18 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.7.4" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" +checksum = "c5102a6aaa05aa011a238e178e6bca86d2cb56fc9f586d37cb80f5bca6e07759" dependencies = [ "bitflags", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "2a0e75113e14dc5acb068cd0786884f214f1312650a3d36d269f5c4f3cdee8a2" dependencies = [ "aho-corasick", "memchr", @@ -3664,9 +3778,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "1f388202e4b80542a0921078cc23b6333bcf1409c1e3f86404cae4766a6131db" dependencies = [ "aho-corasick", "memchr", @@ -3681,21 +3795,21 @@ checksum = "cab834c73d247e67f4fae452806d17d3c7501756d98c8808d7c9c7aa7d18f973" [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64", "bytes", "futures-core", - "http 1.4.0", - "http-body 1.0.1", + "http 1.4.2", + "http-body", "http-body-util", "hyper 1.9.0", "hyper-rustls 0.27.9", @@ -3775,9 +3889,9 @@ dependencies = [ [[package]] name = "rust-embed" -version = "8.11.0" +version = "8.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04113cb9355a377d83f06ef1f0a45b8ab8cd7d8b1288160717d66df5c7988d27" +checksum = "e9e7760e252aaba7b09f4be00e36476cf585bdb68a53552ac954cdf504ab4bc9" dependencies = [ "rust-embed-impl", "rust-embed-utils", @@ -3786,10 +3900,11 @@ dependencies = [ [[package]] name = "rust-embed-impl" -version = "8.11.0" +version = "8.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da0902e4c7c8e997159ab384e6d0fc91c221375f6894346ae107f47dd0f3ccaa" +checksum = "3bcfc4d6f53af43755f7a723e4b6b8794fcce052a178dd8c6c1dadc5f5343097" dependencies = [ + "mime_guess", "proc-macro2", "quote", "rust-embed-utils", @@ -3799,19 +3914,19 @@ dependencies = [ [[package]] name = "rust-embed-utils" -version = "8.11.0" +version = "8.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5bcdef0be6fe7f6fa333b1073c949729274b05f123a0ad7efcb8efd878e5c3b1" +checksum = "42ffa149f6aa81b58a5b3011d01a857c4ed12c7a732d2c51947a4c7c692185f0" dependencies = [ - "sha2 0.10.9", + "sha2 0.11.0", "walkdir", ] [[package]] name = "rustc-hash" -version = "2.1.2" +version = "2.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" [[package]] name = "rustc_version" @@ -3823,22 +3938,23 @@ dependencies = [ ] [[package]] -name = "rustls" -version = "0.21.12" +name = "rustix" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f56a14d1f48b391359b22f731fd4bd7e43c97f3c50eee276f3aa09c94784d3e" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "log", - "ring", - "rustls-webpki 0.101.7", - "sct", + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.39" +version = "0.23.41" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c2c118cb077cca2822033836dfb1b975355dfb784b5e8da48f7b6c5db74e60e" +checksum = "6b92b125634d9b795e7beca796cc790df15a7fb38323bf3196fda83292d06b1f" dependencies = [ "aws-lc-rs", "once_cell", @@ -3850,9 +3966,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -3862,9 +3978,9 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "764899a24af3980067ee14bc143654f297b22eaebfe3c7b6b211920a5a59b046" dependencies = [ "web-time", "zeroize", @@ -3872,9 +3988,9 @@ dependencies = [ [[package]] name = "rustls-platform-verifier" -version = "0.6.2" +version = "0.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d99feebc72bae7ab76ba994bb5e121b8d83d910ca40b36e0921f53becc41784" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" dependencies = [ "core-foundation", "core-foundation-sys", @@ -3921,9 +4037,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -4018,16 +4134,6 @@ dependencies = [ "serde_derive", ] -[[package]] -name = "serde-value" -version = "0.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f3a1a3341211875ef120e117ea7fd5228530ae7e7036a779fdc9117be6b3282c" -dependencies = [ - "ordered-float", - "serde", -] - [[package]] name = "serde_core" version = "1.0.228" @@ -4050,9 +4156,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.149" +version = "1.0.150" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" dependencies = [ "itoa", "memchr", @@ -4062,35 +4168,31 @@ dependencies = [ ] [[package]] -name = "serde_urlencoded" -version = "0.7.1" +name = "serde_plain" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +checksum = "9ce1fc6db65a611022b23a0dec6975d63fb80a302cb3388835ff02c097258d50" dependencies = [ - "form_urlencoded", - "itoa", - "ryu", "serde", ] [[package]] -name = "serde_yaml" -version = "0.9.34+deprecated" +name = "serde_urlencoded" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" dependencies = [ - "indexmap", + "form_urlencoded", "itoa", "ryu", "serde", - "unsafe-libyaml", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -4105,7 +4207,7 @@ checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -4127,7 +4229,7 @@ checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -4143,11 +4245,20 @@ dependencies = [ "tokio", ] +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -4175,6 +4286,16 @@ version = "0.3.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +[[package]] +name = "simd_cesu8" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94f90157bb87cddf702797c5dadfa0be7d266cdf49e22da2fcaa32eff75b2c33" +dependencies = [ + "rustc_version", + "simdutf8", +] + [[package]] name = "simd_helpers" version = "0.1.0" @@ -4185,16 +4306,28 @@ dependencies = [ ] [[package]] -name = "slab" -version = "0.4.12" +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" [[package]] name = "smallvec" -version = "1.15.1" +version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" dependencies = [ "serde", ] @@ -4211,9 +4344,9 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -4286,7 +4419,7 @@ dependencies = [ "serde_json", "sha2 0.10.9", "smallvec", - "thiserror 2.0.18", + "thiserror", "tokio", "tokio-stream", "tracing", @@ -4365,12 +4498,12 @@ dependencies = [ "rand 0.8.6", "rsa", "serde", - "sha1 0.10.6", + "sha1 0.10.7", "sha2 0.10.9", "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror", "tracing", "uuid", "whoami", @@ -4410,7 +4543,7 @@ dependencies = [ "smallvec", "sqlx-core", "stringprep", - "thiserror 2.0.18", + "thiserror", "tracing", "uuid", "whoami", @@ -4436,7 +4569,7 @@ dependencies = [ "serde", "serde_urlencoded", "sqlx-core", - "thiserror 2.0.18", + "thiserror", "tracing", "url", "uuid", @@ -4448,6 +4581,30 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator", + "phf_shared", + "proc-macro2", + "quote", +] + [[package]] name = "stringprep" version = "0.1.5" @@ -4473,9 +4630,9 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "syn" -version = "2.0.117" +version = "2.0.118" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" dependencies = [ "proc-macro2", "quote", @@ -4509,32 +4666,34 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417" [[package]] -name = "thiserror" -version = "1.0.69" +name = "tempfile" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ - "thiserror-impl 1.0.69", + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys 0.61.2", ] [[package]] -name = "thiserror" -version = "2.0.18" +name = "tendril" +version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08" dependencies = [ - "thiserror-impl 2.0.18", + "new_debug_unreachable", ] [[package]] -name = "thiserror-impl" -version = "1.0.69" +name = "thiserror" +version = "2.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" dependencies = [ - "proc-macro2", - "quote", - "syn", + "thiserror-impl", ] [[package]] @@ -4549,13 +4708,12 @@ dependencies = [ ] [[package]] -name = "thread-id" -version = "5.1.0" +name = "thread_local" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2010d27add3f3240c1fef7959f46c814487b216baee662af53be645ba7831c07" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ - "libc", - "windows-sys 0.61.2", + "cfg-if", ] [[package]] @@ -4574,12 +4732,11 @@ dependencies = [ [[package]] name = "time" -version = "0.3.47" +version = "0.3.53" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "18dfaaeddcb932337b5e7866ee7d0ce9b76d2fd092997146f187ec09b4558a50" dependencies = [ "deranged", - "itoa", "js-sys", "num-conv", "powerfmt", @@ -4590,15 +4747,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.31" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "c431b87111666e491a90baa837f914fb45cd5dc3c268591b0220ff5057f2085f" dependencies = [ "num-conv", "time-core", @@ -4616,9 +4773,9 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" dependencies = [ "tinyvec_macros", ] @@ -4631,9 +4788,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.52.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -4641,7 +4798,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.3", + "socket2 0.6.4", "tokio-macros", "windows-sys 0.61.2", ] @@ -4718,25 +4875,25 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", "bitflags", "bytes", "futures-core", "futures-util", - "http 1.4.0", - "http-body 1.0.1", + "http 1.4.2", + "http-body", "http-body-util", - "iri-string", "pin-project-lite", "tokio", "tokio-util", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -4763,6 +4920,19 @@ dependencies = [ "tracing-core", ] +[[package]] +name = "tracing-actix-web" +version = "0.7.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "36bb7a33ce7f0807d44124b5119ea3581fd59028db56477a7aa01741c869ca6a" +dependencies = [ + "actix-web", + "mutually_exclusive_features", + "pin-project", + "tracing", + "uuid", +] + [[package]] name = "tracing-attributes" version = "0.1.31" @@ -4781,6 +4951,49 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" dependencies = [ "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-serde" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704b1aeb7be0d0a84fc9828cae51dab5970fee5088f83d1dd7ee6f6246fc6ff1" +dependencies = [ + "serde", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "serde", + "serde_json", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", + "tracing-serde", ] [[package]] @@ -4795,20 +5008,11 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" -[[package]] -name = "typemap-ors" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68c24b707f02dd18f1e4ccceb9d49f2058c2fb86384ef9972592904d7a28867" -dependencies = [ - "unsafe-any-ors", -] - [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "unicase" @@ -4845,9 +5049,9 @@ checksum = "7df058c713841ad818f1dc5d3fd88063241cc61f49f5fbea4b951e8cf5a8d71d" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-xid" @@ -4855,21 +5059,6 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" -[[package]] -name = "unsafe-any-ors" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e0a303d30665362d9680d7d91d78b23f5f899504d4f08b3c4cf08d055d87c0ad" -dependencies = [ - "destructure_traitobject", -] - -[[package]] -name = "unsafe-libyaml" -version = "0.2.11" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" - [[package]] name = "untrusted" version = "0.9.0" @@ -4908,9 +5097,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "utoipa" -version = "5.4.0" +version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2fcc29c80c21c31608227e0912b2d7fddba57ad76b606890627ba8ee7964e993" +checksum = "8bde15df68e80b16c7d16b9616e80770ad158988daa56a27dccd1e55558b0160" dependencies = [ "indexmap", "serde", @@ -4920,9 +5109,9 @@ dependencies = [ [[package]] name = "utoipa-gen" -version = "5.4.0" +version = "5.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d79d08d92ab8af4c5e8a6da20c47ae3f61a0f1dabc1997cdf2d082b757ca08b" +checksum = "6ba0b99ee52df3028635d93840c797102da61f8a7bb3cf751032455895b52ef8" dependencies = [ "proc-macro2", "quote", @@ -4951,13 +5140,13 @@ dependencies = [ [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", - "rand 0.10.1", + "rand 0.10.2", "serde_core", "wasm-bindgen", ] @@ -4973,6 +5162,12 @@ dependencies = [ "wasm-bindgen", ] +[[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.20.0" @@ -4991,18 +5186,23 @@ dependencies = [ [[package]] name = "validator_derive" -version = "0.20.0" +version = "0.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7df16e474ef958526d1205f6dda359fdfab79d9aa6d54bafcb92dcd07673dca" +checksum = "240e4b81c20a1d6d50d1d7265c658dfbd204e8b9ac4d80f3c931f39462196335" dependencies = [ "darling", - "once_cell", - "proc-macro-error2", + "proc-macro-error3", "proc-macro2", "quote", "syn", ] +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + [[package]] name = "vcpkg" version = "0.2.15" @@ -5048,20 +5248,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] @@ -5072,9 +5263,9 @@ checksum = "b8dad83b4f25e74f184f64c43b150b91efe7647395b42289f38e50566d82855b" [[package]] name = "wasm-bindgen" -version = "0.2.118" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" dependencies = [ "cfg-if", "once_cell", @@ -5085,9 +5276,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.76" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" dependencies = [ "js-sys", "wasm-bindgen", @@ -5095,9 +5286,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5105,9 +5296,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" dependencies = [ "bumpalo", "proc-macro2", @@ -5118,52 +5309,18 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.126" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" dependencies = [ "unicode-ident", ] -[[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" -dependencies = [ - "bitflags", - "hashbrown 0.15.5", - "indexmap", - "semver", -] - [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.103" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" dependencies = [ "js-sys", "wasm-bindgen", @@ -5179,11 +5336,23 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web_atoms" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "075474b12bcb3d2e3d4546580e9de478eeeead668a1761e2a8860c836b7ef297" +dependencies = [ + "phf", + "phf_codegen", + "string_cache", + "string_cache_codegen", +] + [[package]] name = "webpki-root-certs" -version = "1.0.7" +version = "1.0.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" +checksum = "0d46a5a140e6f7afeccd8eae97eff335163939eac8b929834875168b29b3d267" dependencies = [ "rustls-pki-types", ] @@ -5204,22 +5373,6 @@ dependencies = [ "wasite", ] -[[package]] -name = "winapi" -version = "0.3.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" -dependencies = [ - "winapi-i686-pc-windows-gnu", - "winapi-x86_64-pc-windows-gnu", -] - -[[package]] -name = "winapi-i686-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" - [[package]] name = "winapi-util" version = "0.1.11" @@ -5229,12 +5382,6 @@ dependencies = [ "windows-sys 0.61.2", ] -[[package]] -name = "winapi-x86_64-pc-windows-gnu" -version = "0.4.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" - [[package]] name = "windows-core" version = "0.62.2" @@ -5294,15 +5441,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-sys" -version = "0.45.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" -dependencies = [ - "windows-targets 0.42.2", -] - [[package]] name = "windows-sys" version = "0.48.0" @@ -5321,15 +5459,6 @@ dependencies = [ "windows-targets 0.52.6", ] -[[package]] -name = "windows-sys" -version = "0.60.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" -dependencies = [ - "windows-targets 0.53.5", -] - [[package]] name = "windows-sys" version = "0.61.2" @@ -5339,21 +5468,6 @@ dependencies = [ "windows-link", ] -[[package]] -name = "windows-targets" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" -dependencies = [ - "windows_aarch64_gnullvm 0.42.2", - "windows_aarch64_msvc 0.42.2", - "windows_i686_gnu 0.42.2", - "windows_i686_msvc 0.42.2", - "windows_x86_64_gnu 0.42.2", - "windows_x86_64_gnullvm 0.42.2", - "windows_x86_64_msvc 0.42.2", -] - [[package]] name = "windows-targets" version = "0.48.5" @@ -5378,36 +5492,13 @@ dependencies = [ "windows_aarch64_gnullvm 0.52.6", "windows_aarch64_msvc 0.52.6", "windows_i686_gnu 0.52.6", - "windows_i686_gnullvm 0.52.6", + "windows_i686_gnullvm", "windows_i686_msvc 0.52.6", "windows_x86_64_gnu 0.52.6", "windows_x86_64_gnullvm 0.52.6", "windows_x86_64_msvc 0.52.6", ] -[[package]] -name = "windows-targets" -version = "0.53.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" -dependencies = [ - "windows-link", - "windows_aarch64_gnullvm 0.53.1", - "windows_aarch64_msvc 0.53.1", - "windows_i686_gnu 0.53.1", - "windows_i686_gnullvm 0.53.1", - "windows_i686_msvc 0.53.1", - "windows_x86_64_gnu 0.53.1", - "windows_x86_64_gnullvm 0.53.1", - "windows_x86_64_msvc 0.53.1", -] - -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" - [[package]] name = "windows_aarch64_gnullvm" version = "0.48.5" @@ -5420,18 +5511,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" -[[package]] -name = "windows_aarch64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" - -[[package]] -name = "windows_aarch64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" - [[package]] name = "windows_aarch64_msvc" version = "0.48.5" @@ -5444,18 +5523,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" -[[package]] -name = "windows_aarch64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" - -[[package]] -name = "windows_i686_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" - [[package]] name = "windows_i686_gnu" version = "0.48.5" @@ -5468,30 +5535,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" -[[package]] -name = "windows_i686_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" - [[package]] name = "windows_i686_gnullvm" version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" -[[package]] -name = "windows_i686_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" - -[[package]] -name = "windows_i686_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" - [[package]] name = "windows_i686_msvc" version = "0.48.5" @@ -5504,18 +5553,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" -[[package]] -name = "windows_i686_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" - -[[package]] -name = "windows_x86_64_gnu" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" - [[package]] name = "windows_x86_64_gnu" version = "0.48.5" @@ -5528,18 +5565,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" -[[package]] -name = "windows_x86_64_gnu" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" - -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" - [[package]] name = "windows_x86_64_gnullvm" version = "0.48.5" @@ -5552,18 +5577,6 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" -[[package]] -name = "windows_x86_64_gnullvm" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" - -[[package]] -name = "windows_x86_64_msvc" -version = "0.42.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" - [[package]] name = "windows_x86_64_msvc" version = "0.48.5" @@ -5576,106 +5589,12 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" -[[package]] -name = "windows_x86_64_msvc" -version = "0.53.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" - -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap", - "prettyplease", - "syn", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags", - "indexmap", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" version = "0.6.3" @@ -5696,9 +5615,9 @@ checksum = "7a5a4b21e1a62b67a2970e6831bc091d7b87e119e7f9791aef9702e3bef04448" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -5719,18 +5638,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "b7cbbc0a705a0fd05cc3676525980d2bf5a9bc4adac6d6475209a7887cf59d19" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.54" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "e2e817b7b52d0c7358d3246da9d69935ebb18116b2b102b4230dac079b4862f5" dependencies = [ "proc-macro2", "quote", @@ -5739,9 +5658,9 @@ dependencies = [ [[package]] name = "zerofrom" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" dependencies = [ "zerofrom-derive", ] @@ -5760,23 +5679,9 @@ dependencies = [ [[package]] name = "zeroize" -version = "1.8.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" -dependencies = [ - "zeroize_derive", -] - -[[package]] -name = "zeroize_derive" -version = "1.4.3" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" [[package]] name = "zerotrie" @@ -5827,9 +5732,9 @@ dependencies = [ [[package]] name = "zip" -version = "7.2.0" +version = "8.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c42e33efc22a0650c311c2ef19115ce232583abbe80850bc8b66509ebef02de0" +checksum = "2d04a6b5381502aa6087c94c669499eb1602eb9c5e8198e534de571f7154809b" dependencies = [ "aes", "bzip2", @@ -5837,15 +5742,14 @@ dependencies = [ "crc32fast", "deflate64", "flate2", - "generic-array", - "getrandom 0.3.4", - "hmac 0.12.1", + "getrandom 0.4.3", + "hmac 0.13.0", "indexmap", "lzma-rust2", "memchr", "pbkdf2", "ppmd-rust", - "sha1 0.10.6", + "sha1 0.11.0", "time", "typed-path", "zeroize", @@ -5855,9 +5759,9 @@ dependencies = [ [[package]] name = "zlib-rs" -version = "0.6.3" +version = "0.6.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" +checksum = "b142a20ec14a91d5bc708c1dc21b080c550113d8aa77afa29635673a65dd02c5" [[package]] name = "zmij" diff --git a/Cargo.toml b/Cargo.toml index 005b2ff0..ea9d971e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,17 +1,33 @@ [package] name = "geode-index" -version = "0.53.2" +version = "0.55.1" edition = "2024" +[features] +dev-tools = ["dep:actix-files"] + [dependencies] -image = { version = "0.25", features = ["png"] } +actix-cors = "0.7" +actix-files = { version = "0.6", optional = true } +actix-multipart = "0.8" actix-web = "4.10" +ammonia = "4" anyhow = "1.0" +bytes = "1.11" +chrono = { version = "0.4", features = ["default", "serde"] } +clap = { version = "4.5", features = ["derive"] } dotenvy = "0.15" -log = "0.4" futures = "0.3" +image = { version = "0.25", features = ["png", "jpeg", "webp"] } +# Required for zip crate to compile properly +lzma-rust2 = "0.16.5" +moka = { version = "0.12.13", features = ["future"] } +regex = "1.10" +reqwest = { version = "0.13", default-features = false, features = ["json", "gzip", "rustls", "query"] } +semver = "1.0" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" +sha256 = "1.5" sqlx = { version = "0.8", features = [ "postgres", "runtime-tokio", @@ -19,31 +35,16 @@ sqlx = { version = "0.8", features = [ "uuid", "ipnetwork", ] } -tokio = { version = "1.44", features = ["rt", "macros", "rt-multi-thread"] } -reqwest = { version = "0.13", default-features = false, features = ["json", "gzip", "rustls", "query"] } -uuid = { version = "1.8", features = ["v4", "fast-rng", "macro-diagnostics"] } -zip = { version = "7.2.0" } -# Required for zip crate to compile properly -lzma-rust2 = "0.15.7" -sha256 = "1.5" -semver = "1.0" -clap = { version = "4.5", features = ["derive"] } -regex = "1.10" -chrono = { version = "0.4", features = ["default", "serde"] } -actix-cors = "0.7" -log4rs = { version = "1.3.0", features = [ - "console_appender", - "rolling_file_appender", - "fixed_window_roller", - "time_trigger", - "default", - "threshold_filter", -] } thiserror = "2.0.12" -moka = { version = "0.12.13", features = ["future"] } +tokio = { version = "1.44", features = ["rt", "macros", "rt-multi-thread"] } +tracing = "0.1" +tracing-actix-web = "0.7" +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] } +urlencoding = "2.1.3" utoipa = { version = "5.4.0", features = ["actix_extras", "chrono", "uuid"] } utoipa-swagger-ui = { version = "9.0.2", features = ["actix-web"] } -urlencoding = "2.1.3" +uuid = { version = "1.8", features = ["v4", "fast-rng", "macro-diagnostics"] } validator = { version = "0.20.0", features = ["derive"] } +zip = "8.6.0" aws-config = { version = "1.1.7", features = ["behavior-version-latest"] } aws-sdk-s3 = "1.137.0" diff --git a/Dockerfile b/Dockerfile index fe667f54..3013b2e3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,32 +3,32 @@ ARG LIBC=musl # 1. shared toolchain (Rust + Zig + cargo-chef + cargo-zigbuild) -FROM --platform=$BUILDPLATFORM debian:trixie-slim AS builder-tools +# rust:1.97.0-slim-trixie +FROM --platform=$BUILDPLATFORM rust@sha256:7284e7501ed1b80ae3d2f826024e8384749bb860c46d7989d3b70033b56bf31e AS builder-tools ARG ZIG_VERSION=0.15.2 -ARG CARGO_ZIGBUILD_VERSION=0.22.1 +ARG ZIG_SHA256=02aa270f183da276e5b5920b1dac44a63f1a49e55050ebde3aecc9eb82f93239 +ARG CARGO_ZIGBUILD_VERSION=0.23.0 ARG CARGO_CHEF_VERSION=0.1.77 -ENV CARGO_HOME=/cargo \ - RUSTUP_HOME=/rustup \ - PATH=/cargo/bin:/zig:$PATH +ENV PATH=/zig:$PATH RUN apt-get update && apt-get install -y --no-install-recommends \ pkg-config ca-certificates curl xz-utils build-essential \ && rm -rf /var/lib/apt/lists/* -# Install Rust (minimal profile, stable toolchain) -RUN curl https://sh.rustup.rs -sSf | sh -s -- -y --profile minimal --default-toolchain stable \ - && rustup target add \ +RUN rustup target add \ x86_64-unknown-linux-musl \ aarch64-unknown-linux-musl \ x86_64-unknown-linux-gnu \ aarch64-unknown-linux-gnu # Install Zig (used by cargo-zigbuild as the cross-linker) -RUN curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/zig-x86_64-linux-${ZIG_VERSION}.tar.xz" \ - | tar -xJ \ - && mv "zig-x86_64-linux-${ZIG_VERSION}" /zig +RUN curl -fsSL "https://ziglang.org/download/${ZIG_VERSION}/zig-x86_64-linux-${ZIG_VERSION}.tar.xz" -o /tmp/zig.tar.xz \ + && echo "${ZIG_SHA256} /tmp/zig.tar.xz" | sha256sum -c - \ + && tar -xJf /tmp/zig.tar.xz \ + && mv "zig-x86_64-linux-${ZIG_VERSION}" /zig \ + && rm /tmp/zig.tar.xz # Install cargo-chef and cargo-zigbuild RUN cargo install --locked cargo-chef --version ${CARGO_CHEF_VERSION} \ @@ -88,33 +88,54 @@ RUN apk add --no-cache ca-certificates tzdata WORKDIR /app COPY --from=builder /app/geode-index ./geode-index COPY migrations ./migrations -COPY config ./config +COPY static ./static +COPY docker/sync-static.sh /usr/local/bin/sync-static.sh -RUN addgroup -S geode && adduser -S geode -G geode \ +RUN addgroup -S -g 1000 geode && adduser -S -u 1000 geode -G geode \ + && chmod +x /usr/local/bin/sync-static.sh \ && chown -R geode:geode /app USER geode EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q --spider "http://127.0.0.1:${PORT:-3000}/" || exit 1 ENTRYPOINT ["./geode-index"] # 4b. Debian slim runtime (glibc / dynamically linked) FROM debian:trixie-slim AS runtime-gnu RUN apt-get update && apt-get install -y --no-install-recommends \ - ca-certificates tzdata \ + ca-certificates tzdata wget \ && rm -rf /var/lib/apt/lists/* WORKDIR /app COPY --from=builder /app/geode-index ./geode-index COPY migrations ./migrations -COPY config ./config +COPY static ./static +COPY docker/sync-static.sh /usr/local/bin/sync-static.sh -RUN groupadd --system geode && useradd --system --gid geode geode \ +RUN groupadd --system --gid 1000 geode && useradd --system --uid 1000 --gid geode geode \ + && chmod +x /usr/local/bin/sync-static.sh \ && chown -R geode:geode /app USER geode EXPOSE 3000 +HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \ + CMD wget -q --spider "http://127.0.0.1:${PORT:-3000}/" || exit 1 ENTRYPOINT ["./geode-index"] FROM runtime-${LIBC:-musl} AS runtime ARG LIBC + +# Overriden in CI +ARG VERSION=dev +ARG REVISION=unknown +ARG BUILD_DATE=unknown + +LABEL org.opencontainers.image.title="geode-index" \ + org.opencontainers.image.description="Geode SDK mod index server" \ + org.opencontainers.image.source="https://github.com/geode-sdk/server" \ + org.opencontainers.image.documentation="https://github.com/geode-sdk/server#environment-variables" \ + org.opencontainers.image.version="${VERSION}" \ + org.opencontainers.image.revision="${REVISION}" \ + org.opencontainers.image.created="${BUILD_DATE}" diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 00000000..127a5bc3 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,23 @@ +Boost Software License - Version 1.0 - August 17th, 2003 + +Permission is hereby granted, free of charge, to any person or organization +obtaining a copy of the software and accompanying documentation covered by +this license (the "Software") to use, reproduce, display, distribute, +execute, and transmit the Software, and to prepare derivative works of the +Software, and to permit third-parties to whom the Software is furnished to +do so, all subject to the following: + +The copyright notices in the Software and this entire statement, including +the above license grant, this restriction and the following disclaimer, +must be included in all copies of the Software, in whole or in part, and +all derivative works of the Software, unless such copies or derivative +works are solely in the form of machine-executable object code generated by +a source language processor. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE, TITLE AND NON-INFRINGEMENT. IN NO EVENT +SHALL THE COPYRIGHT HOLDERS OR ANYONE DISTRIBUTING THE SOFTWARE BE LIABLE +FOR ANY DAMAGES OR OTHER LIABILITY, WHETHER IN CONTRACT, TORT OR OTHERWISE, +ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER +DEALINGS IN THE SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md index 09be47a4..97b8e35e 100644 --- a/README.md +++ b/README.md @@ -2,21 +2,99 @@ Server for the Geode Index, the API used by Geode SDK to retrieve mod information. Based on actix-web and sqlx. -## Requirements for hosting +## API Documentation -- rust stable -- PostgreSQL 17 or later +The API documentation can be found [here](https://api.geode-sdk.org/swagger/). A machine readable openapi.json specification can be found [here](https://api.geode-sdk.org/swagger/openapi.json). -## Documentation +## Running the server -The API documentation can be found [here](https://api.geode-sdk.org/swagger/). A machine readable openapi.json specification can be found [here](https://api.geode-sdk.org/swagger/openapi.json). +Requires PostgreSQL 17 or later. Two ways to run the server: + +- **Docker** (recommended for hosting): see [Running with Docker](#running-with-docker) below. No Rust toolchain needed. +- **From source** (recommended for development): requires the Rust stable toolchain. See the [development environment setup](docs/dev_setup.md) to get started, or just run: + ```bash + cargo build # or cargo build --release + ``` + +## Running with Docker + +Images are published to GHCR with two variants per version, differing only in base OS/libc: + +- `-alpine`: musl, statically linked, smaller image (recommended default) +- `-trixie`: glibc, Debian-based + +Available as `latest-alpine`/`latest-trixie`, or pinned to a version, e.g. `0.50.0-alpine`/`0.50.0-trixie`. + +```bash +docker run -p 3000:3000 \ + -e DATABASE_URL=postgres://user:password@host/schema \ + -e PORT=3000 \ + ghcr.io/geode-sdk/server:latest-alpine +``` + +Database migrations run automatically on startup, no separate migration step needed. + +The image has a built-in `HEALTHCHECK` that polls `GET /` (expects `200`); the same endpoint works for an external load balancer/orchestrator health check if you're not relying on Docker's own. + +### Environment variables + +| Variable | Required | Default | Description | +| --- | --- | --- | --- | +| `DATABASE_URL` | yes | - | PostgreSQL connection string: `postgres://{username}:{password}@{host}/{database}` | +| `DATABASE_CONNECTIONS` | no | `10` | Max size of the Postgres connection pool | +| `PORT` | no | `8080` | Port the server listens on | +| `APP_DEBUG` | no | `0` | Set to `1` to run single-threaded, for step debugging | +| `APP_URL` | no | `http://localhost:3000` | Used for URL generation in downloads | +| `FRONT_URL` | no | `http://localhost:5173` | Used for link generation (mod status badges, GitHub auth redirects) | +| `GITHUB_CLIENT_ID` | no | - | GitHub OAuth app client ID, used for login | +| `GITHUB_CLIENT_SECRET` | no | - | GitHub OAuth app client secret | +| `DISCORD_WEBHOOK_URL` | no | - | Webhook URL for general index notifications | +| `INDEX_ADMIN_DISCORD_WEBHOOK_URL` | no | - | Webhook URL for admin-only notifications | +| `MAX_MOD_FILESIZE_MB` | no | `250` | Max accepted mod upload size, in MB | +| `DISABLE_DOWNLOAD_COUNTS` | no | `0` | Set to `1` to globally disable download counting (e.g. during abuse) | -## Configuration +### Persisting uploaded files -Check out the [development environment setup](docs/dev_setup.md) to get started! +The server writes uploaded files to `/app/storage` inside the container (public downloads, private files). This is **not** persisted by default, if you remove/recreate the container without a volume, uploads are lost. -## Building the server +Static assets (`/app/static`) are baked into the image itself and don't need persisting, they are re-synced to whatever serves them whenever the image updates, see the Compose example below. + +> [!IMPORTANT] +> The index does not serve these files itself in production. You are responsible for serving them with your own reverse proxy: `/app/static` must be served at `/static/`, and `/app/storage/public` at `/storage/`, both relative to `APP_URL`. + +The container runs as a fixed non-root user, UID `1000`, GID `1000`. + +#### Production example: app + nginx via Compose + +A common production setup is to run the app alongside an nginx container sharing the storage volume, with nginx serving `/static` and `/storage` directly and proxying everything else through. This also sidesteps file permission issues you'd otherwise hit trying to read the app's (UID `1000`-owned) storage volume from a host-level reverse proxy. + +See [`docs/examples/docker-compose.example.yml`](docs/examples/docker-compose.example.yml) and [`docs/examples/nginx.example.conf`](docs/examples/nginx.example.conf). Your host-level reverse proxy (if any) just forwards to nginx's published port. + +#### Other options + +**Named volume**: the directory ownership is baked into the image, so this works with no extra setup: ```bash -cargo build # or cargo build --release +docker volume create geode-storage + +docker run -p 3000:3000 \ + -e DATABASE_URL=postgres://user:password@host/schema \ + -e PORT=3000 \ + -v geode-storage:/app/storage \ + ghcr.io/geode-sdk/server:latest-alpine ``` + +**Bind mount**: the host directory must be owned by UID/GID `1000` yourself, since Docker doesn't touch bind-mounted host paths' permissions: + +```bash +mkdir -p ./geode-storage +chown -R 1000:1000 ./geode-storage + +docker run -p 3000:3000 \ + -e DATABASE_URL=postgres://user:password@host/schema \ + -e PORT=3000 \ + -v ./geode-storage:/app/storage \ + ghcr.io/geode-sdk/server:latest-alpine +``` + +If your host already has a user/group with UID/GID `1000` (common for the first non-root user on Linux), you may already own the directory by default and can skip the `chown`. diff --git a/config/log4rs.example.yaml b/config/log4rs.example.yaml deleted file mode 100644 index 4cdfff1b..00000000 --- a/config/log4rs.example.yaml +++ /dev/null @@ -1,32 +0,0 @@ -appenders: - console: - kind: console - encoder: - pattern: "{d(%Y-%m-%d %H:%M:%S)} {M}:{L} {h({l})} {m}{n}" - daily: - kind: rolling_file - path: "log/index.log" - encoder: - pattern: "{d(%Y-%m-%d %H:%M:%S)} {M}:{L} {h({l})} {m}{n}" - policy: - trigger: - kind: time - interval: 1 day - roller: - kind: fixed_window - pattern: "log/index.{}.log" - base: 0 - count: 10 -root: - level: info - -loggers: - geode_index: - level: info - appenders: - - console - - daily - actix_server: - level: info - appenders: - - console \ No newline at end of file diff --git a/docker/sync-static.sh b/docker/sync-static.sh new file mode 100644 index 00000000..210de7f3 --- /dev/null +++ b/docker/sync-static.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -eu + +target="${1:-/shared-static}" + +rm -rf "${target:?}"/* "${target}"/.[!.]* 2>/dev/null || true +cp -r /app/static/. "$target" +chmod -R a+rX "$target" \ No newline at end of file diff --git a/docs/dev_setup.md b/docs/dev_setup.md index 9a50fb18..ddd54a22 100644 --- a/docs/dev_setup.md +++ b/docs/dev_setup.md @@ -69,11 +69,9 @@ Third, we need to setup a local GitHub OAuth app. Since the index doesn't store ## 4. Logging -Next up, set up the log4rs config file found in `config`: -```bash -cp config/log4rs.example.yaml config/log4rs.yaml -``` -Feel free to change the settings, but the default works fine. +Logging is configured via environment variables. See the `.env.example` file for the available options: +- `RUST_LOG`: Controls log verbosity (e.g., "info", "debug", "info,sqlx=warn", "geode_index=debug"). Defaults to "info,sqlx=warn,tracing_actix_web::middleware=error" when unset. +- `LOG_FORMAT`: Output format - "text" (default, human-readable) or "json" (structured, for log aggregation). After all of this is done, you should be able to run `cargo run` inside the index directory. The migrations will be ran automatically, and the index will start. You can check `http://localhost:8080` (if you haven't changed the port in your .env file) to see if it all works. diff --git a/docs/examples/docker-compose.example.yml b/docs/examples/docker-compose.example.yml new file mode 100644 index 00000000..1110e701 --- /dev/null +++ b/docs/examples/docker-compose.example.yml @@ -0,0 +1,79 @@ +# Example production setup: app + nginx + postgres sharing a storage volume. +# +# See nginx.example.conf for the corresponding nginx config, and the +# "Running with Docker" section of the README for environment variables. + +services: + # Re-syncs static assets from whichever image tag is currently pulled into + # the geode-static volume, so nginx always serves assets matching the + # deployed (or rolled-back) app version. Runs once per `docker compose up`. + static-init: + image: ghcr.io/geode-sdk/server:latest-alpine + entrypoint: ["/usr/local/bin/sync-static.sh", "/shared-static"] + user: "0:0" + volumes: + - geode-static:/shared-static + + app: + image: ghcr.io/geode-sdk/server:latest-alpine + restart: unless-stopped + depends_on: + postgres: + condition: service_healthy + environment: + DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres/${POSTGRES_DB} + PORT: "3000" + APP_URL: https://api.sapphire-sdk.org + FRONT_URL: https://sapphire-sdk.org + volumes: + - geode-storage-public:/app/storage/public + - geode-storage-private:/app/storage/private + networks: + - geode + + nginx: + image: nginx:1.27-alpine + restart: unless-stopped + depends_on: + app: + condition: service_started + static-init: + condition: service_completed_successfully + volumes: + - geode-static:/app/static:ro + - geode-storage-public:/app/storage/public:ro + - ./nginx.conf:/etc/nginx/conf.d/default.conf:ro + # include only if you want to make this available on the host machine + # (if your reverse proxy is on the host, not inside docker) + # ports: + # - "127.0.0.1:8080:80" + networks: + - geode + + postgres: + image: postgres:17-alpine + restart: unless-stopped + # you should probably not keep those variables inside this + # compose file, maybe define a .env file for it + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - geode-postgres:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 5s + timeout: 5s + retries: 5 + networks: + - geode + +volumes: + geode-postgres: + geode-static: + geode-storage-public: + geode-storage-private: + +networks: + geode: diff --git a/docs/examples/nginx.example.conf b/docs/examples/nginx.example.conf new file mode 100644 index 00000000..595080a7 --- /dev/null +++ b/docs/examples/nginx.example.conf @@ -0,0 +1,38 @@ +# Example nginx config for docker-compose.example.yml. Serves the app's +# storage directly (avoiding the app process itself), proxies everything +# else through to the app service. +# +# This config doesn't handle HTTPS, that should be done by your actual +# reverse proxy. +# +# Paths must match src/main.rs's actix_files mounts: +# /static -> static +# /storage -> storage/public + +server { + listen 80; + + location /static/ { + alias /app/static/; + } + + location /storage/ { + alias /app/storage/public/; + autoindex off; + + # content-addressed files safe to cache forever + # since the hash is the filename + location ~ "^/storage/(.*/[a-f0-9]{2}/[a-f0-9]{64}(?:\.[a-z0-9]+)?)/?$" { + alias /app/storage/public/$1; + add_header Cache-Control "public, max-age=31536000, immutable"; + } + } + + location / { + proxy_pass http://app:3000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} diff --git a/migrations/20260310175835_create_mod_version_submissions_table.down.sql b/migrations/20260310175835_create_mod_version_submissions_table.down.sql new file mode 100644 index 00000000..51dfa79c --- /dev/null +++ b/migrations/20260310175835_create_mod_version_submissions_table.down.sql @@ -0,0 +1,8 @@ +-- Add down migration script here + +DROP TABLE IF EXISTS mod_version_submission_comment_audit; +DROP TABLE IF EXISTS mod_version_submission_comments; +DROP TABLE IF EXISTS mod_version_submissions_audit; +DROP TABLE IF EXISTS mod_version_submissions; +DROP TYPE IF EXISTS audit_action; +DROP TYPE IF EXISTS submission_lock; \ No newline at end of file diff --git a/migrations/20260310175835_create_mod_version_submissions_table.up.sql b/migrations/20260310175835_create_mod_version_submissions_table.up.sql new file mode 100644 index 00000000..08bf499d --- /dev/null +++ b/migrations/20260310175835_create_mod_version_submissions_table.up.sql @@ -0,0 +1,62 @@ +-- Add up migration script here + +CREATE TYPE audit_action AS ENUM ( + 'created', + 'updated', + 'deleted', + 'restored' +); + +CREATE TYPE submission_lock AS ENUM ( + 'none', + 'internal', + 'locked' +); + +CREATE TABLE mod_version_submissions ( + mod_version_id INT NOT NULL PRIMARY KEY, + lock submission_lock NOT NULL DEFAULT 'none', + locked_by INT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + FOREIGN KEY (mod_version_id) REFERENCES mod_versions(id) ON DELETE CASCADE, + FOREIGN KEY (locked_by) REFERENCES developers(id) ON DELETE SET NULL +); + +CREATE TABLE mod_version_submissions_audit ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + submission_id INT NOT NULL, + action audit_action NOT NULL, + details TEXT, + performed_by INT, + performed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + FOREIGN KEY (submission_id) REFERENCES mod_version_submissions(mod_version_id) ON DELETE CASCADE, + FOREIGN KEY (performed_by) REFERENCES developers(id) ON DELETE SET NULL +); + +CREATE INDEX idx_submissions_audit_submission_id ON mod_version_submissions_audit(submission_id); + +CREATE TABLE mod_version_submission_comments ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + submission_id INT NOT NULL, + author_id INT NOT NULL, + comment TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ, + FOREIGN KEY (submission_id) REFERENCES mod_version_submissions(mod_version_id) ON DELETE CASCADE, + FOREIGN KEY (author_id) REFERENCES developers(id) ON DELETE RESTRICT +); + +CREATE INDEX idx_submission_comments_submission_id + ON mod_version_submission_comments(submission_id); + +CREATE TABLE mod_version_submission_comment_audit ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + comment_id BIGINT NOT NULL, + action audit_action NOT NULL, + details TEXT, + performed_by INT, + performed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + FOREIGN KEY (comment_id) REFERENCES mod_version_submission_comments(id) ON DELETE CASCADE, + FOREIGN KEY (performed_by) REFERENCES developers(id) ON DELETE SET NULL +); diff --git a/migrations/20260312205413_create_submission_comment_attachments.down.sql b/migrations/20260312205413_create_submission_comment_attachments.down.sql new file mode 100644 index 00000000..42513aa0 --- /dev/null +++ b/migrations/20260312205413_create_submission_comment_attachments.down.sql @@ -0,0 +1,2 @@ +-- Add down migration script here +DROP TABLE IF EXISTS mod_version_submission_comment_attachments; diff --git a/migrations/20260312205413_create_submission_comment_attachments.up.sql b/migrations/20260312205413_create_submission_comment_attachments.up.sql new file mode 100644 index 00000000..c7528ca4 --- /dev/null +++ b/migrations/20260312205413_create_submission_comment_attachments.up.sql @@ -0,0 +1,13 @@ +-- Add up migration script here +CREATE TABLE mod_version_submission_comment_attachments ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + comment_id BIGINT NOT NULL, + filename TEXT NOT NULL, -- SHA-256 hex of WebP content; file on disk: {STORAGE_PATH}/submission-attachments/{filename} + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + FOREIGN KEY (comment_id) REFERENCES mod_version_submission_comments (id) ON DELETE CASCADE +); + +CREATE INDEX idx_submission_attachments_comment_id ON mod_version_submission_comment_attachments (comment_id); + +-- For fast COUNT lookups on delete (deduplication check) +CREATE INDEX idx_submission_attachments_filename ON mod_version_submission_comment_attachments (filename); diff --git a/src/auth/github.rs b/src/auth/github.rs index e29f416f..33366f0e 100644 --- a/src/auth/github.rs +++ b/src/auth/github.rs @@ -1,10 +1,10 @@ use crate::auth::AuthenticationError; use crate::database::repository::github_login_attempts; use crate::types::models::github_login_attempt::StoredLoginAttempt; -use reqwest::{header::HeaderValue, Client}; +use reqwest::{Client, header::HeaderValue}; use serde::{Deserialize, Serialize}; use serde_json::json; -use sqlx::{types::ipnetwork::IpNetwork, PgConnection}; +use sqlx::{PgConnection, types::ipnetwork::IpNetwork}; use uuid::Uuid; #[derive(Debug, Deserialize, Serialize)] @@ -108,10 +108,12 @@ impl GithubClient { })) .send() .await - .inspect_err(|e| log::error!("Failed to start OAuth device flow with GitHub: {e}"))?; + .inspect_err(|e| { + tracing::error!("Failed to start OAuth device flow with GitHub: {e}") + })?; if !res.status().is_success() { - log::error!( + tracing::error!( "GitHub OAuth device flow failed to start. Error code: {}. Body: {}", res.status(), res.text().await.unwrap_or("No body received".into()) @@ -125,7 +127,7 @@ impl GithubClient { .json::() .await .inspect_err(|e| { - log::error!("Failed to parse OAuth device flow response from GitHub: {e}") + tracing::error!("Failed to parse OAuth device flow response from GitHub: {e}") }) .or(Err(AuthenticationError::InternalError( "Failed to parse response from GitHub".into(), @@ -183,18 +185,18 @@ impl GithubClient { .send() .await .inspect_err(|e| { - log::error!("Failed to poll GitHub for developer access token: {e}") + tracing::error!("Failed to poll GitHub for developer access token: {e}") })?; Ok(resp .json::() .await - .inspect_err(|e| log::error!("Failed to decode GitHub response: {e}"))? + .inspect_err(|e| tracing::error!("Failed to decode GitHub response: {e}"))? .get("access_token") .ok_or(AuthenticationError::UserAuthPending)? .as_str() .ok_or_else(|| { - log::error!("Invalid access_token received from GitHub"); + tracing::error!("Invalid access_token received from GitHub"); AuthenticationError::InternalError( "Failed to retrieve access token from GitHub".into(), ) @@ -211,7 +213,7 @@ impl GithubClient { .await?; if !resp.status().is_success() { - log::error!( + tracing::error!( "github::get_user: received non-2xx response: {}. Body: {}", resp.status(), resp.text().await.unwrap_or("No response body".into()) @@ -223,7 +225,7 @@ impl GithubClient { resp.json::() .await - .inspect_err(|e| log::error!("github::get_user: failed to parse response: {e}")) + .inspect_err(|e| tracing::error!("github::get_user: failed to parse response: {e}")) .or(Err(AuthenticationError::InternalError( "Failed to parse user JSON received from GitHub".into(), ))) @@ -242,11 +244,11 @@ impl GithubClient { .send() .await .inspect_err(|e| { - log::error!("github::get_installation: failed to fetch repositories: {e}") + tracing::error!("github::get_installation: failed to fetch repositories: {e}") })?; if !resp.status().is_success() { - log::error!( + tracing::error!( "github::get_installation: received non-2xx response: {}. Body: {}", resp.status(), resp.text().await.unwrap_or("No response body".into()) @@ -259,7 +261,9 @@ impl GithubClient { let body = resp .json::() .await - .inspect_err(|e| log::error!("github::get_installation: failed to parse response: {e}")) + .inspect_err(|e| { + tracing::error!("github::get_installation: failed to parse response: {e}") + }) .or(Err(AuthenticationError::InternalError( "Failed to parse response from GitHub".into(), )))?; @@ -285,7 +289,7 @@ impl GithubClient { serde_json::from_value(owner) .inspect_err(|e| { - log::error!( + tracing::error!( "github::get_installation: failed to extract owner from serde_json value: {e}" ) }) diff --git a/src/config.rs b/src/config.rs index b716f648..b58fc240 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,6 +19,7 @@ pub struct AppData { github: GitHubClientData, s3: Option, webhook_url: String, + index_admin_webhook_url: String, static_storage: StaticStorage, public_storage: PublicStorage, private_storage: PrivateStorage, @@ -50,6 +51,7 @@ pub async fn build_config() -> anyhow::Result { let pool = sqlx::postgres::PgPoolOptions::default() .max_connections(pg_connections) + .acquire_timeout(Duration::from_secs(10)) .connect(&env_url) .await?; let port = dotenvy::var("PORT").map_or(8080, |x: String| x.parse::().unwrap()); @@ -59,6 +61,8 @@ pub async fn build_config() -> anyhow::Result { let github_client = dotenvy::var("GITHUB_CLIENT_ID").unwrap_or("".to_string()); let github_secret = dotenvy::var("GITHUB_CLIENT_SECRET").unwrap_or("".to_string()); let webhook_url = dotenvy::var("DISCORD_WEBHOOK_URL").unwrap_or("".to_string()); + let index_admin_webhook_url = + dotenvy::var("INDEX_ADMIN_DISCORD_WEBHOOK_URL").unwrap_or("".to_string()); let disable_downloads = dotenvy::var("DISABLE_DOWNLOAD_COUNTS").unwrap_or("0".to_string()) == "1"; let max_download_mb = dotenvy::var("MAX_MOD_FILESIZE_MB") @@ -97,6 +101,7 @@ pub async fn build_config() -> anyhow::Result { }) }, webhook_url, + index_admin_webhook_url, static_storage: StaticStorage::new(app_url.clone()), public_storage: PublicStorage::new(app_url.clone()), private_storage: PrivateStorage::new(), @@ -143,6 +148,10 @@ impl AppData { &self.webhook_url } + pub fn index_admin_webhook_url(&self) -> &str { + &self.index_admin_webhook_url + } + pub fn disable_downloads(&self) -> bool { self.disable_downloads } diff --git a/src/database/mod.rs b/src/database/mod.rs index 294e31fd..b19a744b 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -5,5 +5,5 @@ pub enum DatabaseError { #[error("Invalid input: {0}")] InvalidInput(String), #[error("Unknown database error")] - SqlxError(#[from] sqlx::Error) + SqlxError(#[from] sqlx::Error), } diff --git a/src/database/repository/auth_tokens.rs b/src/database/repository/auth_tokens.rs index 27091103..01ade28b 100644 --- a/src/database/repository/auth_tokens.rs +++ b/src/database/repository/auth_tokens.rs @@ -4,6 +4,7 @@ use sqlx::PgConnection; use uuid::Uuid; /// Assumes developer ID exists +#[tracing::instrument(skip_all, fields(developer_id = %developer_id))] pub async fn generate_token( developer_id: i32, with_expiry: bool, @@ -28,13 +29,12 @@ pub async fn generate_token( ) .execute(&mut *conn) .await - .inspect_err(|e| { - log::error!("Failed to insert auth_token for developer {developer_id}: {e}") - })?; + .inspect_err(|e| tracing::error!("{:?}", e))?; Ok(token) } +#[tracing::instrument(skip_all)] pub async fn remove_token(token: Uuid, conn: &mut PgConnection) -> Result<(), DatabaseError> { let hash = sha256::digest(token.to_string()); @@ -45,11 +45,12 @@ pub async fn remove_token(token: Uuid, conn: &mut PgConnection) -> Result<(), Da ) .execute(&mut *conn) .await - .inspect_err(|e| log::error!("Failed to remove auth token: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; Ok(()) } +#[tracing::instrument(skip_all, fields(developer_id = %developer_id))] pub async fn remove_developer_tokens( developer_id: i32, conn: &mut PgConnection, @@ -61,11 +62,12 @@ pub async fn remove_developer_tokens( ) .execute(&mut *conn) .await - .inspect_err(|e| log::error!("Failed to wipe developer tokens: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; Ok(()) } +#[tracing::instrument(skip_all)] pub async fn cleanup(conn: &mut PgConnection) -> Result<(), DatabaseError> { sqlx::query!( "DELETE FROM auth_tokens @@ -73,7 +75,7 @@ pub async fn cleanup(conn: &mut PgConnection) -> Result<(), DatabaseError> { ) .execute(conn) .await - .inspect_err(|e| log::error!("Auth token cleanup failed: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; Ok(()) } diff --git a/src/database/repository/dependencies.rs b/src/database/repository/dependencies.rs index e7846188..0d71ce59 100644 --- a/src/database/repository/dependencies.rs +++ b/src/database/repository/dependencies.rs @@ -8,6 +8,7 @@ use crate::{ }, }; +#[tracing::instrument(skip_all, fields(mod_version_id = %mod_version_id))] pub async fn create( mod_version_id: i32, json: &ModJson, @@ -59,10 +60,11 @@ pub async fn create( ) .fetch_all(conn) .await - .inspect_err(|e| log::error!("dependenceis::create query failed: {e}")) + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) } +#[tracing::instrument(skip_all, fields(mod_version_id = %id))] pub async fn clear(id: i32, conn: &mut PgConnection) -> Result<(), DatabaseError> { sqlx::query!( "DELETE FROM dependencies @@ -71,7 +73,7 @@ pub async fn clear(id: i32, conn: &mut PgConnection) -> Result<(), DatabaseError ) .execute(conn) .await - .inspect_err(|e| log::error!("dependencies::clear query failed: {e}")) + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) .map(|_| ()) } diff --git a/src/database/repository/deprecations.rs b/src/database/repository/deprecations.rs index 75861afa..8596513e 100644 --- a/src/database/repository/deprecations.rs +++ b/src/database/repository/deprecations.rs @@ -3,6 +3,7 @@ use crate::types::models::deprecations::Deprecation; use crate::types::models::developer::Developer; use sqlx::PgConnection; +#[tracing::instrument(skip_all, fields(mod_ids = ?ids))] pub async fn get_for_mods( ids: &[String], conn: &mut PgConnection, @@ -15,7 +16,7 @@ pub async fn get_for_mods( ) .fetch_all(&mut *conn) .await - .inspect_err(|e| log::error!("deprecations::get_for_mods failed: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; let mut bys: Vec<_> = sqlx::query!( "SELECT dby.deprecation_id, dby.by_mod_id @@ -25,7 +26,7 @@ pub async fn get_for_mods( ) .fetch_all(&mut *conn) .await - .inspect_err(|e| log::error!("deprecations::get_for_mods failed: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; Ok(deps .into_iter() @@ -41,6 +42,7 @@ pub async fn get_for_mods( .collect()) } +#[tracing::instrument(skip_all, fields(deprecation_id = %id))] pub async fn get(id: i32, conn: &mut PgConnection) -> Result, DatabaseError> { let dep = sqlx::query!( "SELECT @@ -53,7 +55,7 @@ pub async fn get(id: i32, conn: &mut PgConnection) -> Result ) .fetch_optional(&mut *conn) .await - .inspect_err(|e| log::error!("deprecations::get failed: {e}"))? + .inspect_err(|e| tracing::error!("{:?}", e))? .map(|x| Deprecation { id, mod_id: x.mod_id, @@ -75,13 +77,14 @@ pub async fn get(id: i32, conn: &mut PgConnection) -> Result ) .fetch_all(&mut *conn) .await - .inspect_err(|e| log::error!("deprecations::get failed: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; dep.by = deprecated_by.into_iter().map(|b| b.by_mod_id).collect(); Ok(Some(dep)) } +#[tracing::instrument(skip_all, fields(mod_id = %mod_id))] pub async fn create( mod_id: &str, by: &[String], @@ -99,7 +102,7 @@ pub async fn create( ) .fetch_one(&mut *conn) .await - .inspect_err(|e| log::error!("deprecations::create failed: {e}"))? + .inspect_err(|e| tracing::error!("{:?}", e))? .id; if !by.is_empty() { @@ -114,6 +117,7 @@ pub async fn create( }) } +#[tracing::instrument(skip_all, fields(deprecation_id = %deprecation.id))] pub async fn update( mut deprecation: Deprecation, by: Option<&[String]>, @@ -140,7 +144,7 @@ pub async fn update( ) .execute(&mut *conn) .await - .inspect_err(|e| log::error!("deprecations::update failed: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; deprecation.reason = reason.to_string(); @@ -155,7 +159,7 @@ pub async fn update( ) .execute(&mut *conn) .await - .inspect_err(|e| log::error!("deprecations::update failed: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; insert_deprecated_by(deprecation.id, by, &mut *conn).await?; @@ -172,12 +176,14 @@ pub async fn update( deprecation.id ) .execute(&mut *conn) - .await?; + .await + .inspect_err(|e| tracing::error!("{:?}", e))?; } Ok(deprecation) } +#[tracing::instrument(skip_all, fields(deprecation_id = %id))] pub async fn delete(id: i32, conn: &mut PgConnection) -> Result<(), DatabaseError> { sqlx::query!( "DELETE FROM deprecations @@ -186,11 +192,12 @@ pub async fn delete(id: i32, conn: &mut PgConnection) -> Result<(), DatabaseErro ) .execute(&mut *conn) .await - .inspect_err(|e| log::error!("deprecations::delete failed: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; Ok(()) } +#[tracing::instrument(skip_all, fields(mod_id = %mod_id))] pub async fn clear_all(mod_id: &str, conn: &mut PgConnection) -> Result<(), DatabaseError> { sqlx::query!( "DELETE FROM deprecations @@ -199,11 +206,12 @@ pub async fn clear_all(mod_id: &str, conn: &mut PgConnection) -> Result<(), Data ) .execute(&mut *conn) .await - .inspect_err(|e| log::error!("deprecations::clear_all failed: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; Ok(()) } +#[tracing::instrument(skip_all, fields(deprecation_id = %id))] async fn insert_deprecated_by( id: i32, by: &[String], @@ -227,7 +235,7 @@ async fn insert_deprecated_by( ) .execute(&mut *conn) .await - .inspect_err(|e| log::error!("deprecations::insert_deprecated_by failed: {e}")) + .inspect_err(|e| tracing::error!("{:?}", e)) .map(|_| ()) .map_err(|e| e.into()) } diff --git a/src/database/repository/developers.rs b/src/database/repository/developers.rs index 18eae63b..70baaf81 100644 --- a/src/database/repository/developers.rs +++ b/src/database/repository/developers.rs @@ -5,6 +5,7 @@ use sqlx::PgConnection; use std::collections::HashMap; use uuid::Uuid; +#[tracing::instrument(skip_all, fields(query = ?query))] pub async fn index( query: Option<&str>, page: i64, @@ -37,7 +38,7 @@ pub async fn index( ) .fetch_all(&mut *conn) .await - .inspect_err(|e| log::error!("Failed to fetch developers: {}", e))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; let count = index_count(query, &mut *conn).await?; @@ -47,6 +48,7 @@ pub async fn index( }) } +#[tracing::instrument(skip_all, fields(query = ?query))] pub async fn index_count( query: Option<&str>, conn: &mut PgConnection, @@ -62,11 +64,12 @@ pub async fn index_count( ) .fetch_one(&mut *conn) .await - .inspect_err(|e| log::error!("Failed to fetch developer count: {}", e)) + .inspect_err(|e| tracing::error!("{:?}", e)) .map(|x| x.count.unwrap_or(0)) .map_err(|e| e.into()) } +#[tracing::instrument(skip_all, fields(github_id = %github_id, username = %username))] pub async fn fetch_or_insert_github( github_id: i64, username: &str, @@ -87,13 +90,14 @@ pub async fn fetch_or_insert_github( ) .fetch_optional(&mut *conn) .await - .inspect_err(|e| log::error!("Failed to fetch developer for GitHub id: {e}"))? + .inspect_err(|e| tracing::error!("{:?}", e))? { Some(dev) => Ok(dev), None => Ok(insert_github(github_id, username, conn).await?), } } +#[tracing::instrument(skip_all, fields(github_id = %github_id, username = %username))] async fn insert_github( github_id: i64, username: &str, @@ -115,10 +119,11 @@ async fn insert_github( ) .fetch_one(&mut *conn) .await - .inspect_err(|e| log::error!("Failed to insert developer: {e}")) + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) } +#[tracing::instrument(skip_all, fields(developer_id = %id))] pub async fn get_one(id: i32, conn: &mut PgConnection) -> Result, DatabaseError> { sqlx::query_as!( Developer, @@ -135,10 +140,38 @@ pub async fn get_one(id: i32, conn: &mut PgConnection) -> Result Result, DatabaseError> { + if ids.is_empty() { + return Ok(vec![]); + } + sqlx::query_as!( + Developer, + "SELECT + id, + username, + display_name, + verified, + admin, + github_user_id as github_id + FROM developers + WHERE id = ANY($1)", + ids + ) + .fetch_all(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) +} + +#[tracing::instrument(skip_all, fields(username = %username))] pub async fn get_one_by_username( username: &str, conn: &mut PgConnection, @@ -165,10 +198,11 @@ pub async fn get_one_by_username( ) .fetch_optional(&mut *conn) .await - .inspect_err(|e| log::error!("Failed to fetch developer {username}: {e}")) + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|x| x.into()) } +#[tracing::instrument(skip_all, fields(mod_id = %mod_id))] pub async fn get_all_for_mod( mod_id: &str, conn: &mut PgConnection, @@ -188,10 +222,11 @@ pub async fn get_all_for_mod( ) .fetch_all(conn) .await - .inspect_err(|e| log::error!("Failed to fetch developers for mod {}: {}", mod_id, e)) + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) } +#[tracing::instrument(skip_all, fields(mod_ids = ?mod_ids))] pub async fn get_all_for_mods( mod_ids: &[String], conn: &mut PgConnection, @@ -223,7 +258,7 @@ pub async fn get_all_for_mods( ) .fetch_all(conn) .await - .inspect_err(|e| log::error!("Failed to fetch developers for mods: {}", e))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; let mut ret: HashMap> = HashMap::new(); @@ -241,6 +276,7 @@ pub async fn get_all_for_mods( Ok(ret) } +#[tracing::instrument(skip_all, fields(developer_id = %dev_id, mod_id = %mod_id))] pub async fn has_access_to_mod( dev_id: i32, mod_id: &str, @@ -255,18 +291,31 @@ pub async fn has_access_to_mod( ) .fetch_optional(&mut *conn) .await - .inspect_err(|e| { - log::error!( - "Failed to find mod {} access for developer {}: {}", - mod_id, - dev_id, - e - ); - }) + .inspect_err(|e| tracing::error!("{:?}", e)) .map(|x| x.is_some()) .map_err(|e| e.into()) } +#[tracing::instrument(skip_all, fields(developer_id = %dev_id))] +pub async fn has_active_mod(dev_id: i32, conn: &mut PgConnection) -> Result { + sqlx::query!( + "SELECT mods.id FROM mods + INNER JOIN mod_versions ON mods.id = mod_versions.mod_id + INNER JOIN mod_version_statuses ON mod_version_statuses.id = mod_versions.status_id + INNER JOIN mods_developers ON mods.id = mods_developers.mod_id + WHERE mods_developers.developer_id = $1 + AND mod_version_statuses.status = 'accepted' + LIMIT 1", + dev_id + ) + .fetch_optional(conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) + .map(|result| result.is_some()) +} + +#[tracing::instrument(skip_all, fields(developer_id = %dev_id, mod_id = %mod_id))] pub async fn owns_mod( dev_id: i32, mod_id: &str, @@ -282,17 +331,11 @@ pub async fn owns_mod( ) .fetch_optional(&mut *conn) .await - .inspect_err(|e| { - log::error!( - "Failed to check mod {} owner for developer {}: {}", - mod_id, - dev_id, - e - ) - })? + .inspect_err(|e| tracing::error!("{:?}", e))? .is_some()) } +#[tracing::instrument(skip_all, fields(mod_id = %mod_id))] pub async fn get_owner_for_mod( mod_id: &str, conn: &mut PgConnection, @@ -314,10 +357,11 @@ pub async fn get_owner_for_mod( ) .fetch_optional(&mut *conn) .await - .inspect_err(|e| log::error!("Failed to fetch owner for mod {mod_id}: {e}")) + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) } +#[tracing::instrument(skip_all, fields(developer_id = %dev_id))] pub async fn update_status( dev_id: i32, verified: bool, @@ -343,10 +387,11 @@ pub async fn update_status( ) .fetch_one(&mut *conn) .await - .inspect_err(|e| log::error!("Failed to update developer {dev_id}: {e}")) + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) } +#[tracing::instrument(skip_all, fields(developer_id = %dev_id))] pub async fn update_profile( dev_id: i32, display_name: &str, @@ -369,10 +414,11 @@ pub async fn update_profile( ) .fetch_one(&mut *conn) .await - .inspect_err(|e| log::error!("Failed to update profile for {dev_id}: {e}")) + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) } +#[tracing::instrument(skip_all)] pub async fn find_by_refresh_token( uuid: Uuid, conn: &mut PgConnection, @@ -395,10 +441,11 @@ pub async fn find_by_refresh_token( ) .fetch_optional(conn) .await - .inspect_err(|e| log::error!("Failed to search for developer by refresh token: {e}")) + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) } +#[tracing::instrument(skip_all)] pub async fn find_by_token( token: &Uuid, conn: &mut PgConnection, @@ -424,6 +471,25 @@ pub async fn find_by_token( ) .fetch_optional(&mut *conn) .await - .inspect_err(|e| log::error!("{}", e)) + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) +} + +#[tracing::instrument(skip_all, fields(developer_id = %id))] +pub async fn has_accepted_mod(id: i32, conn: &mut PgConnection) -> Result { + sqlx::query!( + "SELECT mod_versions.id + FROM mod_versions + INNER JOIN mods ON mods.id = mod_versions.mod_id + INNER JOIN mods_developers ON mods.id = mods_developers.mod_id + INNER JOIN mod_version_statuses ON mod_version_statuses.id = mod_versions.status_id + WHERE mod_version_statuses.status = 'accepted' + AND mods_developers.developer_id = $1", + id + ) + .fetch_optional(conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map(|x| x.is_some()) .map_err(|e| e.into()) } diff --git a/src/database/repository/github_login_attempts.rs b/src/database/repository/github_login_attempts.rs index 854760ab..0dcbfa6f 100644 --- a/src/database/repository/github_login_attempts.rs +++ b/src/database/repository/github_login_attempts.rs @@ -1,10 +1,11 @@ use crate::database::DatabaseError; use crate::types::models::github_login_attempt::StoredLoginAttempt; use chrono::Utc; -use sqlx::types::ipnetwork::IpNetwork; use sqlx::PgConnection; +use sqlx::types::ipnetwork::IpNetwork; use uuid::Uuid; +#[tracing::instrument(skip_all)] pub async fn get_one_by_ip( ip: IpNetwork, conn: &mut PgConnection, @@ -27,10 +28,11 @@ pub async fn get_one_by_ip( ) .fetch_optional(conn) .await - .inspect_err(|e| log::error!("Failed to fetch existing login attempt: {e}")) + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) } +#[tracing::instrument(skip_all)] pub async fn get_one_by_uuid( uuid: Uuid, pool: &mut PgConnection, @@ -53,10 +55,11 @@ pub async fn get_one_by_uuid( ) .fetch_optional(pool) .await - .inspect_err(|e| log::error!("Failed to fetch GitHub login attempt: {e}")) + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) } +#[tracing::instrument(skip_all)] pub async fn create( ip: IpNetwork, device_code: String, @@ -90,10 +93,11 @@ pub async fn create( ) .fetch_one(&mut *pool) .await - .inspect_err(|e| log::error!("Failed to insert new GitHub login attempt: {e}")) + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) } +#[tracing::instrument(skip_all)] pub async fn poll_now(uuid: Uuid, conn: &mut PgConnection) -> Result<(), DatabaseError> { let now = Utc::now(); sqlx::query!( @@ -105,16 +109,17 @@ pub async fn poll_now(uuid: Uuid, conn: &mut PgConnection) -> Result<(), Databas ) .execute(conn) .await - .inspect_err(|e| log::error!("Failed to poll GitHub login attempt: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; Ok(()) } +#[tracing::instrument(skip_all)] pub async fn remove(uuid: Uuid, conn: &mut PgConnection) -> Result<(), DatabaseError> { sqlx::query!("DELETE FROM github_login_attempts WHERE uid = $1", uuid) .execute(conn) .await - .inspect_err(|e| log::error!("Failed to remove GitHub login attempt: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; Ok(()) } diff --git a/src/database/repository/github_web_logins.rs b/src/database/repository/github_web_logins.rs index cd8acf98..799e11b7 100644 --- a/src/database/repository/github_web_logins.rs +++ b/src/database/repository/github_web_logins.rs @@ -2,31 +2,34 @@ use crate::database::DatabaseError; use sqlx::PgConnection; use uuid::Uuid; +#[tracing::instrument(skip_all)] pub async fn create_unique(conn: &mut PgConnection) -> Result { let unique = Uuid::new_v4(); sqlx::query!("INSERT INTO github_web_logins (state) VALUES ($1)", unique) .execute(conn) .await - .inspect_err(|e| log::error!("Failed to create GitHub web login secret: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; Ok(unique) } +#[tracing::instrument(skip_all)] pub async fn exists(uuid: Uuid, conn: &mut PgConnection) -> Result { sqlx::query!("SELECT state FROM github_web_logins WHERE state = $1", uuid) .fetch_optional(conn) .await - .inspect_err(|e| log::error!("Failed to delete GitHub web login secret: {e}")) + .inspect_err(|e| tracing::error!("{:?}", e)) .map(|x| x.is_some()) .map_err(|e| e.into()) } +#[tracing::instrument(skip_all)] pub async fn remove(uuid: Uuid, conn: &mut PgConnection) -> Result<(), DatabaseError> { sqlx::query!("DELETE FROM github_web_logins WHERE state = $1", uuid) .execute(conn) .await - .inspect_err(|e| log::error!("Failed to delete GitHub web login secret: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; Ok(()) } diff --git a/src/database/repository/incompatibilities.rs b/src/database/repository/incompatibilities.rs index 8c43188a..85bd88f6 100644 --- a/src/database/repository/incompatibilities.rs +++ b/src/database/repository/incompatibilities.rs @@ -11,6 +11,7 @@ use crate::{ }, }; +#[tracing::instrument(skip_all, fields(mod_version_id = %mod_version_id))] pub async fn create( mod_version_id: i32, json: &ModJson, @@ -64,10 +65,11 @@ pub async fn create( ) .fetch_all(conn) .await - .inspect_err(|e| log::error!("incompatibilities::create query failed: {e}")) + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) } +#[tracing::instrument(skip_all, fields(mod_version_id = %id))] pub async fn clear(id: i32, conn: &mut PgConnection) -> Result<(), DatabaseError> { sqlx::query!( "DELETE FROM incompatibilities @@ -76,7 +78,7 @@ pub async fn clear(id: i32, conn: &mut PgConnection) -> Result<(), DatabaseError ) .execute(conn) .await - .inspect_err(|e| log::error!("incompatibilities::clear query failed: {e}")) + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) .map(|_| ()) } diff --git a/src/database/repository/mod.rs b/src/database/repository/mod.rs index 1073d211..72539227 100644 --- a/src/database/repository/mod.rs +++ b/src/database/repository/mod.rs @@ -10,6 +10,7 @@ pub mod mod_gd_versions; pub mod mod_links; pub mod mod_tags; pub mod mod_version_statuses; +pub mod mod_version_submissions; pub mod mod_versions; pub mod mods; pub mod refresh_tokens; diff --git a/src/database/repository/mod_downloads.rs b/src/database/repository/mod_downloads.rs index 4f64e157..3decb4d9 100644 --- a/src/database/repository/mod_downloads.rs +++ b/src/database/repository/mod_downloads.rs @@ -1,8 +1,9 @@ use crate::database::DatabaseError; use chrono::{Days, Utc}; -use sqlx::types::ipnetwork::IpNetwork; use sqlx::PgConnection; +use sqlx::types::ipnetwork::IpNetwork; +#[tracing::instrument(skip_all, fields(mod_version_id = %mod_version_id))] pub async fn create( ip: IpNetwork, mod_version_id: i32, @@ -17,13 +18,12 @@ pub async fn create( ) .execute(&mut *conn) .await - .inspect_err(|e| { - log::error!("Failed to insert new download for mod_version id {mod_version_id}: {e}"); - })?; + .inspect_err(|e| tracing::error!("{:?}", e))?; Ok(result.rows_affected() > 0) } +#[tracing::instrument(skip_all, fields(mod_id = %mod_id))] pub async fn has_downloaded_mod( ip: IpNetwork, mod_id: &str, @@ -40,11 +40,12 @@ pub async fn has_downloaded_mod( ) .fetch_optional(&mut *conn) .await - .inspect_err(|e| log::error!("mod_downloads::has_downloaded_mod query error: {e}")) + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) .map(|x| x.is_some()) } +#[tracing::instrument(skip_all)] pub async fn cleanup(conn: &mut PgConnection) -> Result<(), DatabaseError> { let date = Utc::now().checked_sub_days(Days::new(30)).unwrap(); sqlx::query!( @@ -54,7 +55,7 @@ pub async fn cleanup(conn: &mut PgConnection) -> Result<(), DatabaseError> { ) .execute(&mut *conn) .await - .inspect_err(|e| log::error!("mod_downloads::cleanup query failed: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; Ok(()) } diff --git a/src/database/repository/mod_gd_versions.rs b/src/database/repository/mod_gd_versions.rs index 19efa36a..f694d21a 100644 --- a/src/database/repository/mod_gd_versions.rs +++ b/src/database/repository/mod_gd_versions.rs @@ -8,6 +8,7 @@ use crate::{ }, }; +#[tracing::instrument(skip_all, fields(mod_version_id = %mod_version_id))] pub async fn create( mod_version_id: i32, json: &ModJson, @@ -33,11 +34,12 @@ pub async fn create( ) .execute(conn) .await - .inspect_err(|e| log::error!("mod_gd_versions::create query failed: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; Ok(json.gd.clone()) } +#[tracing::instrument(skip_all, fields(mod_version_id = %mod_version_id))] pub async fn clear(mod_version_id: i32, conn: &mut PgConnection) -> Result<(), DatabaseError> { sqlx::query!( "DELETE FROM mod_gd_versions mgv @@ -46,7 +48,7 @@ pub async fn clear(mod_version_id: i32, conn: &mut PgConnection) -> Result<(), D ) .execute(&mut *conn) .await - .inspect_err(|e| log::error!("incompatibilities::clear query failed: {e}")) + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) .map(|_| ()) } diff --git a/src/database/repository/mod_links.rs b/src/database/repository/mod_links.rs index 86547a59..a224bd27 100644 --- a/src/database/repository/mod_links.rs +++ b/src/database/repository/mod_links.rs @@ -2,6 +2,7 @@ use sqlx::PgConnection; use crate::{database::DatabaseError, types::models::mod_link::ModLinks}; +#[tracing::instrument(skip_all, fields(mod_id = %mod_id))] pub async fn upsert( mod_id: &str, community: Option, @@ -26,7 +27,7 @@ pub async fn upsert( ) .execute(&mut *conn) .await - .inspect_err(|x| log::error!("Failed to upsert mod_links for id {mod_id}: {x}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; Ok(ModLinks { mod_id: mod_id.into(), diff --git a/src/database/repository/mod_tags.rs b/src/database/repository/mod_tags.rs index e2987cc2..2d15efc4 100644 --- a/src/database/repository/mod_tags.rs +++ b/src/database/repository/mod_tags.rs @@ -1,8 +1,9 @@ -use std::collections::HashSet; use crate::database::DatabaseError; use crate::types::models::tag::Tag; use sqlx::PgConnection; +use std::collections::HashSet; +#[tracing::instrument(skip_all)] pub async fn get_all_writable(conn: &mut PgConnection) -> Result, DatabaseError> { let tags = sqlx::query!( "SELECT @@ -15,7 +16,7 @@ pub async fn get_all_writable(conn: &mut PgConnection) -> Result, Datab ) .fetch_all(&mut *conn) .await - .inspect_err(|e| log::error!("mod_tags::get_all_writeable failed: {e}"))? + .inspect_err(|e| tracing::error!("{:?}", e))? .into_iter() .map(|i| Tag { id: i.id, @@ -28,6 +29,7 @@ pub async fn get_all_writable(conn: &mut PgConnection) -> Result, Datab Ok(tags) } +#[tracing::instrument(skip_all, fields(mod_id = %id))] pub async fn get_allowed_for_mod( id: &str, conn: &mut PgConnection, @@ -47,7 +49,7 @@ pub async fn get_allowed_for_mod( ) .fetch_all(&mut *conn) .await - .inspect_err(|e| log::error!("mod_tags::get_allowed_for_mod failed: {e}"))? + .inspect_err(|e| tracing::error!("{:?}", e))? .into_iter() .map(|i| Tag { id: i.id, @@ -62,6 +64,7 @@ pub async fn get_allowed_for_mod( return Ok(writable); } +#[tracing::instrument(skip_all)] pub async fn get_all(conn: &mut PgConnection) -> Result, DatabaseError> { let tags = sqlx::query!( "SELECT @@ -73,7 +76,7 @@ pub async fn get_all(conn: &mut PgConnection) -> Result, DatabaseError> ) .fetch_all(&mut *conn) .await - .inspect_err(|e| log::error!("mod_tags::get_all failed: {e}"))? + .inspect_err(|e| tracing::error!("{:?}", e))? .into_iter() .map(|i| Tag { id: i.id, @@ -86,6 +89,7 @@ pub async fn get_all(conn: &mut PgConnection) -> Result, DatabaseError> Ok(tags) } +#[tracing::instrument(skip_all, fields(mod_id = %id))] pub async fn get_for_mod(id: &str, conn: &mut PgConnection) -> Result, DatabaseError> { sqlx::query!( "SELECT @@ -100,7 +104,7 @@ pub async fn get_for_mod(id: &str, conn: &mut PgConnection) -> Result, ) .fetch_all(&mut *conn) .await - .inspect_err(|e| log::error!("mod_tags::get_tags failed: {e}")) + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) .map(|vec| { vec.into_iter() @@ -114,6 +118,7 @@ pub async fn get_for_mod(id: &str, conn: &mut PgConnection) -> Result, }) } +#[tracing::instrument(skip_all, fields(mod_id = %id))] pub async fn update_for_mod( id: &str, tags: &[Tag], @@ -145,7 +150,7 @@ pub async fn update_for_mod( ) .execute(&mut *conn) .await - .inspect_err(|e| log::error!("Failed to remove tags: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; } if !insertable.is_empty() { @@ -163,7 +168,7 @@ pub async fn update_for_mod( ) .execute(&mut *conn) .await - .inspect_err(|e| log::error!("Failed to insert tags: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; } Ok(()) diff --git a/src/database/repository/mod_version_statuses.rs b/src/database/repository/mod_version_statuses.rs index 37c9defe..e78e964b 100644 --- a/src/database/repository/mod_version_statuses.rs +++ b/src/database/repository/mod_version_statuses.rs @@ -2,6 +2,7 @@ use crate::database::DatabaseError; use crate::types::models::mod_version_status::ModVersionStatusEnum; use sqlx::PgConnection; +#[tracing::instrument(skip_all, fields(mod_version_id = %mod_version_id, status = ?status))] pub async fn create( mod_version_id: i32, status: ModVersionStatusEnum, @@ -19,7 +20,7 @@ pub async fn create( ) .fetch_one(conn) .await - .inspect_err(|e| log::error!("Failed to create mod_version_status: {e}")) + .inspect_err(|e| tracing::error!("{:?}", e)) .map(|i| i.id) .map_err(|e| e.into()) } diff --git a/src/database/repository/mod_version_submissions.rs b/src/database/repository/mod_version_submissions.rs new file mode 100644 index 00000000..caf4425c --- /dev/null +++ b/src/database/repository/mod_version_submissions.rs @@ -0,0 +1,449 @@ +use crate::database::DatabaseError; +use crate::types::models::audit_actions::{AuditAction, AuditActionRow}; +use crate::types::models::mod_version_submission::{ + ModVersionSubmissionAttachmentRow, ModVersionSubmissionCommentRow, ModVersionSubmissionLock, + ModVersionSubmissionRow, +}; +use sqlx::{Error, PgConnection}; +use std::collections::HashMap; + +#[tracing::instrument(skip_all, fields(mod_version_id = %id))] +pub async fn get_for_mod_version( + id: i32, + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query_as!( + ModVersionSubmissionRow, + r#"SELECT + mod_version_id, lock as "lock: _", locked_by, + created_at, updated_at + FROM mod_version_submissions + WHERE mod_version_id = $1"#, + id + ) + .fetch_optional(conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) +} + +#[tracing::instrument(skip_all, fields(submission_id = %id))] +pub async fn get_audit_for_submission( + id: i32, + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query_as!( + AuditActionRow, + r#"SELECT + action as "action: _", details, performed_by, performed_at + FROM mod_version_submissions_audit + WHERE submission_id = $1"#, + id + ) + .fetch_all(conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) +} + +#[tracing::instrument(skip_all, fields(mod_version_id = %mod_version_id))] +pub async fn create( + mod_version_id: i32, + conn: &mut PgConnection, +) -> Result { + let row = sqlx::query_as!( + ModVersionSubmissionRow, + r#"INSERT INTO mod_version_submissions (mod_version_id) + VALUES ($1) + RETURNING mod_version_id, lock as "lock: _", locked_by, created_at, updated_at"#, + mod_version_id + ) + .fetch_one(&mut *conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e))?; + + insert_submission_audit(mod_version_id, AuditAction::Created, None, None, conn).await?; + Ok(row) +} + +#[tracing::instrument(skip_all, fields(mod_version_id = %mod_version_id, lock = ?lock))] +pub async fn set_locked( + mod_version_id: i32, + lock: ModVersionSubmissionLock, + locked_by: Option, + conn: &mut PgConnection, +) -> Result { + insert_submission_audit( + mod_version_id, + AuditAction::Updated, + Some(&format!( + "Submission {}{}", + match lock { + ModVersionSubmissionLock::None => "unlocked", + ModVersionSubmissionLock::Internal => "restricted to mod developers and admins", + ModVersionSubmissionLock::Locked => "locked", + }, + if locked_by.is_none() { + " automatically" + } else { + "" + } + )), + locked_by, + &mut *conn, + ) + .await?; + + sqlx::query_as!( + ModVersionSubmissionRow, + r#"UPDATE mod_version_submissions + SET lock = $1, locked_by = $2, updated_at = NOW() + WHERE mod_version_id = $3 + RETURNING mod_version_id, lock as "lock: _", locked_by, created_at, updated_at"#, + lock as ModVersionSubmissionLock, + locked_by, + mod_version_id + ) + .fetch_one(conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) +} + +#[tracing::instrument(skip_all, fields(submission_id = %id))] +pub async fn get_paginated_comments_for_submission( + id: i32, + page: i64, + per_page: i64, + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query_as!( + ModVersionSubmissionCommentRow, + "SELECT + id, submission_id, comment, author_id, + created_at, updated_at + FROM mod_version_submission_comments + WHERE submission_id = $1 + ORDER BY id DESC + LIMIT $2 OFFSET $3", + id, + per_page, + (page - 1) * per_page + ) + .fetch_all(conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) +} + +#[tracing::instrument(skip_all, fields(submission_id = %id))] +pub async fn count_comments_for_submission( + id: i32, + conn: &mut PgConnection, +) -> Result { + sqlx::query_scalar!( + "SELECT COUNT(*) FROM mod_version_submission_comments WHERE submission_id = $1", + id + ) + .fetch_one(conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map(|c| c.unwrap_or(0)) + .map_err(|e| e.into()) +} + +#[tracing::instrument(skip_all, fields(submission_id = %submission_id, author_id = %author_id))] +pub async fn create_comment( + submission_id: i32, + author_id: i32, + comment: &str, + conn: &mut PgConnection, +) -> Result { + sqlx::query_as!( + ModVersionSubmissionCommentRow, + "INSERT INTO mod_version_submission_comments (submission_id, author_id, comment) + VALUES ($1, $2, $3) + RETURNING id, submission_id, comment, author_id, created_at, updated_at", + submission_id, + author_id, + comment + ) + .fetch_one(conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) +} + +#[tracing::instrument(skip_all, fields(comment_id = %comment_id))] +pub async fn get_comment( + comment_id: i64, + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query_as!( + ModVersionSubmissionCommentRow, + "SELECT id, submission_id, comment, author_id, created_at, updated_at + FROM mod_version_submission_comments + WHERE id = $1", + comment_id + ) + .fetch_optional(conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) +} + +#[tracing::instrument(skip_all, fields(comment_id = %comment_id))] +pub async fn update_comment( + comment_id: i64, + new_text: &str, + conn: &mut PgConnection, +) -> Result { + sqlx::query_as!( + ModVersionSubmissionCommentRow, + "UPDATE mod_version_submission_comments + SET comment = $1, updated_at = NOW() + WHERE id = $2 + RETURNING id, submission_id, comment, author_id, created_at, updated_at", + new_text, + comment_id + ) + .fetch_one(conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) +} + +#[tracing::instrument(skip_all, fields(comment_id = %comment_id))] +pub async fn delete_comment( + comment_id: i64, + conn: &mut PgConnection, +) -> Result { + let result = sqlx::query!( + "DELETE FROM mod_version_submission_comments WHERE id = $1", + comment_id + ) + .execute(conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e))?; + Ok(result.rows_affected() > 0) +} + +#[tracing::instrument(skip_all, fields(comment_id = %id))] +pub async fn get_audit_for_comment( + id: i64, + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query_as!( + AuditActionRow, + r#"SELECT + action as "action: _", details, performed_by, performed_at + FROM mod_version_submission_comment_audit + WHERE comment_id = $1"#, + id + ) + .fetch_all(conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) +} + +#[tracing::instrument(skip_all, fields(comment_id = %comment_id))] +pub async fn count_attachments_for_comment( + comment_id: i64, + conn: &mut PgConnection, +) -> Result { + sqlx::query_scalar!( + "SELECT COUNT(*) FROM mod_version_submission_comment_attachments WHERE comment_id = $1", + comment_id + ) + .fetch_one(conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map(|c| c.unwrap_or(0)) + .map_err(|e| e.into()) +} + +#[tracing::instrument(skip_all, fields(comment_id = %comment_id, filename = %filename))] +pub async fn create_attachment( + comment_id: i64, + filename: &str, + conn: &mut PgConnection, +) -> Result { + sqlx::query_as!( + ModVersionSubmissionAttachmentRow, + "INSERT INTO mod_version_submission_comment_attachments (comment_id, filename) + VALUES ($1, $2) + RETURNING id, comment_id, filename, created_at", + comment_id, + filename + ) + .fetch_one(conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) +} + +#[tracing::instrument(skip_all, fields(comment_id = %comment_id))] +pub async fn get_attachments_for_comment( + comment_id: i64, + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query_as!( + ModVersionSubmissionAttachmentRow, + "SELECT id, comment_id, filename, created_at + FROM mod_version_submission_comment_attachments + WHERE comment_id = $1 + ORDER BY id ASC", + comment_id + ) + .fetch_all(conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e: Error| e.into()) +} + +#[tracing::instrument(skip_all, fields(comment_ids = ?comment_ids))] +pub async fn get_attachments_for_comments( + comment_ids: &[i64], + conn: &mut PgConnection, +) -> Result>, DatabaseError> { + let rows = sqlx::query_as!( + ModVersionSubmissionAttachmentRow, + "SELECT id, comment_id, filename, created_at + FROM mod_version_submission_comment_attachments + WHERE comment_id = ANY($1) + ORDER BY id ASC", + comment_ids + ) + .fetch_all(conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e))?; + + let mut ret: HashMap> = + HashMap::with_capacity(comment_ids.len()); + + for row in rows { + ret.entry(row.comment_id).or_default().push(row); + } + + Ok(ret) +} + +#[tracing::instrument(skip_all, fields(attachment_id = %attachment_id))] +pub async fn get_attachment( + attachment_id: i64, + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query_as!( + ModVersionSubmissionAttachmentRow, + "SELECT id, comment_id, filename, created_at + FROM mod_version_submission_comment_attachments + WHERE id = $1", + attachment_id + ) + .fetch_optional(conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map_err(|e| e.into()) +} + +#[tracing::instrument(skip_all, fields(attachment_id = %attachment_id))] +pub async fn delete_attachment( + attachment_id: i64, + conn: &mut PgConnection, +) -> Result { + let result = sqlx::query!( + "DELETE FROM mod_version_submission_comment_attachments WHERE id = $1", + attachment_id + ) + .execute(conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e))?; + Ok(result.rows_affected() > 0) +} + +#[tracing::instrument(skip_all, fields(filename = %filename))] +pub async fn count_references_to_filename( + filename: &str, + conn: &mut PgConnection, +) -> Result { + sqlx::query_scalar!( + "SELECT COUNT(*) FROM mod_version_submission_comment_attachments WHERE filename = $1", + filename + ) + .fetch_one(conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map(|c| c.unwrap_or(0)) + .map_err(|e| e.into()) +} + +#[tracing::instrument(skip_all, fields(filenames = ?filenames))] +pub async fn count_references_to_filenames( + filenames: &[String], + conn: &mut PgConnection, +) -> Result, DatabaseError> { + sqlx::query!( + "SELECT + filename, COUNT(*) as count + FROM mod_version_submission_comment_attachments + WHERE filename = ANY($1) + GROUP BY filename", + filenames + ) + .fetch_all(conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map(|x| { + x.into_iter() + .map(|record| (record.filename, record.count.unwrap_or(0))) + .collect() + }) + .map_err(|e| e.into()) +} + +#[tracing::instrument(skip_all, fields(submission_id = %id, action = ?action))] +pub async fn insert_submission_audit( + id: i32, + action: AuditAction, + details: Option<&str>, + performed_by: Option, + conn: &mut PgConnection, +) -> Result<(), DatabaseError> { + sqlx::query!( + "INSERT INTO mod_version_submissions_audit (submission_id, action, details, performed_by) + VALUES ($1, $2, $3, $4)", + id, + action as AuditAction, + details, + performed_by + ) + .execute(conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map(|_| ()) + .map_err(|e| e.into()) +} + +#[tracing::instrument(skip_all, fields(comment_id = %id, action = ?action))] +pub async fn insert_comment_audit( + id: i64, + action: AuditAction, + details: Option<&str>, + performed_by: Option, + conn: &mut PgConnection, +) -> Result<(), DatabaseError> { + sqlx::query!( + "INSERT INTO mod_version_submission_comment_audit (comment_id, action, details, performed_by)\ + VALUES ($1, $2, $3, $4)", + id, + action as AuditAction, + details, + performed_by + ) + .execute(conn) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map(|_| ()) + .map_err(|e| e.into()) +} diff --git a/src/database/repository/mod_versions.rs b/src/database/repository/mod_versions.rs index fc6d4752..98a99e41 100644 --- a/src/database/repository/mod_versions.rs +++ b/src/database/repository/mod_versions.rs @@ -3,7 +3,8 @@ use crate::database::DatabaseError; use crate::types::{ mod_json::ModJson, models::{ - developer::Developer, mod_version::ModVersion, mod_version_status::ModVersionStatusEnum, mod_gd_version::ModGDVersion, + developer::Developer, mod_gd_version::ModGDVersion, mod_version::ModVersion, + mod_version_status::ModVersionStatusEnum, }, }; use chrono::{DateTime, Utc}; @@ -60,6 +61,7 @@ impl ModVersionRow { } } +#[tracing::instrument(skip_all, fields(mod_id = %mod_id, version = %version))] pub async fn get_by_version_str( mod_id: &str, version: &str, @@ -83,11 +85,12 @@ pub async fn get_by_version_str( ) .fetch_optional(conn) .await - .inspect_err(|e| log::error!("Failed to get mod_version by version string: {e}")) + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) .map(|opt| opt.map(|x| x.into_mod_version())) } +#[tracing::instrument(skip_all, fields(mod_id = %mod_id, statuses = ?statuses))] pub async fn get_for_mod( mod_id: &str, statuses: Option<&[ModVersionStatusEnum]>, @@ -112,7 +115,7 @@ pub async fn get_for_mod( ) .fetch_all(&mut *conn) .await - .inspect_err(|e| log::error!("Failed to get mod_versions for mod {mod_id}: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; let version_ids: Vec = records.iter().map(|x| x.id).collect(); let mut gd_versions = ModGDVersion::get_for_mod_versions(&version_ids, conn).await?; @@ -129,6 +132,7 @@ pub async fn get_for_mod( Ok(versions) } +#[tracing::instrument(skip_all, fields(mod_version_id = %id))] pub async fn increment_downloads(id: i32, conn: &mut PgConnection) -> Result<(), DatabaseError> { sqlx::query!( "UPDATE mod_versions @@ -138,11 +142,12 @@ pub async fn increment_downloads(id: i32, conn: &mut PgConnection) -> Result<(), ) .execute(&mut *conn) .await - .inspect_err(|e| log::error!("Failed to increment downloads for mod_version {id}: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; Ok(()) } +#[tracing::instrument(skip_all, fields(mod_id = %json.id, version = %json.version))] pub async fn create_from_json( json: &ModJson, make_accepted: bool, @@ -151,7 +156,7 @@ pub async fn create_from_json( sqlx::query!("SET CONSTRAINTS mod_versions_status_id_fkey DEFERRED") .execute(&mut *conn) .await - .inspect_err(|e| log::error!("Failed to update constraint: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; let geode = Version::parse(&json.geode).or(Err(DatabaseError::InvalidInput( "mod.json geode version is invalid semver".into(), @@ -192,7 +197,7 @@ pub async fn create_from_json( ) .fetch_one(&mut *conn) .await - .inspect_err(|e| log::error!("Failed to insert mod_version: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; let id = row.id; @@ -209,12 +214,12 @@ pub async fn create_from_json( ) .execute(&mut *conn) .await - .inspect_err(|e| log::error!("Failed to set status: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; sqlx::query!("SET CONSTRAINTS mod_versions_status_id_fkey IMMEDIATE") .execute(&mut *conn) .await - .inspect_err(|e| log::error!("Failed to update constraint: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; Ok(ModVersion { id, @@ -242,6 +247,7 @@ pub async fn create_from_json( }) } +#[tracing::instrument(skip_all, fields(mod_version_id = %version_id, mod_id = %json.id, version = %json.version))] pub async fn update_pending_version( version_id: i32, json: &ModJson, @@ -305,13 +311,7 @@ pub async fn update_pending_version( ) .fetch_one(&mut *conn) .await - .inspect_err(|err| { - log::error!( - "Failed to update pending version {}-{}: {err}", - json.id, - json.version - ) - })?; + .inspect_err(|e| tracing::error!("{:?}", e))?; if make_accepted { sqlx::query!( @@ -322,7 +322,7 @@ pub async fn update_pending_version( ) .execute(&mut *conn) .await - .inspect_err(|e| log::error!("Failed to update tag for mod: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; } Ok(ModVersion { @@ -354,6 +354,7 @@ pub async fn update_pending_version( }) } +#[tracing::instrument(skip_all, fields(mod_version_id = %version.id, status = ?status))] pub async fn update_version_status( mut version: ModVersion, status: ModVersionStatusEnum, @@ -379,7 +380,7 @@ pub async fn update_version_status( ) .execute(&mut *conn) .await - .inspect_err(|e| log::error!("Failed to update mod_version_status: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; version.status = status; diff --git a/src/database/repository/mods.rs b/src/database/repository/mods.rs index 54bc4d54..9b550586 100644 --- a/src/database/repository/mods.rs +++ b/src/database/repository/mods.rs @@ -42,6 +42,7 @@ impl ModRecordGetOne { /// Fetches information for a mod, without versions or other added info. /// /// The second parameter decides if about.md and changelog.md are fetched from the database. Those are pretty big files, so only fetch them if needed. +#[tracing::instrument(skip_all, fields(mod_id = %id))] pub async fn get_one( id: &str, include_md: bool, @@ -59,7 +60,7 @@ pub async fn get_one( ) .fetch_optional(conn) .await - .inspect_err(|e| log::error!("Failed to fetch mod {id}: {e}")) + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) .map(|x| x.map(|x| x.into_mod())) } else { @@ -74,13 +75,14 @@ pub async fn get_one( ) .fetch_optional(conn) .await - .inspect_err(|e| log::error!("Failed to fetch mod {}: {}", id, e)) + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) .map(|x| x.map(|x| x.into_mod())) } } /// Does NOT check if the target mod exists +#[tracing::instrument(skip_all, fields(mod_id = %json.id))] pub async fn create(json: &ModJson, conn: &mut PgConnection) -> Result { sqlx::query_as!( ModRecordGetOne, @@ -104,11 +106,12 @@ pub async fn create(json: &ModJson, conn: &mut PgConnection) -> Result Result { Ok(sqlx::query!("SELECT featured FROM mods WHERE id = $1", id) .fetch_optional(&mut *conn) .await - .inspect_err(|e| log::error!("Failed to check if mod {id} is featured: {e}"))? + .inspect_err(|e| tracing::error!("{:?}", e))? .map(|row| row.featured) .unwrap_or(false)) } +#[tracing::instrument(skip_all, fields(mod_id = %id))] pub async fn exists(id: &str, conn: &mut PgConnection) -> Result { Ok(sqlx::query!("SELECT id FROM mods WHERE id = $1", id) .fetch_optional(&mut *conn) .await - .inspect_err(|e| log::error!("Failed to check if mod {} exists: {}", id, e))? + .inspect_err(|e| tracing::error!("{:?}", e))? .is_some()) } /// Checks if multiple ids exist in the database. /// /// Returns a tuple with (existing ids, missing ids). +#[tracing::instrument(skip_all, fields(mod_ids = ?ids))] pub async fn exists_multiple( ids: &[String], conn: &mut PgConnection, @@ -192,7 +191,7 @@ pub async fn exists_multiple( let mods: HashSet = sqlx::query!("SELECT id FROM mods WHERE id = ANY($1)", ids) .fetch_all(&mut *conn) .await - .inspect_err(|e| log::error!("mods::exists_multiple failed: {e}"))? + .inspect_err(|e| tracing::error!("{:?}", e))? .into_iter() .map(|x| x.id) .collect(); @@ -211,6 +210,7 @@ pub async fn exists_multiple( Ok((existing, missing)) } +#[tracing::instrument(skip_all, fields(mod_id = %id))] pub async fn get_logo(id: &str, conn: &mut PgConnection) -> Result>, DatabaseError> { struct QueryResult { image: Option>, @@ -228,7 +228,7 @@ pub async fn get_logo(id: &str, conn: &mut PgConnection) -> Result Result Result<(), DatabaseError> { sqlx::query!( "UPDATE mods @@ -248,11 +249,12 @@ pub async fn increment_downloads(id: &str, conn: &mut PgConnection) -> Result<() ) .execute(&mut *conn) .await - .inspect_err(|e| log::error!("Failed to increment downloads for mod {id}: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; Ok(()) } +#[tracing::instrument(skip_all, fields(mod_id = %the_mod.id))] pub async fn update_with_json( mut the_mod: Mod, json: &ModJson, @@ -274,7 +276,7 @@ pub async fn update_with_json( ) .execute(conn) .await - .inspect_err(|e| log::error!("Failed to update mod: {}", e))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; the_mod.repository = json.repository.clone(); the_mod.about = json.about.clone(); @@ -283,6 +285,7 @@ pub async fn update_with_json( Ok(the_mod) } +#[tracing::instrument(skip_all, fields(mod_id = %the_mod.id))] pub async fn update_with_json_moved( mut the_mod: Mod, json: ModJson, @@ -304,7 +307,7 @@ pub async fn update_with_json_moved( ) .execute(conn) .await - .inspect_err(|e| log::error!("Failed to update mod: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; the_mod.repository = json.repository; the_mod.about = json.about; @@ -315,6 +318,7 @@ pub async fn update_with_json_moved( /// Used when first version goes from pending to accepted. /// Makes it so versions that stay a lot in pending appear at the top of the newly created lists +#[tracing::instrument(skip_all, fields(mod_id = %id))] pub async fn touch_created_at(id: &str, conn: &mut PgConnection) -> Result<(), DatabaseError> { sqlx::query!( "UPDATE mods @@ -324,7 +328,7 @@ pub async fn touch_created_at(id: &str, conn: &mut PgConnection) -> Result<(), D ) .execute(conn) .await - .inspect_err(|e| log::error!("Failed to touch created_at for mod {id}: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; Ok(()) } diff --git a/src/database/repository/refresh_tokens.rs b/src/database/repository/refresh_tokens.rs index 2d643182..79b10fc7 100644 --- a/src/database/repository/refresh_tokens.rs +++ b/src/database/repository/refresh_tokens.rs @@ -3,6 +3,7 @@ use chrono::{Days, Utc}; use sqlx::PgConnection; use uuid::Uuid; +#[tracing::instrument(skip_all, fields(developer_id = %developer_id))] pub async fn generate_token( developer_id: i32, conn: &mut PgConnection, @@ -20,11 +21,12 @@ pub async fn generate_token( ) .execute(conn) .await - .inspect_err(|e| log::error!("Failed to insert refresh token: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; Ok(token) } +#[tracing::instrument(skip_all)] pub async fn remove_token(token: Uuid, conn: &mut PgConnection) -> Result<(), DatabaseError> { let hash = sha256::digest(token.to_string()); sqlx::query!( @@ -34,11 +36,12 @@ pub async fn remove_token(token: Uuid, conn: &mut PgConnection) -> Result<(), Da ) .execute(conn) .await - .inspect_err(|e| log::error!("Failed to remove refresh token: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; Ok(()) } +#[tracing::instrument(skip_all, fields(developer_id = %developer_id))] pub async fn remove_developer_tokens( developer_id: i32, conn: &mut PgConnection, @@ -50,11 +53,12 @@ pub async fn remove_developer_tokens( ) .execute(conn) .await - .inspect_err(|e| log::error!("Failed to remove refresh tokens: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; Ok(()) } +#[tracing::instrument(skip_all)] pub async fn cleanup(conn: &mut PgConnection) -> Result<(), DatabaseError> { sqlx::query!( "DELETE FROM refresh_tokens @@ -62,7 +66,7 @@ pub async fn cleanup(conn: &mut PgConnection) -> Result<(), DatabaseError> { ) .execute(conn) .await - .inspect_err(|e| log::error!("Refresh token cleanup failed: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; Ok(()) } diff --git a/src/endpoints/auth/github.rs b/src/endpoints/auth/github.rs index f14aa222..d79b988c 100644 --- a/src/endpoints/auth/github.rs +++ b/src/endpoints/auth/github.rs @@ -1,16 +1,16 @@ use actix_web::http::StatusCode; -use actix_web::{dev::ConnectionInfo, post, web, HttpResponse, Responder}; +use actix_web::{HttpResponse, Responder, dev::ConnectionInfo, post, web}; use serde::Deserialize; -use sqlx::{types::ipnetwork::IpNetwork, Acquire}; -use uuid::Uuid; +use sqlx::{Acquire, types::ipnetwork::IpNetwork}; use utoipa::ToSchema; +use uuid::Uuid; use crate::config::AppData; use crate::database::repository::{ auth_tokens, developers, github_login_attempts, github_web_logins, refresh_tokens, }; -use crate::endpoints::auth::TokensResponse; use crate::endpoints::ApiError; +use crate::endpoints::auth::TokensResponse; use crate::{auth::github, types::api::ApiResponse}; #[derive(Deserialize, ToSchema)] @@ -40,6 +40,7 @@ struct CallbackParams { ) )] #[post("v1/login/github")] +#[tracing::instrument(skip_all)] pub async fn start_github_login( data: web::Data, info: ConnectionInfo, @@ -77,6 +78,7 @@ pub async fn start_github_login( ) )] #[post("v1/login/github/web")] +#[tracing::instrument(skip_all)] pub async fn start_github_web_login(data: web::Data) -> Result { let mut pool = data.db().acquire().await?; @@ -106,6 +108,7 @@ pub async fn start_github_web_login(data: web::Data) -> Result, data: web::Data, @@ -163,6 +166,7 @@ pub async fn github_web_callback( ) )] #[post("v1/login/github/poll")] +#[tracing::instrument(skip_all)] pub async fn poll_github_login( json: web::Json, data: web::Data, @@ -219,7 +223,7 @@ pub async fn poll_github_login( let user = client .get_user(&token) .await - .inspect_err(|e| log::error!("Failed to fetch user from GitHub: {e}")) + .inspect_err(|e| tracing::error!("Failed to fetch user from GitHub: {e}")) .map_err(|_| ApiError::InternalError("Failed to fetch user data from GitHub".into()))?; let mut tx = pool.begin().await?; @@ -267,6 +271,7 @@ pub async fn poll_github_login( ) )] #[post("v1/login/github/token")] +#[tracing::instrument(skip_all)] pub async fn github_token_login( json: web::Json, data: web::Data, @@ -277,10 +282,10 @@ pub async fn github_token_login( ); let user = match client.get_user(&json.token).await { - Err(_) => client - .get_installation(&json.token) - .await - .map_err(|_| ApiError::BadRequest(format!("Invalid access token: {}", json.token)))?, + Err(_) => client.get_installation(&json.token).await.map_err(|e| { + tracing::error!(error = ?e, "invalid access token"); + ApiError::BadRequest(format!("Invalid access token: {}", json.token)) + })?, Ok(u) => u, }; diff --git a/src/endpoints/auth/mod.rs b/src/endpoints/auth/mod.rs index bf72a8a9..286a08c2 100644 --- a/src/endpoints/auth/mod.rs +++ b/src/endpoints/auth/mod.rs @@ -3,11 +3,11 @@ use crate::database::repository::{auth_tokens, developers, refresh_tokens}; use crate::endpoints::ApiError; use crate::extractors::auth::Auth; use crate::types::api::ApiResponse; -use actix_web::{post, web, Responder}; +use actix_web::{Responder, post, web}; use serde::{Deserialize, Serialize}; use sqlx::Acquire; -use uuid::Uuid; use utoipa::ToSchema; +use uuid::Uuid; pub mod github; @@ -34,6 +34,7 @@ struct RefreshBody { ) )] #[post("v1/login/refresh")] +#[tracing::instrument(skip_all)] pub async fn refresh_token( json: web::Json, data: web::Data, diff --git a/src/endpoints/deprecations.rs b/src/endpoints/deprecations.rs index f07fa348..1d06cab6 100644 --- a/src/endpoints/deprecations.rs +++ b/src/endpoints/deprecations.rs @@ -1,296 +1,301 @@ -use crate::{ - config::AppData, - database::repository::{deprecations, developers, mods}, - endpoints::ApiError, - extractors::auth::Auth, - types::api::ApiResponse, - types::models::deprecations::Deprecation, -}; -use actix_web::{HttpResponse, Responder, delete, get, post, put, web}; -use serde::Deserialize; -use sqlx::{Acquire, PgConnection}; -use utoipa::{IntoParams, ToSchema}; - -#[derive(Deserialize, IntoParams)] -struct ModPath { - id: String, -} - -#[derive(Deserialize, IntoParams)] -struct ModDeprecationPath { - id: String, - deprecation_id: i32, -} - -#[derive(Deserialize, ToSchema)] -struct CreateDeprecationData { - by: Vec, - reason: String, -} - -#[derive(Deserialize, ToSchema)] -struct UpdateDeprecationData { - by: Option>, - reason: Option, -} - -const MAX_MODS_PER_DEPRECATION: usize = 20; - -/// Fetch all deprecations for a mod -#[utoipa::path( - get, - path = "/v1/mods/{id}/deprecations", - tag = "deprecations", - params(ModPath), - responses( - (status = 200, description = "List of deprecations", body = inline(ApiResponse>)), - (status = 404, description = "Mod not found") - ) -)] -#[get("v1/mods/{id}/deprecations")] -pub async fn index( - data: web::Data, - path: web::Path -) -> Result { - let mut pool = data.db().acquire().await?; - - if !mods::exists(&path.id, &mut pool).await? { - return Err(ApiError::NotFound(format!("Mod id {} not found", path.id))); - } - - let deps = deprecations::get_for_mods(std::slice::from_ref(&path.id), &mut pool).await?; - Ok(web::Json(ApiResponse { - error: "".into(), - payload: deps, - })) -} - -/// Insert one deprecation for a mod -#[utoipa::path( - post, - path = "/v1/mods/{id}/deprecations", - tag = "deprecations", - params(ModPath), - request_body = CreateDeprecationData, - responses( - (status = 201, description = "Deprecation created", body = inline(ApiResponse)), - (status = 400, description = "Bad request"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden"), - (status = 404, description = "Mod not found") - ), - security( - ("bearer_token" = []) - ) -)] -#[post("v1/mods/{id}/deprecations")] -pub async fn store( - data: web::Data, - path: web::Path, - json: web::Json, - auth: Auth, -) -> Result { - let dev = auth.developer()?; - let mut pool = data.db().acquire().await?; - let mut tx = pool.begin().await?; - - if !mods::exists(&path.id, &mut tx).await? { - return Err(ApiError::NotFound(format!("Mod id {} not found", path.id))); - } - - if !dev.admin && !developers::owns_mod(dev.id, &path.id, &mut tx).await? { - return Err(ApiError::Authorization); - } - - check_existing_mods(&json.by, &mut tx).await?; - - let deprecation = deprecations::create(&path.id, &json.by, &json.reason, &dev, &mut tx).await?; - - tx.commit().await?; - Ok(HttpResponse::Created().json(ApiResponse { - error: "".into(), - payload: deprecation, - })) -} - -/// Update a deprecation -#[utoipa::path( - put, - path = "/v1/mods/{id}/deprecations/{deprecation_id}", - tag = "deprecations", - params(ModDeprecationPath), - request_body = UpdateDeprecationData, - responses( - (status = 200, description = "Deprecation updated", body = inline(ApiResponse)), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden"), - (status = 404, description = "Deprecation not found") - ), - security( - ("bearer_token" = []) - ) -)] -#[put("v1/mods/{id}/deprecations/{deprecation_id}")] -pub async fn update( - data: web::Data, - path: web::Path, - json: web::Json, - auth: Auth, -) -> Result { - let dev = auth.developer()?; - let mut pool = data.db().acquire().await?; - let mut tx = pool.begin().await?; - - if !mods::exists(&path.id, &mut tx).await? { - return Err(ApiError::NotFound(format!("Mod id {} not found", path.id))); - } - - if let Some(by) = &json.by { - check_existing_mods(by, &mut tx).await?; - } - - let deprecation = deprecations::get(path.deprecation_id, &mut tx) - .await? - .ok_or(ApiError::NotFound(format!( - "Deprecation id {} not found", - path.deprecation_id - )))?; - - // If the ID doesn't match, just ignore it - if deprecation.mod_id != path.id { - return Err(ApiError::NotFound(format!( - "Deprecation id {} not found", - path.deprecation_id - ))); - } - - if !dev.admin && !developers::owns_mod(dev.id, &path.id, &mut tx).await? { - return Err(ApiError::Authorization); - } - - let updated = deprecations::update( - deprecation, - json.by.as_deref(), - json.reason.as_deref(), - &dev, - &mut tx, - ) - .await?; - - tx.commit().await?; - Ok(HttpResponse::Ok().json(ApiResponse { - error: "".into(), - payload: updated, - })) -} - -/// Delete a deprecation -#[utoipa::path( - delete, - path = "/v1/mods/{id}/deprecations/{deprecation_id}", - tag = "deprecations", - params(ModDeprecationPath), - responses( - (status = 204, description = "Deprecation deleted"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden"), - (status = 404, description = "Deprecation not found") - ), - security( - ("bearer_token" = []) - ) -)] -#[delete("v1/mods/{id}/deprecations/{deprecation_id}")] -pub async fn delete( - data: web::Data, - path: web::Path, - auth: Auth, -) -> Result { - let dev = auth.developer()?; - let mut pool = data.db().acquire().await?; - - if !mods::exists(&path.id, &mut pool).await? { - return Err(ApiError::NotFound(format!("Mod id {} not found", path.id))); - } - - let deprecation = deprecations::get(path.deprecation_id, &mut pool) - .await? - .ok_or(ApiError::NotFound(format!( - "Deprecation id {} not found", - path.deprecation_id - )))?; - - // If the ID doesn't match, just ignore it - if deprecation.mod_id != path.id { - return Err(ApiError::NotFound(format!( - "Deprecation id {} not found", - path.deprecation_id - ))); - } - - if !dev.admin && !developers::owns_mod(dev.id, &path.id, &mut pool).await? { - return Err(ApiError::Authorization); - } - - deprecations::delete(deprecation.id, &mut pool).await?; - - Ok(HttpResponse::NoContent()) -} - -/// Delete all deprecations for a mod -#[utoipa::path( - delete, - path = "/v1/mods/{id}/deprecations", - tag = "deprecations", - params(ModPath), - responses( - (status = 204, description = "All deprecations deleted"), - (status = 401, description = "Unauthorized"), - (status = 403, description = "Forbidden"), - (status = 404, description = "Mod not found") - ), - security( - ("bearer_token" = []) - ) -)] -#[delete("v1/mods/{id}/deprecations")] -pub async fn clear_all( - data: web::Data, - path: web::Path, - auth: Auth, -) -> Result { - let dev = auth.developer()?; - let mut pool = data.db().acquire().await?; - - if !mods::exists(&path.id, &mut pool).await? { - return Err(ApiError::NotFound(format!("Mod id {} not found", path.id))); - } - - if !dev.admin && !developers::owns_mod(dev.id, &path.id, &mut pool).await? { - return Err(ApiError::Authorization); - } - - deprecations::clear_all(&path.id, &mut pool).await?; - - Ok(HttpResponse::NoContent()) -} - -async fn check_existing_mods(ids: &[String], conn: &mut PgConnection) -> Result<(), ApiError> { - if ids.len() > MAX_MODS_PER_DEPRECATION { - return Err(ApiError::BadRequest(format!( - "Max {} mods allowed per deprecation", - MAX_MODS_PER_DEPRECATION - ))); - } - - let (_, missing) = mods::exists_multiple(ids, &mut *conn).await?; - - if !missing.is_empty() { - return Err(ApiError::BadRequest(format!( - "The following mods don't exist on the index: {}", - missing.join(", ") - ))); - } - - Ok(()) -} +use crate::{ + config::AppData, + database::repository::{deprecations, developers, mods}, + endpoints::ApiError, + extractors::auth::Auth, + types::api::ApiResponse, + types::models::deprecations::Deprecation, +}; +use actix_web::{HttpResponse, Responder, delete, get, post, put, web}; +use serde::Deserialize; +use sqlx::{Acquire, PgConnection}; +use utoipa::{IntoParams, ToSchema}; + +#[derive(Deserialize, IntoParams)] +struct ModPath { + id: String, +} + +#[derive(Deserialize, IntoParams)] +struct ModDeprecationPath { + id: String, + deprecation_id: i32, +} + +#[derive(Deserialize, ToSchema)] +struct CreateDeprecationData { + by: Vec, + reason: String, +} + +#[derive(Deserialize, ToSchema)] +struct UpdateDeprecationData { + by: Option>, + reason: Option, +} + +const MAX_MODS_PER_DEPRECATION: usize = 20; + +/// Fetch all deprecations for a mod +#[utoipa::path( + get, + path = "/v1/mods/{id}/deprecations", + tag = "deprecations", + params(ModPath), + responses( + (status = 200, description = "List of deprecations", body = inline(ApiResponse>)), + (status = 404, description = "Mod not found") + ) +)] +#[get("v1/mods/{id}/deprecations")] +#[tracing::instrument(skip_all, fields(mod_id = %path.id))] +pub async fn index( + data: web::Data, + path: web::Path, +) -> Result { + let mut pool = data.db().acquire().await?; + + if !mods::exists(&path.id, &mut pool).await? { + return Err(ApiError::NotFound(format!("Mod id {} not found", path.id))); + } + + let deps = deprecations::get_for_mods(std::slice::from_ref(&path.id), &mut pool).await?; + Ok(web::Json(ApiResponse { + error: "".into(), + payload: deps, + })) +} + +/// Insert one deprecation for a mod +#[utoipa::path( + post, + path = "/v1/mods/{id}/deprecations", + tag = "deprecations", + params(ModPath), + request_body = CreateDeprecationData, + responses( + (status = 201, description = "Deprecation created", body = inline(ApiResponse)), + (status = 400, description = "Bad request"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Mod not found") + ), + security( + ("bearer_token" = []) + ) +)] +#[post("v1/mods/{id}/deprecations")] +#[tracing::instrument(skip_all, fields(mod_id = %path.id))] +pub async fn store( + data: web::Data, + path: web::Path, + json: web::Json, + auth: Auth, +) -> Result { + let dev = auth.developer()?; + let mut pool = data.db().acquire().await?; + let mut tx = pool.begin().await?; + + if !mods::exists(&path.id, &mut tx).await? { + return Err(ApiError::NotFound(format!("Mod id {} not found", path.id))); + } + + if !dev.admin && !developers::owns_mod(dev.id, &path.id, &mut tx).await? { + return Err(ApiError::Authorization); + } + + check_existing_mods(&json.by, &mut tx).await?; + + let deprecation = deprecations::create(&path.id, &json.by, &json.reason, &dev, &mut tx).await?; + + tx.commit().await?; + Ok(HttpResponse::Created().json(ApiResponse { + error: "".into(), + payload: deprecation, + })) +} + +/// Update a deprecation +#[utoipa::path( + put, + path = "/v1/mods/{id}/deprecations/{deprecation_id}", + tag = "deprecations", + params(ModDeprecationPath), + request_body = UpdateDeprecationData, + responses( + (status = 200, description = "Deprecation updated", body = inline(ApiResponse)), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Deprecation not found") + ), + security( + ("bearer_token" = []) + ) +)] +#[put("v1/mods/{id}/deprecations/{deprecation_id}")] +#[tracing::instrument(skip_all, fields(mod_id = %path.id, deprecation_id = %path.deprecation_id))] +pub async fn update( + data: web::Data, + path: web::Path, + json: web::Json, + auth: Auth, +) -> Result { + let dev = auth.developer()?; + let mut pool = data.db().acquire().await?; + let mut tx = pool.begin().await?; + + if !mods::exists(&path.id, &mut tx).await? { + return Err(ApiError::NotFound(format!("Mod id {} not found", path.id))); + } + + if let Some(by) = &json.by { + check_existing_mods(by, &mut tx).await?; + } + + let deprecation = deprecations::get(path.deprecation_id, &mut tx) + .await? + .ok_or(ApiError::NotFound(format!( + "Deprecation id {} not found", + path.deprecation_id + )))?; + + // If the ID doesn't match, just ignore it + if deprecation.mod_id != path.id { + return Err(ApiError::NotFound(format!( + "Deprecation id {} not found", + path.deprecation_id + ))); + } + + if !dev.admin && !developers::owns_mod(dev.id, &path.id, &mut tx).await? { + return Err(ApiError::Authorization); + } + + let updated = deprecations::update( + deprecation, + json.by.as_deref(), + json.reason.as_deref(), + &dev, + &mut tx, + ) + .await?; + + tx.commit().await?; + Ok(HttpResponse::Ok().json(ApiResponse { + error: "".into(), + payload: updated, + })) +} + +/// Delete a deprecation +#[utoipa::path( + delete, + path = "/v1/mods/{id}/deprecations/{deprecation_id}", + tag = "deprecations", + params(ModDeprecationPath), + responses( + (status = 204, description = "Deprecation deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Deprecation not found") + ), + security( + ("bearer_token" = []) + ) +)] +#[delete("v1/mods/{id}/deprecations/{deprecation_id}")] +#[tracing::instrument(skip_all, fields(mod_id = %path.id, deprecation_id = %path.deprecation_id))] +pub async fn delete( + data: web::Data, + path: web::Path, + auth: Auth, +) -> Result { + let dev = auth.developer()?; + let mut pool = data.db().acquire().await?; + + if !mods::exists(&path.id, &mut pool).await? { + return Err(ApiError::NotFound(format!("Mod id {} not found", path.id))); + } + + let deprecation = deprecations::get(path.deprecation_id, &mut pool) + .await? + .ok_or(ApiError::NotFound(format!( + "Deprecation id {} not found", + path.deprecation_id + )))?; + + // If the ID doesn't match, just ignore it + if deprecation.mod_id != path.id { + return Err(ApiError::NotFound(format!( + "Deprecation id {} not found", + path.deprecation_id + ))); + } + + if !dev.admin && !developers::owns_mod(dev.id, &path.id, &mut pool).await? { + return Err(ApiError::Authorization); + } + + deprecations::delete(deprecation.id, &mut pool).await?; + + Ok(HttpResponse::NoContent()) +} + +/// Delete all deprecations for a mod +#[utoipa::path( + delete, + path = "/v1/mods/{id}/deprecations", + tag = "deprecations", + params(ModPath), + responses( + (status = 204, description = "All deprecations deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Mod not found") + ), + security( + ("bearer_token" = []) + ) +)] +#[delete("v1/mods/{id}/deprecations")] +#[tracing::instrument(skip_all, fields(mod_id = %path.id))] +pub async fn clear_all( + data: web::Data, + path: web::Path, + auth: Auth, +) -> Result { + let dev = auth.developer()?; + let mut pool = data.db().acquire().await?; + + if !mods::exists(&path.id, &mut pool).await? { + return Err(ApiError::NotFound(format!("Mod id {} not found", path.id))); + } + + if !dev.admin && !developers::owns_mod(dev.id, &path.id, &mut pool).await? { + return Err(ApiError::Authorization); + } + + deprecations::clear_all(&path.id, &mut pool).await?; + + Ok(HttpResponse::NoContent()) +} + +async fn check_existing_mods(ids: &[String], conn: &mut PgConnection) -> Result<(), ApiError> { + if ids.len() > MAX_MODS_PER_DEPRECATION { + return Err(ApiError::BadRequest(format!( + "Max {} mods allowed per deprecation", + MAX_MODS_PER_DEPRECATION + ))); + } + + let (_, missing) = mods::exists_multiple(ids, &mut *conn).await?; + + if !missing.is_empty() { + return Err(ApiError::BadRequest(format!( + "The following mods don't exist on the index: {}", + missing.join(", ") + ))); + } + + Ok(()) +} diff --git a/src/endpoints/developers.rs b/src/endpoints/developers.rs index da80cabb..670fc8bc 100644 --- a/src/endpoints/developers.rs +++ b/src/endpoints/developers.rs @@ -1,19 +1,18 @@ -use actix_web::{delete, get, post, put, web, HttpResponse, Responder}; +use actix_web::{HttpResponse, Responder, delete, get, post, put, web}; use serde::{Deserialize, Serialize}; -use utoipa::{ToSchema, IntoParams}; +use utoipa::{IntoParams, ToSchema}; use super::ApiError; use crate::config::AppData; use crate::database::repository::{auth_tokens, developers, mods, refresh_tokens}; use crate::types::api::{ApiResponse, PaginatedData}; +use crate::types::models::developer::SelfDeveloper; use crate::{ extractors::auth::Auth, - types::{ - models::{ - developer::{ModDeveloper, Developer}, - mod_entity::Mod, - mod_version_status::ModVersionStatusEnum, - }, + types::models::{ + developer::{Developer, ModDeveloper}, + mod_entity::Mod, + mod_version_status::ModVersionStatusEnum, }, }; @@ -81,6 +80,7 @@ struct DeveloperIndexQuery { ) )] #[get("v1/developers")] +#[tracing::instrument(skip_all)] pub async fn developer_index( data: web::Data, query: web::Query, @@ -117,6 +117,7 @@ pub async fn developer_index( ) )] #[post("v1/mods/{id}/developers")] +#[tracing::instrument(skip_all, fields(mod_id = %path.id, username = %json.username))] pub async fn add_developer_to_mod( data: web::Data, path: web::Path, @@ -163,6 +164,7 @@ pub async fn add_developer_to_mod( ) )] #[delete("v1/mods/{id}/developers/{username}")] +#[tracing::instrument(skip_all, fields(mod_id = %path.id, username = %path.username))] pub async fn remove_dev_from_mod( data: web::Data, path: web::Path, @@ -219,6 +221,7 @@ pub async fn remove_dev_from_mod( ) )] #[delete("v1/me/token")] +#[tracing::instrument(skip_all)] pub async fn delete_token( data: web::Data, auth: Auth, @@ -245,6 +248,7 @@ pub async fn delete_token( ) )] #[delete("v1/me/tokens")] +#[tracing::instrument(skip_all)] pub async fn delete_tokens( data: web::Data, auth: Auth, @@ -279,6 +283,7 @@ struct UploadProfilePayload { ) )] #[put("v1/me")] +#[tracing::instrument(skip_all)] pub async fn update_profile( data: web::Data, json: web::Json, @@ -336,6 +341,7 @@ pub fn default_own_mods_status() -> ModVersionStatusEnum { ) )] #[get("v1/me/mods")] +#[tracing::instrument(skip_all)] pub async fn get_own_mods( data: web::Data, query: web::Query, @@ -357,7 +363,7 @@ pub async fn get_own_mods( path = "/v1/me", tag = "developers", responses( - (status = 200, description = "Current developer profile", body = inline(ApiResponse)), + (status = 200, description = "Current developer profile", body = inline(ApiResponse)), (status = 401, description = "Unauthorized") ), security( @@ -365,11 +371,16 @@ pub async fn get_own_mods( ) )] #[get("v1/me")] -pub async fn get_me(auth: Auth) -> Result { +#[tracing::instrument(skip_all)] +pub async fn get_me(auth: Auth, data: web::Data) -> Result { + let mut pool = data.db().acquire().await?; let dev = auth.developer()?; + + let has_accepted_mod = developers::has_accepted_mod(dev.id, &mut pool).await?; + Ok(HttpResponse::Ok().json(ApiResponse { error: "".to_string(), - payload: dev, + payload: dev.to_self_developer(has_accepted_mod), })) } @@ -390,6 +401,7 @@ struct GetDeveloperPath { ) )] #[get("v1/developers/{id}")] +#[tracing::instrument(skip_all, fields(developer_id = %path.id))] pub async fn get_developer( data: web::Data, path: web::Path, @@ -424,6 +436,7 @@ pub async fn get_developer( ) )] #[put("v1/developers/{id}")] +#[tracing::instrument(skip_all, fields(developer_id = %path.id))] pub async fn update_developer( auth: Auth, data: web::Data, diff --git a/src/endpoints/health.rs b/src/endpoints/health.rs index 5f45011e..b97aec9f 100644 --- a/src/endpoints/health.rs +++ b/src/endpoints/health.rs @@ -1,4 +1,4 @@ -use actix_web::get; +use actix_web::route; /// Health check endpoint #[utoipa::path( @@ -9,7 +9,8 @@ use actix_web::get; (status = 200, description = "Service is healthy", body = String, content_type = "text/plain") ) )] -#[get("/")] +#[route("/", method = "GET", method = "HEAD")] +#[tracing::instrument(skip_all)] pub async fn health() -> &'static str { r#" _____ _____ diff --git a/src/endpoints/loader.rs b/src/endpoints/loader.rs index ef875ef0..34345668 100644 --- a/src/endpoints/loader.rs +++ b/src/endpoints/loader.rs @@ -1,7 +1,7 @@ -use actix_web::{get, post, web, HttpResponse, Responder}; +use actix_web::{HttpResponse, Responder, get, post, web}; use serde::Deserialize; use std::str::FromStr; -use utoipa::{ToSchema, IntoParams}; +use utoipa::{IntoParams, ToSchema}; use sqlx::Acquire; @@ -44,6 +44,7 @@ struct GetOnePath { ) )] #[get("v1/loader/versions/{version}")] +#[tracing::instrument(skip_all, fields(version = %path.version))] pub async fn get_one( path: web::Path, data: web::Data, @@ -108,6 +109,7 @@ struct CreateVersionBody { ) )] #[post("v1/loader/versions")] +#[tracing::instrument(skip_all, fields(tag = %payload.tag))] pub async fn create_version( data: web::Data, payload: web::Json, @@ -160,6 +162,7 @@ struct GetManyQuery { ) )] #[get("v1/loader/versions")] +#[tracing::instrument(skip_all)] pub async fn get_many( data: web::Data, query: web::Query, diff --git a/src/endpoints/mod.rs b/src/endpoints/mod.rs index edb5be74..581fd26a 100644 --- a/src/endpoints/mod.rs +++ b/src/endpoints/mod.rs @@ -2,24 +2,25 @@ use crate::{ mod_zip::ModZipError, types::{api::ApiResponse, models::mod_gd_version::PlatformParseError}, }; -use actix_web::{http::StatusCode, HttpResponse}; +use actix_web::{HttpResponse, http::StatusCode}; pub mod auth; +pub mod deprecations; pub mod developers; pub mod health; pub mod loader; -pub mod mod_versions; pub mod mod_status_badge; +pub mod mod_version_submissions; +pub mod mod_versions; pub mod mods; pub mod stats; pub mod tags; -pub mod deprecations; #[derive(thiserror::Error, Debug)] pub enum ApiError { #[error("Authentication error: {0}")] Authentication(#[from] crate::auth::AuthenticationError), - #[error("You do not have acces to this resource")] + #[error("You do not have access to this resource")] Authorization, #[error("{0}")] Database(#[from] crate::database::DatabaseError), @@ -43,6 +44,8 @@ pub enum ApiError { Zip(#[from] zip::result::ZipError), #[error("Failed to contact external resource: {0}")] Reqwest(#[from] reqwest::Error), + #[error("I/O error: {0}")] + IO(#[from] std::io::Error), } impl ApiError { @@ -62,6 +65,7 @@ impl actix_web::ResponseError for ApiError { ApiError::Json(..) => StatusCode::BAD_REQUEST, ApiError::TooManyRequests(..) => StatusCode::TOO_MANY_REQUESTS, ApiError::NotFound(_) => StatusCode::NOT_FOUND, + ApiError::BadRequest(..) => StatusCode::BAD_REQUEST, _ => StatusCode::INTERNAL_SERVER_ERROR, } } diff --git a/src/endpoints/mod_status_badge.rs b/src/endpoints/mod_status_badge.rs index 7183e971..75f1778b 100644 --- a/src/endpoints/mod_status_badge.rs +++ b/src/endpoints/mod_status_badge.rs @@ -39,6 +39,7 @@ pub struct StatusBadgeQuery { ) )] #[get("/v1/mods/{id}/status_badge")] +#[tracing::instrument(skip_all, fields(mod_id = %id))] pub async fn status_badge( data: web::Data, id: web::Path, @@ -66,14 +67,16 @@ pub async fn status_badge( "shields/mod_downloads.svg", ), }; - let svg = data - .static_storage() - .read(svg_path) - .await - .map_err(|_| ApiError::InternalError("Failed to read status badge file".into()))?; + let svg = data.static_storage().read(svg_path).await.map_err(|e| { + tracing::error!(error = ?e, "failed to read status badge file"); + ApiError::InternalError("Failed to read status badge file".into()) + })?; let api_url = format!("{}/v1/mods/{}?abbreviate=true", data.app_url(), id); let mod_link = format!("{}/mods/{}", data.front_url(), id); - let svg_data_url = format!("data:image/svg+xml;utf8,{}", urlencoding::encode_binary(&svg)); + let svg_data_url = format!( + "data:image/svg+xml;utf8,{}", + urlencoding::encode_binary(&svg) + ); let shields_url = format!( "https://img.shields.io/badge/dynamic/json?url={}&query={}&label={}&labelColor={}&color={}&link={}&style=plastic&logo={}", urlencoding::encode(&api_url), diff --git a/src/endpoints/mod_version_submissions.rs b/src/endpoints/mod_version_submissions.rs new file mode 100644 index 00000000..630b2084 --- /dev/null +++ b/src/endpoints/mod_version_submissions.rs @@ -0,0 +1,1002 @@ +use super::ApiError; +use crate::config::AppData; +use crate::database::DatabaseError; +use crate::database::repository::{developers, mod_version_submissions, mod_versions, mods}; +use crate::events::thread_comment::NewThreadComment; +use crate::extractors::auth::Auth; +use crate::storage::StorageDisk; +use crate::types::api::{ApiResponse, PaginatedData}; +use crate::types::models::audit_actions::{AuditAction, AuditActionRow}; +use crate::types::models::developer::Developer; +use crate::types::models::mod_version_status::ModVersionStatusEnum; +use crate::types::models::mod_version_submission::{ + CreateCommentPayload, ModVersionSubmission, ModVersionSubmissionAttachment, + ModVersionSubmissionComment, ModVersionSubmissionLock, UpdateCommentPayload, + UpdateSubmissionPayload, +}; +use crate::webhook::discord::DiscordWebhook; +use actix_multipart::Multipart; +use actix_web::{HttpResponse, Responder, delete, get, post, put, web}; +use futures::StreamExt; +use serde::Deserialize; +use sqlx::{Acquire, PgConnection}; +use std::collections::HashMap; +use tracing::error; +use utoipa::{IntoParams, ToSchema}; + +fn sanitize_comment(raw: &str) -> String { + ammonia::Builder::default() + .tags(std::collections::HashSet::new()) + .clean(raw) + .to_string() + .trim() + .to_string() +} + +#[derive(Deserialize, IntoParams)] +struct SubmissionPath { + id: String, + version: String, +} + +#[derive(Deserialize, IntoParams)] +struct CommentPath { + id: String, + version: String, + comment_id: i64, +} + +#[derive(Deserialize, IntoParams)] +struct AttachmentPath { + id: String, + version: String, + comment_id: i64, + attachment_id: i64, +} + +#[derive(Deserialize, IntoParams)] +struct CommentsQuery { + page: Option, + per_page: Option, +} + +async fn resolve_version_id( + mod_id: &str, + version: &str, + pool: &mut PgConnection, +) -> Result { + let ver = mod_versions::get_by_version_str(mod_id, version, pool) + .await? + .ok_or_else(|| ApiError::NotFound(format!("Version {} not found", version)))?; + Ok(ver.id) +} + +async fn check_submission_lock( + dev: &Developer, + mod_id: &str, + lock_status: ModVersionSubmissionLock, + conn: &mut PgConnection, +) -> Result { + if dev.admin { + return Ok(true); + } + + let access_to_mod = developers::has_access_to_mod(dev.id, mod_id, &mut *conn).await?; + let active_developer = developers::has_active_mod(dev.id, &mut *conn).await?; + + Ok(match lock_status { + ModVersionSubmissionLock::None => access_to_mod || active_developer, + ModVersionSubmissionLock::Internal => access_to_mod, + ModVersionSubmissionLock::Locked => false, + }) +} + +/// Get the submission for a mod version, or create one if the mod version is pending and has no submission +#[utoipa::path( + get, + path = "/v1/mods/{id}/versions/{version}/submission", + tag = "mod_version_submissions", + params(SubmissionPath), + responses( + (status = 200, description = "Submission details", body = inline(ApiResponse)), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Mod, version, or submission not found"), + ), + security(("bearer_token" = [])) +)] +#[get("v1/mods/{id}/versions/{version}/submission")] +#[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version))] +pub async fn get_submission( + path: web::Path, + data: web::Data, +) -> Result { + let mut pool = data.db().acquire().await?; + + let mut tx = pool.begin().await?; + + if !mods::exists(&path.id, &mut tx).await? { + return Err(ApiError::NotFound(format!("Mod {} not found", path.id))); + } + + let version = mod_versions::get_by_version_str(&path.id, &path.version, &mut tx) + .await? + .ok_or_else(|| ApiError::NotFound("Version doesn't exist".into()))?; + + let mut row = mod_version_submissions::get_for_mod_version(version.id, &mut tx).await?; + + if let None = row + && version.status == ModVersionStatusEnum::Pending + { + row = Some(mod_version_submissions::create(version.id, &mut tx).await?); + } + + let row = row.ok_or(ApiError::NotFound("Submission not found".into()))?; + + let locked_by = match row.locked_by { + Some(dev_id) => Some( + developers::get_one(dev_id, &mut tx) + .await? + .ok_or_else(|| ApiError::InternalError("Locked-by developer not found".into()))?, + ), + None => None, + }; + + tx.commit().await?; + + Ok(web::Json(ApiResponse { + error: "".into(), + payload: row.into_submission(locked_by), + })) +} + +#[utoipa::path( + path = "/v1/mods/{id}/versions/{version}/submission/audit", + tag = "mod_version_submissions", + params(SubmissionPath), + responses( + (status = 200, description = "Submission audit", body = inline(ApiResponse>)), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - admin only"), + (status = 404, description = "Mod, version, or submission not found"), + ), + security(("bearer_token" = [])) +)] +#[get("v1/mods/{id}/versions/{version}/submission/audit")] +#[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version))] +pub async fn get_submission_audit( + path: web::Path, + data: web::Data, + auth: Auth, +) -> Result { + auth.check_admin()?; + + let mut pool = data.db().acquire().await?; + + if !mods::exists(&path.id, &mut pool).await? { + return Err(ApiError::NotFound(format!("Mod {} not found", path.id))); + } + + let version_id = resolve_version_id(&path.id, &path.version, &mut pool).await?; + + let row = mod_version_submissions::get_for_mod_version(version_id, &mut pool) + .await? + .ok_or_else(|| ApiError::NotFound("Submission not found".into()))?; + + let audit = + mod_version_submissions::get_audit_for_submission(row.mod_version_id, &mut pool).await?; + + Ok(web::Json(ApiResponse { + error: "".into(), + payload: audit, + })) +} + +/// Update (lock / unlock) a submission (admin only) +#[utoipa::path( + put, + path = "/v1/mods/{id}/versions/{version}/submission", + tag = "mod_version_submissions", + params(SubmissionPath), + request_body = UpdateSubmissionPayload, + responses( + (status = 200, description = "Submission updated", body = inline(ApiResponse)), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Mod, version, or submission not found"), + ), + security(("bearer_token" = [])) +)] +#[put("v1/mods/{id}/versions/{version}/submission")] +#[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version))] +pub async fn update_submission( + path: web::Path, + data: web::Data, + payload: web::Json, + auth: Auth, +) -> Result { + let dev = auth.developer()?; + auth.check_admin()?; + let mut pool = data.db().acquire().await?; + + let mut tx = pool.begin().await?; + + if !mods::exists(&path.id, &mut tx).await? { + return Err(ApiError::NotFound(format!("Mod {} not found", path.id))); + } + + let version_id = resolve_version_id(&path.id, &path.version, &mut tx).await?; + + mod_version_submissions::get_for_mod_version(version_id, &mut tx) + .await? + .ok_or_else(|| ApiError::NotFound("Submission not found".into()))?; + + let locked_by_id = if payload.lock != ModVersionSubmissionLock::None { + Some(dev.id) + } else { + None + }; + + let row = mod_version_submissions::set_locked(version_id, payload.lock, locked_by_id, &mut tx) + .await?; + + tx.commit().await?; + + let locked_by = match payload.lock { + ModVersionSubmissionLock::None => None, + _ => Some(dev.clone()), + }; + + Ok(web::Json(ApiResponse { + error: "".into(), + payload: row.into_submission(locked_by), + })) +} + +/// List comments for a mod version submission +#[utoipa::path( + get, + path = "/v1/mods/{id}/versions/{version}/submission/comments", + tag = "mod_version_submissions", + params(SubmissionPath, CommentsQuery), + responses( + (status = 200, description = "List of comments", body = inline(ApiResponse>)), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Mod, version, or submission not found"), + ), + security(("bearer_token" = [])) +)] +#[get("v1/mods/{id}/versions/{version}/submission/comments")] +#[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version))] +pub async fn get_comments( + path: web::Path, + data: web::Data, + query: web::Query, +) -> Result { + let mut pool = data.db().acquire().await?; + + if !mods::exists(&path.id, &mut pool).await? { + return Err(ApiError::NotFound(format!("Mod {} not found", path.id))); + } + + let version_id = resolve_version_id(&path.id, &path.version, &mut pool).await?; + + mod_version_submissions::get_for_mod_version(version_id, &mut pool) + .await? + .ok_or_else(|| ApiError::NotFound("Submission not found".into()))?; + + let page = query.page.unwrap_or(1).max(1); + let per_page = query.per_page.unwrap_or(20).clamp(1, 100); + + let count = + mod_version_submissions::count_comments_for_submission(version_id, &mut pool).await?; + let rows = mod_version_submissions::get_paginated_comments_for_submission( + version_id, page, per_page, &mut pool, + ) + .await?; + + let author_ids: Vec = { + let mut ids: Vec = rows.iter().map(|r| r.author_id).collect(); + ids.sort_unstable(); + ids.dedup(); + ids + }; + + let comment_ids = rows.iter().map(|i| i.id).collect::>(); + + let authors_map = developers::get_many_by_id(&author_ids, &mut pool) + .await? + .into_iter() + .map(|dev| (dev.id, dev)) + .collect::>(); + + let mut attachments_map = + mod_version_submissions::get_attachments_for_comments(&comment_ids, &mut pool) + .await? + .into_iter() + .map(|(k, v)| { + let transformed = v + .into_iter() + .map(|i| (i.id, data.public_storage().asset_url(&i.filename))) + .collect(); + (k, transformed) + }) + .collect::>>(); + + let comments = rows + .into_iter() + .map(|row| { + let author = authors_map + .get(&row.author_id) + .cloned() + .ok_or_else(|| ApiError::InternalError("Author not found".into()))?; + + let id = row.id; + + Ok(row.into_comment(author, attachments_map.remove(&id).unwrap_or_default())) + }) + .collect::, ApiError>>()?; + + Ok(web::Json(ApiResponse { + error: "".into(), + payload: PaginatedData { + data: comments, + count, + }, + })) +} + +#[utoipa::path( + path = "/v1/mods/{id}/versions/{version}/submission/comments/{comment_id}/audit", + tag = "mod_version_submissions", + params(CommentPath), + responses( + (status = 200, description = "Comment audit", body = inline(ApiResponse>)), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden - admin only"), + (status = 404, description = "Mod, version, submission or comment not found"), + ), + security(("bearer_token" = [])) +)] +#[get("v1/mods/{id}/versions/{version}/submission/comments/{comment_id}/audit")] +#[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version, comment_id = %path.comment_id))] +pub async fn get_comment_audit( + path: web::Path, + data: web::Data, + auth: Auth, +) -> Result { + auth.check_admin()?; + + let mut pool = data.db().acquire().await?; + + if !mods::exists(&path.id, &mut pool).await? { + return Err(ApiError::NotFound(format!("Mod {} not found", path.id))); + } + + let version_id = resolve_version_id(&path.id, &path.version, &mut pool).await?; + + let row = mod_version_submissions::get_for_mod_version(version_id, &mut pool) + .await? + .ok_or_else(|| ApiError::NotFound("Submission not found".into()))?; + + let comment = mod_version_submissions::get_comment(path.comment_id, &mut pool) + .await? + .ok_or_else(|| ApiError::NotFound("Comment not found".into()))?; + + if comment.submission_id != row.mod_version_id { + return Err(ApiError::NotFound("Comment not found".into())); + } + + let audit = mod_version_submissions::get_audit_for_comment(comment.id, &mut pool).await?; + + Ok(web::Json(ApiResponse { + error: "".into(), + payload: audit, + })) +} + +/// Add a comment to a mod version submission +#[utoipa::path( + post, + path = "/v1/mods/{id}/versions/{version}/submission/comments", + tag = "mod_version_submissions", + params(SubmissionPath), + request_body = CreateCommentPayload, + responses( + (status = 201, description = "Comment created", body = inline(ApiResponse)), + (status = 400, description = "Bad request - locked submission or empty comment"), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Mod, version, or submission not found"), + ), + security(("bearer_token" = [])) +)] +#[post("v1/mods/{id}/versions/{version}/submission/comments")] +#[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version))] +pub async fn create_comment( + path: web::Path, + data: web::Data, + payload: web::Json, + auth: Auth, +) -> Result { + let dev = auth.developer()?; + + let comment_text = sanitize_comment(&payload.comment); + if comment_text.is_empty() { + return Err(ApiError::BadRequest("Comment must not be empty".into())); + } + if comment_text.len() > 1000 { + return Err(ApiError::BadRequest( + "Comment must not exceed 1000 characters".into(), + )); + } + + let mut pool = data.db().acquire().await?; + let mut tx = pool.begin().await?; + + if !mods::exists(&path.id, &mut tx).await? { + return Err(ApiError::NotFound(format!("Mod {} not found", path.id))); + } + + let version_id = resolve_version_id(&path.id, &path.version, &mut tx).await?; + + let submission = mod_version_submissions::get_for_mod_version(version_id, &mut tx) + .await? + .ok_or_else(|| ApiError::NotFound("Submission not found".into()))?; + + if !check_submission_lock(&dev, &path.id, submission.lock, &mut tx).await? { + return Err(ApiError::Authorization); + } + + let row = + mod_version_submissions::create_comment(version_id, dev.id, &comment_text, &mut tx).await?; + + mod_version_submissions::insert_comment_audit( + row.id, + AuditAction::Created, + None, + Some(dev.id), + &mut tx, + ) + .await?; + + tx.commit().await?; + + if !data.index_admin_webhook_url().is_empty() { + NewThreadComment { + mod_id: path.id.clone(), + mod_version: path.version.clone(), + username: dev.username.clone(), + } + .to_discord_webhook() + .send(data.index_admin_webhook_url()); + } + + Ok(HttpResponse::Created().json(ApiResponse { + error: "".into(), + payload: row.into_comment(dev, vec![]), + })) +} + +/// Update a comment on a mod version submission +#[utoipa::path( + put, + path = "/v1/mods/{id}/versions/{version}/submission/comments/{comment_id}", + tag = "mod_version_submissions", + params(CommentPath), + request_body = UpdateCommentPayload, + responses( + (status = 200, description = "Comment updated", body = inline(ApiResponse)), + (status = 400, description = "Bad request – locked submission or empty comment"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden – may only edit own comments (admins can edit any)"), + (status = 404, description = "Mod, version, submission, or comment not found"), + ), + security(("bearer_token" = [])) +)] +#[put("v1/mods/{id}/versions/{version}/submission/comments/{comment_id}")] +#[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version, comment_id = %path.comment_id))] +pub async fn update_comment( + path: web::Path, + data: web::Data, + payload: web::Json, + auth: Auth, +) -> Result { + let dev = auth.developer()?; + + let comment_text = sanitize_comment(&payload.comment); + if comment_text.is_empty() { + return Err(ApiError::BadRequest("Comment must not be empty".into())); + } + if comment_text.len() > 1000 { + return Err(ApiError::BadRequest( + "Comment must not exceed 1000 characters".into(), + )); + } + + let mut pool = data.db().acquire().await?; + let mut tx = pool.begin().await?; + + if !mods::exists(&path.id, &mut tx).await? { + return Err(ApiError::NotFound(format!("Mod {} not found", path.id))); + } + + let version_id = resolve_version_id(&path.id, &path.version, &mut tx).await?; + + let submission = mod_version_submissions::get_for_mod_version(version_id, &mut tx) + .await? + .ok_or_else(|| ApiError::NotFound("Submission not found".into()))?; + + if !check_submission_lock(&dev, &path.id, submission.lock, &mut tx).await? { + return Err(ApiError::Authorization); + } + + let comment_row = mod_version_submissions::get_comment(path.comment_id, &mut tx) + .await? + .ok_or_else(|| ApiError::NotFound(format!("Comment {} not found", path.comment_id)))?; + + if comment_row.submission_id != version_id { + return Err(ApiError::NotFound(format!( + "Comment {} does not belong to this submission", + path.comment_id + ))); + } + + if comment_row.author_id != dev.id { + return Err(ApiError::Authorization); + } + + mod_version_submissions::insert_comment_audit( + comment_row.id, + AuditAction::Updated, + Some(&format!( + "Updated comment. Previously: {}", + comment_row.comment + )), + Some(dev.id), + &mut tx, + ) + .await?; + + let updated_row = + mod_version_submissions::update_comment(path.comment_id, &comment_text, &mut tx).await?; + + let author = developers::get_one(updated_row.author_id, &mut tx) + .await? + .ok_or_else(|| ApiError::InternalError("Author not found".into()))?; + + let attachments = + mod_version_submissions::get_attachments_for_comment(path.comment_id, &mut tx) + .await? + .into_iter() + .map(|i| (i.id, data.public_storage().asset_url(&i.filename))) + .collect::>(); + + tx.commit().await?; + + Ok(web::Json(ApiResponse { + error: "".into(), + payload: updated_row.into_comment(author, attachments), + })) +} + +/// Delete a comment on a mod version submission +#[utoipa::path( + delete, + path = "/v1/mods/{id}/versions/{version}/submission/comments/{comment_id}", + tag = "mod_version_submissions", + params(CommentPath), + responses( + (status = 204, description = "Comment deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden – may only delete own comments (admins can delete any)"), + (status = 404, description = "Mod, version, submission, or comment not found"), + ), + security(("bearer_token" = [])) +)] +#[delete("v1/mods/{id}/versions/{version}/submission/comments/{comment_id}")] +#[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version, comment_id = %path.comment_id))] +pub async fn delete_comment( + path: web::Path, + data: web::Data, + auth: Auth, +) -> Result { + let dev = auth.developer()?; + + let mut pool = data.db().acquire().await?; + + let mut tx = pool.begin().await?; + + if !mods::exists(&path.id, &mut tx).await? { + return Err(ApiError::NotFound(format!("Mod {} not found", path.id))); + } + + let version_id = resolve_version_id(&path.id, &path.version, &mut tx).await?; + + let submission = mod_version_submissions::get_for_mod_version(version_id, &mut tx) + .await? + .ok_or_else(|| ApiError::NotFound("Submission not found".into()))?; + + if !check_submission_lock(&dev, &path.id, submission.lock, &mut tx).await? { + return Err(ApiError::Authorization); + } + + let comment_row = mod_version_submissions::get_comment(path.comment_id, &mut tx) + .await? + .ok_or_else(|| ApiError::NotFound(format!("Comment {} not found", path.comment_id)))?; + + if comment_row.submission_id != version_id { + return Err(ApiError::NotFound("Comment not found".to_string())); + } + + if !dev.admin && comment_row.author_id != dev.id { + return Err(ApiError::Authorization); + } + + let attachment_filenames = + mod_version_submissions::get_attachments_for_comment(comment_row.id, &mut tx) + .await? + .into_iter() + .map(|a| a.filename) + .collect::>(); + + mod_version_submissions::insert_comment_audit( + comment_row.id, + AuditAction::Deleted, + None, + Some(dev.id), + &mut tx, + ) + .await?; + + mod_version_submissions::delete_comment(path.comment_id, &mut tx).await?; + + let references = + mod_version_submissions::count_references_to_filenames(&attachment_filenames, &mut tx) + .await?; + + tx.commit().await?; + + for filename in attachment_filenames { + if !references.contains_key(&filename) + && let Err(e) = data.public_storage().delete(&filename).await + { + tracing::error!("Failed to delete attachment file {filename}: {e}"); + } + } + + Ok(HttpResponse::NoContent()) +} + +/// List attachments for a submission comment +#[utoipa::path( + get, + path = "/v1/mods/{id}/versions/{version}/submission/comments/{comment_id}/attachments", + tag = "mod_version_submissions", + params(CommentPath), + responses( + (status = 200, description = "List of attachments", body = inline(ApiResponse>)), + (status = 401, description = "Unauthorized"), + (status = 404, description = "Mod, version, submission, or comment not found"), + ), + security(("bearer_token" = [])) +)] +#[get("v1/mods/{id}/versions/{version}/submission/comments/{comment_id}/attachments")] +#[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version, comment_id = %path.comment_id))] +pub async fn get_attachments( + path: web::Path, + data: web::Data, +) -> Result { + let mut pool = data.db().acquire().await?; + + if !mods::exists(&path.id, &mut pool).await? { + return Err(ApiError::NotFound(format!("Mod {} not found", path.id))); + } + + let version_id = resolve_version_id(&path.id, &path.version, &mut pool).await?; + + mod_version_submissions::get_for_mod_version(version_id, &mut pool) + .await? + .ok_or_else(|| ApiError::NotFound("Submission not found".into()))?; + + let comment_row = mod_version_submissions::get_comment(path.comment_id, &mut pool) + .await? + .ok_or_else(|| ApiError::NotFound(format!("Comment {} not found", path.comment_id)))?; + + if comment_row.submission_id != version_id { + return Err(ApiError::NotFound(format!( + "Comment {} does not belong to this submission", + path.comment_id + ))); + } + + let rows = + mod_version_submissions::get_attachments_for_comment(path.comment_id, &mut pool).await?; + + let app_url = data.app_url().to_string(); + let attachments: Vec = rows + .into_iter() + .map(|r| r.into_attachment(&app_url)) + .collect(); + + Ok(web::Json(ApiResponse { + error: "".into(), + payload: attachments, + })) +} + +/// Documentation-only struct +#[derive(ToSchema)] +#[allow(dead_code)] +pub struct UploadAttachmentsForm { + #[schema(value_type = Vec, format = Binary)] + image: Vec>, +} + +/// Upload attachments to a submission comment +#[utoipa::path( + post, + path = "/v1/mods/{id}/versions/{version}/submission/comments/{comment_id}/attachments", + tag = "mod_version_submissions", + params(CommentPath), + request_body( + content = UploadAttachmentsForm, + content_type = "multipart/form-data" + ), + responses( + (status = 201, description = "Attachments uploaded", body = inline(ApiResponse>)), + (status = 400, description = "Bad request - no images, file too large, or attachment limit exceeded"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Unauthorized (only verified / mod developers / index admins can post attachments)"), + (status = 404, description = "Mod, version, submission, or comment not found"), + ), + security(("bearer_token" = [])) +)] +#[post("v1/mods/{id}/versions/{version}/submission/comments/{comment_id}/attachments")] +#[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version, comment_id = %path.comment_id))] +pub async fn upload_attachments( + path: web::Path, + data: web::Data, + mut multipart: Multipart, + auth: Auth, +) -> Result { + let dev = auth.developer()?; + + let mut pool = data.db().acquire().await?; + + let mut tx = pool.begin().await?; + + if !mods::exists(&path.id, &mut tx).await? { + return Err(ApiError::NotFound(format!("Mod {} not found", path.id))); + } + + let version_id = resolve_version_id(&path.id, &path.version, &mut tx).await?; + + let submission = mod_version_submissions::get_for_mod_version(version_id, &mut tx) + .await? + .ok_or_else(|| ApiError::NotFound("Submission not found".into()))?; + + if !check_submission_lock(&dev, &path.id, submission.lock, &mut tx).await? { + return Err(ApiError::Authorization); + } + + let comment_row = mod_version_submissions::get_comment(path.comment_id, &mut tx) + .await? + .ok_or_else(|| ApiError::NotFound(format!("Comment {} not found", path.comment_id)))?; + + if comment_row.submission_id != version_id { + return Err(ApiError::NotFound("Comment not found".into())); + } + + if comment_row.author_id != dev.id { + return Err(ApiError::Authorization); + } + + let dev_of_mod = developers::has_access_to_mod(dev.id, &path.id, &mut tx).await?; + if !dev.verified && !dev.admin && !dev_of_mod { + return Err(ApiError::Authorization); + } + + let existing = + mod_version_submissions::count_attachments_for_comment(path.comment_id, &mut tx).await?; + + const MAX_ATTACHMENTS_PER_COMMENT: i64 = 5; + + // Collect all `image` fields from the multipart stream + const MAX_BYTES: usize = 5 * 1024 * 1024; + let mut count = 0; + let mut images: Vec = Vec::new(); + while let Some(field) = multipart.next().await { + let mut field = field.map_err(|e| ApiError::BadRequest(e.to_string()))?; + if field.name() != Some("image") { + continue; + } + count += 1; + + if existing + count > MAX_ATTACHMENTS_PER_COMMENT { + return Err(ApiError::BadRequest(format!( + "Comment already has {} attachment(s); adding {} would exceed the limit of 5", + existing, count + ))); + } + let mut buf = bytes::BytesMut::new(); + while let Some(chunk) = field.next().await { + let chunk = chunk.map_err(|e| ApiError::BadRequest(e.to_string()))?; + buf.extend_from_slice(&chunk); + if buf.len() > MAX_BYTES { + return Err(ApiError::BadRequest("Image exceeds {} MB limit".into())); + } + } + images.push(buf.freeze()); + } + + if images.is_empty() { + return Err(ApiError::BadRequest( + "At least one image field is required".into(), + )); + } + + // Decode -> encode WebP -> hash, all in a blocking thread + let processed: Vec> = + tokio::task::spawn_blocking(move || -> Result>, ApiError> { + images + .into_iter() + .map(|raw| { + let img = image::load_from_memory(&raw) + .map_err(|e| ApiError::BadRequest(format!("Invalid image: {e}")))?; + let mut webp_bytes: Vec = Vec::new(); + img.write_to( + &mut std::io::Cursor::new(&mut webp_bytes), + image::ImageFormat::WebP, + ) + .map_err(|e| ApiError::InternalError(format!("WebP encode failed: {e}")))?; + Ok(webp_bytes) + }) + .collect() + }) + .await + .inspect_err(|e| tracing::error!("Bad bad bad bad {e}")) + .map_err(|_| ApiError::InternalError("Something very bad happened!".into()))??; + + let mut result = Vec::with_capacity(processed.len()); + let mut stored_filenames: Vec = Vec::with_capacity(processed.len()); + + let outcome: Result<_, ApiError> = async { + for webp_bytes in &processed { + let filename = data + .public_storage() + .store_hashed_with_extension("submission-attachments", webp_bytes, Some("webp")) + .await?; + stored_filenames.push(filename.clone()); + let row = + mod_version_submissions::create_attachment(path.comment_id, &filename, &mut tx) + .await?; + result.push(row.into_attachment(data.app_url())); + } + mod_version_submissions::insert_comment_audit( + comment_row.id, + AuditAction::Updated, + Some(&format!( + "Attached {} file{}", + result.len(), + if result.len() > 1 || result.is_empty() { + "s" + } else { + "" + } + )), + Some(dev.id), + &mut tx, + ) + .await?; + tx.commit().await?; + Ok(()) + } + .await; + + if let Err(e) = outcome { + for filename in &stored_filenames { + let _ = data.public_storage().delete(filename).await; + } + return Err(e); + } + + Ok(HttpResponse::Created().json(ApiResponse { + error: "".into(), + payload: result, + })) +} + +/// Delete an attachment from a submission comment +#[utoipa::path( + delete, + path = "/v1/mods/{id}/versions/{version}/submission/comments/{comment_id}/attachments/{attachment_id}", + tag = "mod_version_submissions", + params(AttachmentPath), + responses( + (status = 204, description = "Attachment deleted"), + (status = 401, description = "Unauthorized"), + (status = 403, description = "Forbidden"), + (status = 404, description = "Mod, version, submission, comment, or attachment not found"), + ), + security(("bearer_token" = [])) +)] +#[delete( + "v1/mods/{id}/versions/{version}/submission/comments/{comment_id}/attachments/{attachment_id}" +)] +#[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version, comment_id = %path.comment_id, attachment_id = %path.attachment_id))] +pub async fn delete_attachment( + path: web::Path, + data: web::Data, + auth: Auth, +) -> Result { + let dev = auth.developer()?; + + let mut pool = data.db().acquire().await?; + + let mut tx = pool.begin().await?; + + if !mods::exists(&path.id, &mut tx).await? { + return Err(ApiError::NotFound(format!("Mod {} not found", path.id))); + } + + let version_id = resolve_version_id(&path.id, &path.version, &mut tx).await?; + + let submission = mod_version_submissions::get_for_mod_version(version_id, &mut tx) + .await? + .ok_or_else(|| ApiError::NotFound("Submission not found".into()))?; + + if !check_submission_lock(&dev, &path.id, submission.lock, &mut tx).await? { + return Err(ApiError::Authorization); + } + + let comment_row = mod_version_submissions::get_comment(path.comment_id, &mut tx) + .await? + .ok_or_else(|| ApiError::NotFound(format!("Comment {} not found", path.comment_id)))?; + + if comment_row.submission_id != version_id { + return Err(ApiError::NotFound("Comment not found".into())); + } + + if !dev.admin && comment_row.author_id != dev.id { + return Err(ApiError::Authorization); + } + + let attachment = mod_version_submissions::get_attachment(path.attachment_id, &mut tx) + .await? + .ok_or_else(|| { + ApiError::NotFound(format!("Attachment {} not found", path.attachment_id)) + })?; + + if attachment.comment_id != path.comment_id { + return Err(ApiError::NotFound(format!( + "Attachment {} does not belong to this comment", + path.attachment_id + ))); + } + + let filename = attachment.filename.clone(); + mod_version_submissions::delete_attachment(path.attachment_id, &mut tx).await?; + mod_version_submissions::insert_comment_audit( + comment_row.id, + AuditAction::Updated, + Some("Removed an attachment"), + Some(dev.id), + &mut tx, + ) + .await?; + + let remaining = + mod_version_submissions::count_references_to_filename(&filename, &mut tx).await?; + + tx.commit().await?; + + if remaining == 0 { + // TODO: implement a cleanup job for orphaned attachment files + // If this fails it's fine to still return a 204 to the user + let r = data.public_storage().delete(&filename).await; + if let Err(e) = r { + error!("Failed to delete attachment: {e}"); + } + } + + Ok(HttpResponse::NoContent()) +} diff --git a/src/endpoints/mod_versions.rs b/src/endpoints/mod_versions.rs index 487fcc4e..93dd2ab6 100644 --- a/src/endpoints/mod_versions.rs +++ b/src/endpoints/mod_versions.rs @@ -1,27 +1,29 @@ use std::str::FromStr; -use actix_web::{dev::ConnectionInfo, get, post, put, web, HttpResponse, Responder}; +use actix_web::{HttpResponse, Responder, dev::ConnectionInfo, get, post, put, web}; use serde::Deserialize; -use sqlx::{types::ipnetwork::IpNetwork, Acquire}; -use utoipa::{ToSchema, IntoParams}; +use sqlx::{Acquire, types::ipnetwork::IpNetwork}; +use utoipa::{IntoParams, ToSchema}; use crate::config::AppData; use crate::database::repository::{ dependencies, developers, incompatibilities, mod_downloads, mod_gd_versions, mod_links, - mod_tags, mod_versions, mods, + mod_tags, mod_version_submissions, mod_versions, mods, }; use crate::endpoints::ApiError; use crate::events::mod_created::{ NewModAcceptedEvent, NewModVersionAcceptedEvent, NewModVersionVerification, + NewUnverifiedModVersionCreated, }; use crate::mod_zip::{self, download_mod}; use crate::types::models; +use crate::types::models::mod_version_submission::ModVersionSubmissionLock; use crate::webhook::discord::DiscordWebhook; use crate::{ extractors::auth::Auth, types::{ api::ApiResponse, - mod_json::{split_version_and_compare, ModJson}, + mod_json::{ModJson, split_version_and_compare}, models::{ mod_gd_version::{GDVersionEnum, VerPlatform}, mod_version::{self, ModVersion}, @@ -88,6 +90,7 @@ struct IndexQuery { ) )] #[get("v1/mods/{id}/versions")] +#[tracing::instrument(skip_all, fields(mod_id = %path.id))] pub async fn get_version_index( path: web::Path, data: web::Data, @@ -148,6 +151,7 @@ pub async fn get_version_index( ) )] #[get("v1/mods/{id}/versions/{version}")] +#[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version))] pub async fn get_one( path: web::Path, data: web::Data, @@ -210,6 +214,7 @@ struct DownloadQuery { ) )] #[get("v1/mods/{id}/versions/{version}/download")] +#[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version))] pub async fn download_version( path: web::Path, data: web::Data, @@ -291,6 +296,7 @@ pub async fn download_version( ) )] #[post("v1/mods/{id}/versions")] +#[tracing::instrument(skip_all, fields(mod_id = %path))] pub async fn create_version( path: web::Path, data: web::Data, @@ -302,7 +308,7 @@ pub async fn create_version( let id = path.into_inner(); - let the_mod = mods::get_one(&id, false, &mut pool) + let mut the_mod = mods::get_one(&id, false, &mut pool) .await? .ok_or(ApiError::NotFound(format!("Mod {} not found", &id)))?; @@ -342,7 +348,7 @@ pub async fn create_version( let bytes = download_mod(&download_link, data.max_download_mb()).await?; let json = ModJson::from_zip(bytes, &download_link, make_accepted) - .inspect_err(|e| log::error!("Failed to parse mod.json: {e}"))?; + .inspect_err(|e| tracing::error!("Failed to parse mod.json: {e}"))?; if json.id != the_mod.id { return Err(ApiError::BadRequest(format!( "Request id {} does not match mod.json id {}", @@ -366,7 +372,7 @@ pub async fn create_version( } else { let latest = versions.first().unwrap(); let latest_version = semver::Version::parse(&latest.version) - .inspect_err(|e| log::error!("Failed to parse locally stored version: {}", e)) + .inspect_err(|e| tracing::error!("Failed to parse locally stored version: {}", e)) .or(Err(ApiError::InternalError(format!( "Failed to parse semver for existing mod version: {}", &latest.version @@ -427,14 +433,18 @@ pub async fn create_version( ) .await?; } - if let Some(tags) = &json.tags { - if !tags.is_empty() { - let tags = models::tag::parse_tag_list(tags, &the_mod.id, &mut tx).await?; - mod_tags::update_for_mod(&the_mod.id, &tags, &mut tx).await?; - } + if let Some(tags) = &json.tags + && !tags.is_empty() + { + let tags = models::tag::parse_tag_list(tags, &the_mod.id, &mut tx).await?; + mod_tags::update_for_mod(&the_mod.id, &tags, &mut tx).await?; } - mods::update_with_json_moved(the_mod, json, &mut tx).await?; + the_mod = mods::update_with_json_moved(the_mod, json, &mut tx).await?; + } + + if !make_accepted { + mod_version_submissions::create(version.id, &mut tx).await?; } tx.commit().await?; @@ -454,6 +464,15 @@ pub async fn create_version( } .to_discord_webhook() .send(data.webhook_url()); + } else { + NewUnverifiedModVersionCreated { + id: the_mod.id.clone(), + name: version.name.clone(), + version: version.version.clone(), + owner: dev.clone(), + } + .to_discord_webhook() + .send(data.index_admin_webhook_url()); } version.modify_metadata(data.app_url(), false); @@ -483,6 +502,7 @@ pub async fn create_version( ) )] #[put("v1/mods/{id}/versions/{version}")] +#[tracing::instrument(skip_all, fields(mod_id = %path.id, version = %path.version))] pub async fn update_version( path: web::Path, data: web::Data, @@ -572,6 +592,20 @@ pub async fn update_version( mod_tags::update_for_mod(&the_mod.id, &tags, &mut tx).await?; mods::update_with_json_moved(the_mod, json, &mut tx).await?; + + // Let's also maybe lock the thread if it exists! + let thread = mod_version_submissions::get_for_mod_version(version.id, &mut tx).await?; + if let Some(thread) = thread + && thread.lock != ModVersionSubmissionLock::Locked + { + mod_version_submissions::set_locked( + thread.mod_version_id, + ModVersionSubmissionLock::Locked, + None, + &mut tx, + ) + .await?; + } } tx.commit().await?; diff --git a/src/endpoints/mods.rs b/src/endpoints/mods.rs index 9719e6a0..4daa6d5b 100644 --- a/src/endpoints/mods.rs +++ b/src/endpoints/mods.rs @@ -10,8 +10,9 @@ use crate::database::repository::mod_links; use crate::database::repository::mod_tags; use crate::database::repository::mod_versions; use crate::database::repository::mods; -use crate::database::repository::{dependencies, deprecations}; +use crate::database::repository::{dependencies, deprecations, mod_version_submissions}; use crate::endpoints::ApiError; +use crate::events::mod_created::NewUnverifiedModVersionCreated; use crate::events::mod_feature::ModFeaturedEvent; use crate::extractors::auth::Auth; use crate::mod_zip; @@ -28,7 +29,7 @@ use actix_web::{HttpResponse, Responder, get, post, put, web}; use serde::Deserialize; use serde::Serialize; use sqlx::Acquire; -use utoipa::{ToSchema, IntoParams}; +use utoipa::{IntoParams, ToSchema}; #[derive(Deserialize, Default, Hash, Eq, PartialEq, ToSchema)] #[serde(rename_all = "snake_case")] @@ -78,8 +79,8 @@ pub struct CreateQueryParams { (status = 403, description = "Forbidden") ) )] - #[get("/v1/mods")] +#[tracing::instrument(skip_all)] pub async fn index( data: web::Data, query: web::Query, @@ -127,6 +128,7 @@ pub async fn index( ) )] #[get("/v1/mods/{id}")] +#[tracing::instrument(skip_all, fields(mod_id = %id))] pub async fn get( data: web::Data, id: web::Path, @@ -201,6 +203,7 @@ pub async fn get( ) )] #[post("/v1/mods")] +#[tracing::instrument(skip_all)] pub async fn create( data: web::Data, payload: web::Json, @@ -215,7 +218,9 @@ pub async fn create( let existing: Option = mods::get_one(&json.id, false, &mut pool).await?; if json.id.starts_with("geode.") && !dev.admin { - return Err(ApiError::BadRequest("Only index admins may use mod ids that start with 'geode.'".into())); + return Err(ApiError::BadRequest( + "Only index admins may use mod ids that start with 'geode.'".into(), + )); } if let Some(m) = &existing { @@ -278,8 +283,21 @@ pub async fn create( the_mod.developers = developers::get_all_for_mod(&the_mod.id, &mut tx).await?; the_mod.versions.insert(0, version); + // First version is always pending, so always open a submission for review + let first_version = the_mod.versions.first().unwrap(); + mod_version_submissions::create(first_version.id, &mut tx).await?; + tx.commit().await?; + NewUnverifiedModVersionCreated { + id: the_mod.id.clone(), + name: first_version.name.clone(), + version: first_version.version.clone(), + owner: dev.clone(), + } + .to_discord_webhook() + .send(data.index_admin_webhook_url()); + for i in &mut the_mod.versions { i.modify_metadata(data.app_url(), false); } @@ -319,6 +337,7 @@ enum UpdateQueryResponse { ) )] #[get("/v1/mods/updates")] +#[tracing::instrument(skip_all, fields(ids = %query.ids, geode = %query.geode))] pub async fn get_mod_updates( data: web::Data, query: web::Query, @@ -379,6 +398,7 @@ pub async fn get_mod_updates( ) )] #[get("/v1/mods/{id}/logo")] +#[tracing::instrument(skip_all, fields(mod_id = %path))] pub async fn get_logo( data: web::Data, path: web::Path, @@ -418,6 +438,7 @@ struct UpdateModPayload { ) )] #[put("/v1/mods/{id}")] +#[tracing::instrument(skip_all, fields(mod_id = %path))] pub async fn update_mod( data: web::Data, path: web::Path, @@ -443,22 +464,20 @@ pub async fn update_mod( if featured != payload.featured { let item = Mod::get_one(&id, true, &mut pool).await?; - if let Some(item) = item { - if let Some(owner) = developers::get_owner_for_mod(&id, &mut pool).await? { - let first_ver = item.versions.first(); - if let Some(ver) = first_ver { - ModFeaturedEvent { - id: item.id, - name: ver.name.clone(), - owner, - admin: dev, - base_url: data.app_url().to_string(), - featured: payload.featured, - } - .to_discord_webhook() - .send(data.webhook_url()); - } + if let Some(item) = item + && let Some(owner) = developers::get_owner_for_mod(&id, &mut pool).await? + && let Some(ver) = item.versions.first() + { + ModFeaturedEvent { + id: item.id, + name: ver.name.clone(), + owner, + admin: dev, + base_url: data.app_url().to_string(), + featured: payload.featured, } + .to_discord_webhook() + .send(data.webhook_url()); } } diff --git a/src/endpoints/stats.rs b/src/endpoints/stats.rs index 22ecaf01..158636ff 100644 --- a/src/endpoints/stats.rs +++ b/src/endpoints/stats.rs @@ -1,23 +1,24 @@ -use actix_web::{get, web, Responder}; - -use super::ApiError; -use crate::config::AppData; -use crate::types::{api::ApiResponse, models::stats::Stats}; - -/// Get global index statistics -#[utoipa::path( - get, - path = "/v1/stats", - tag = "stats", - responses( - (status = 200, description = "Index statistics", body = inline(ApiResponse)) - ) -)] -#[get("/v1/stats")] -pub async fn get_stats(data: web::Data) -> Result { - let mut pool = data.db().acquire().await?; - Ok(web::Json(ApiResponse { - error: "".into(), - payload: Stats::get_cached(&mut pool).await?, - })) -} +use actix_web::{Responder, get, web}; + +use super::ApiError; +use crate::config::AppData; +use crate::types::{api::ApiResponse, models::stats::Stats}; + +/// Get global index statistics +#[utoipa::path( + get, + path = "/v1/stats", + tag = "stats", + responses( + (status = 200, description = "Index statistics", body = inline(ApiResponse)) + ) +)] +#[get("/v1/stats")] +#[tracing::instrument(skip_all)] +pub async fn get_stats(data: web::Data) -> Result { + let mut pool = data.db().acquire().await?; + Ok(web::Json(ApiResponse { + error: "".into(), + payload: Stats::get_cached(&mut pool).await?, + })) +} diff --git a/src/endpoints/tags.rs b/src/endpoints/tags.rs index 25bd0bfc..234c1e0d 100644 --- a/src/endpoints/tags.rs +++ b/src/endpoints/tags.rs @@ -1,4 +1,4 @@ -use actix_web::{get, web, Responder}; +use actix_web::{Responder, get, web}; use crate::config::AppData; use crate::database::repository::mod_tags; @@ -16,6 +16,7 @@ use crate::types::models::tag::Tag; ) )] #[get("/v1/tags")] +#[tracing::instrument(skip_all)] pub async fn index(data: web::Data) -> Result { let mut pool = data.db().acquire().await?; let tags = mod_tags::get_all_writable(&mut pool) @@ -40,6 +41,7 @@ pub async fn index(data: web::Data) -> Result ) )] #[get("/v1/detailed-tags")] +#[tracing::instrument(skip_all)] pub async fn detailed_index(data: web::Data) -> Result { let mut pool = data.db().acquire().await?; diff --git a/src/events/mod.rs b/src/events/mod.rs index 745b0ae0..2528197e 100644 --- a/src/events/mod.rs +++ b/src/events/mod.rs @@ -1,2 +1,3 @@ pub mod mod_created; -pub mod mod_feature; \ No newline at end of file +pub mod mod_feature; +pub mod thread_comment; diff --git a/src/events/mod_created.rs b/src/events/mod_created.rs index 530b9327..16ff89ac 100644 --- a/src/events/mod_created.rs +++ b/src/events/mod_created.rs @@ -1,6 +1,13 @@ use crate::types::models::developer::Developer; use crate::webhook::discord::{DiscordMessage, DiscordWebhook}; +pub struct NewUnverifiedModVersionCreated { + pub id: String, + pub name: String, + pub version: String, + pub owner: Developer, +} + pub struct NewModAcceptedEvent { pub id: String, pub name: String, @@ -51,7 +58,23 @@ impl DiscordWebhook for NewModVersionAcceptedEvent { "https://geode-sdk.org/mods/{}\n\nOwned by [{}](https://github.com/{})\n{}", self.id, self.owner.display_name, self.owner.username, accepted_msg )), - Some(&format!("{}/v1/mods/{}/logo?version={}", self.base_url, self.id, self.version)), + Some(&format!( + "{}/v1/mods/{}/logo?version={}", + self.base_url, self.id, self.version + )), + ) + } +} + +impl DiscordWebhook for NewUnverifiedModVersionCreated { + fn to_discord_webhook(&self) -> DiscordMessage { + DiscordMessage::new().embed( + &format!("✅ New mod version pending: {} {}", self.name, self.version), + Some(&format!( + "https://geode-sdk.org/mods/{}?version={}\n\nOwned by [{}](https://github.com/{})", + self.id, self.version, self.owner.display_name, self.owner.username + )), + None, ) } } diff --git a/src/events/thread_comment.rs b/src/events/thread_comment.rs new file mode 100644 index 00000000..22b5ec15 --- /dev/null +++ b/src/events/thread_comment.rs @@ -0,0 +1,23 @@ +use crate::webhook::discord::{DiscordMessage, DiscordWebhook}; + +pub struct NewThreadComment { + pub mod_id: String, + pub mod_version: String, + pub username: String, +} + +impl DiscordWebhook for NewThreadComment { + fn to_discord_webhook(&self) -> DiscordMessage { + DiscordMessage::new().embed( + &format!( + "✅ New comment on thread {} v{}", + self.mod_id, self.mod_version, + ), + Some(&format!( + "https://geode-sdk.org/mods/{}?version={}\n\nComment posted by {}", + self.mod_id, self.mod_version, self.username + )), + None, + ) + } +} diff --git a/src/extractors/auth.rs b/src/extractors/auth.rs index 5e845d0a..959142a0 100644 --- a/src/extractors/auth.rs +++ b/src/extractors/auth.rs @@ -6,7 +6,7 @@ use crate::database::repository::developers; use crate::endpoints::ApiError; use crate::types::models::developer::Developer; use actix_web::http::header::HeaderMap; -use actix_web::{web, FromRequest, HttpRequest}; +use actix_web::{FromRequest, HttpRequest, web}; use futures::Future; use uuid::Uuid; diff --git a/src/jobs/cleanup_downloads.rs b/src/jobs/cleanup_downloads.rs index ec9a42a0..c209c36a 100644 --- a/src/jobs/cleanup_downloads.rs +++ b/src/jobs/cleanup_downloads.rs @@ -7,4 +7,3 @@ pub async fn cleanup_downloads(conn: &mut PgConnection) -> Result<(), ApiError> Ok(()) } - diff --git a/src/jobs/migrate.rs b/src/jobs/migrate.rs index 6abfb07e..b65dd793 100644 --- a/src/jobs/migrate.rs +++ b/src/jobs/migrate.rs @@ -2,8 +2,8 @@ use sqlx::PgConnection; pub async fn migrate(pool: &mut PgConnection) -> anyhow::Result<()> { if let Err(e) = sqlx::migrate!("./migrations").run(&mut *pool).await { - log::error!("Error encountered while running migrations: {}", e); + tracing::error!("Error encountered while running migrations: {}", e); } Ok(()) -} \ No newline at end of file +} diff --git a/src/logging.rs b/src/logging.rs new file mode 100644 index 00000000..b21c3c6f --- /dev/null +++ b/src/logging.rs @@ -0,0 +1,37 @@ +use tracing_subscriber::EnvFilter; + +/// Initialize the global `tracing` subscriber. +/// +/// The filter is controlled by the `RUST_LOG` env var, falling back to +/// `"info,sqlx=warn,tracing_actix_web::middleware=error"` when unset. This +/// default is identical for debug and release builds. +/// +/// The output format is controlled by the `LOG_FORMAT` env var: when it is +/// exactly `"json"`, logs are emitted as JSON; otherwise the default +/// compact/full text format is used. +pub fn init() { + const DEFAULT_VALUE: &str = "info,sqlx=warn,tracing_actix_web::middleware=error"; + + let filter = EnvFilter::from(dotenvy::var("RUST_LOG").unwrap_or(DEFAULT_VALUE.into())); + + let is_json = dotenvy::var("LOG_FORMAT") + .map(|v| v == "json") + .unwrap_or(false); + + if is_json { + tracing_subscriber::fmt() + .with_file(true) + .with_line_number(true) + .with_target(true) + .json() + .with_env_filter(filter) + .init(); + } else { + tracing_subscriber::fmt() + .with_file(true) + .with_line_number(true) + .with_target(true) + .with_env_filter(filter) + .init(); + } +} diff --git a/src/main.rs b/src/main.rs index d1bff29f..c21a9094 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,9 +1,9 @@ use crate::openapi::ApiDoc; +use crate::storage::StorageDisk; use crate::types::api; use actix_cors::Cors; use actix_web::{ App, HttpServer, - middleware::Logger, web::{self, QueryConfig}, }; use utoipa::OpenApi; @@ -18,34 +18,36 @@ mod endpoints; mod events; mod extractors; mod jobs; +mod logging; mod mod_zip; mod openapi; +mod storage; mod types; mod webhook; -mod storage; #[tokio::main] async fn main() -> anyhow::Result<()> { - log4rs::init_file("config/log4rs.yaml", Default::default()) - .map_err(|e| e.context("Failed to read log4rs config"))?; + logging::init(); let app_data = config::build_config().await?; + app_data.static_storage().init().await?; + app_data.private_storage().init().await?; if cli::maybe_cli(&app_data).await? { return Ok(()); } - log::info!("Running migrations"); + tracing::info!("Running migrations"); sqlx::migrate!("./migrations").run(app_data.db()).await?; let port = app_data.port(); let debug = app_data.debug(); - log::info!("Starting server on 0.0.0.0:{}", port); + tracing::info!("Starting server on 0.0.0.0:{}", port); let server = HttpServer::new(move || { let openapi = ApiDoc::openapi(); - App::new() + let app = App::new() .app_data(web::Data::new(app_data.clone())) .app_data(QueryConfig::default().error_handler(api::query_error_handler)) .service( @@ -59,8 +61,14 @@ async fn main() -> anyhow::Result<()> { .supports_credentials() .max_age(3600), ) - .wrap(Logger::default()) - .service(endpoints::mods::index) + .wrap(tracing_actix_web::TracingLogger::default()); + + #[cfg(feature = "dev-tools")] + let app = app + .service(actix_files::Files::new("/static", "static")) + .service(actix_files::Files::new("/storage", "storage/public")); + + app.service(endpoints::mods::index) .service(endpoints::mods::get_mod_updates) .service(endpoints::mods::get) .service(endpoints::mods::create) @@ -72,6 +80,17 @@ async fn main() -> anyhow::Result<()> { .service(endpoints::mod_versions::download_version) .service(endpoints::mod_versions::create_version) .service(endpoints::mod_versions::update_version) + .service(endpoints::mod_version_submissions::get_submission) + .service(endpoints::mod_version_submissions::get_submission_audit) + .service(endpoints::mod_version_submissions::update_submission) + .service(endpoints::mod_version_submissions::get_comments) + .service(endpoints::mod_version_submissions::get_comment_audit) + .service(endpoints::mod_version_submissions::create_comment) + .service(endpoints::mod_version_submissions::update_comment) + .service(endpoints::mod_version_submissions::delete_comment) + .service(endpoints::mod_version_submissions::get_attachments) + .service(endpoints::mod_version_submissions::upload_attachments) + .service(endpoints::mod_version_submissions::delete_attachment) .service(endpoints::deprecations::index) .service(endpoints::deprecations::store) .service(endpoints::deprecations::update) @@ -104,7 +123,7 @@ async fn main() -> anyhow::Result<()> { .bind(("0.0.0.0", port))?; if debug { - log::info!("Running in debug mode, using 1 thread."); + tracing::info!("Running in debug mode, using 1 thread."); server.workers(1).run().await?; } else { server.run().await?; diff --git a/src/mod_zip.rs b/src/mod_zip.rs index 1da03001..8b089581 100644 --- a/src/mod_zip.rs +++ b/src/mod_zip.rs @@ -46,13 +46,13 @@ pub fn extract_mod_logo(file: &mut ZipFile>) -> Result, Mo let mut logo: Vec = Vec::with_capacity(file.size() as usize); file.read_to_end(&mut logo) - .inspect_err(|e| log::error!("logo.png read fail: {}", e))?; + .inspect_err(|e| tracing::error!("logo.png read fail: {}", e))?; let mut reader = BufReader::new(Cursor::new(logo)); let mut img = PngDecoder::new(&mut reader) .and_then(DynamicImage::from_decoder) - .inspect_err(|e| log::error!("Failed to create PngDecoder: {}", e))?; + .inspect_err(|e| tracing::error!("Failed to create PngDecoder: {}", e))?; let dimensions = img.dimensions(); @@ -79,7 +79,7 @@ pub fn extract_mod_logo(file: &mut ZipFile>) -> Result, Mo encoder .write_image(img.as_bytes(), width, height, img.color().into()) - .inspect_err(|e| log::error!("Failed to downscale image to 336x336: {}", e))?; + .inspect_err(|e| tracing::error!("Failed to downscale image to 336x336: {}", e))?; cursor.seek(std::io::SeekFrom::Start(0)).unwrap(); @@ -99,13 +99,13 @@ pub fn validate_mod_logo(file: &mut ZipFile>) -> Result<(), ModZip let mut logo: Vec = Vec::with_capacity(file.size() as usize); file.read_to_end(&mut logo) - .inspect_err(|e| log::error!("logo.png read fail: {}", e))?; + .inspect_err(|e| tracing::error!("logo.png read fail: {}", e))?; let mut reader = BufReader::new(Cursor::new(logo)); let img = PngDecoder::new(&mut reader) .and_then(DynamicImage::from_decoder) - .inspect_err(|e| log::error!("Failed to create PngDecoder: {}", e))?; + .inspect_err(|e| tracing::error!("Failed to create PngDecoder: {}", e))?; let dimensions = img.dimensions(); @@ -142,7 +142,7 @@ pub async fn download_mod_hash_comp( pub fn bytes_to_ziparchive(bytes: Bytes) -> Result>, ModZipError> { ZipArchive::new(Cursor::new(bytes)) - .inspect_err(|e| log::error!("Failed to create ZipArchive: {}", e)) + .inspect_err(|e| tracing::error!("Failed to create ZipArchive: {}", e)) .map_err(|e| e.into()) } @@ -150,9 +150,9 @@ async fn download(url: &str, limit_mb: u32) -> Result { let limit_bytes: u64 = limit_mb as u64 * 1_000_000; let mut response = reqwest::get(url) .await - .inspect_err(|e| log::error!("Failed to fetch .geode file: {e}"))? + .inspect_err(|e| tracing::error!("Failed to fetch .geode file: {e}"))? .error_for_status() - .inspect_err(|e| log::error!("Failed to fetch .geode file: {e}"))?; + .inspect_err(|e| tracing::error!("Failed to fetch .geode file: {e}"))?; // Check Content-Length, but the server can lie about this, so we'll also stream the file // If the header is somehow unavailable, we'll just check the size when streaming diff --git a/src/openapi.rs b/src/openapi.rs index 11e824f3..4691b49d 100644 --- a/src/openapi.rs +++ b/src/openapi.rs @@ -16,6 +16,15 @@ use crate::{endpoints, types}; endpoints::mod_versions::download_version, endpoints::mod_versions::create_version, endpoints::mod_versions::update_version, + endpoints::mod_version_submissions::get_submission, + endpoints::mod_version_submissions::update_submission, + endpoints::mod_version_submissions::get_comments, + endpoints::mod_version_submissions::create_comment, + endpoints::mod_version_submissions::update_comment, + endpoints::mod_version_submissions::delete_comment, + endpoints::mod_version_submissions::get_attachments, + endpoints::mod_version_submissions::upload_attachments, + endpoints::mod_version_submissions::delete_attachment, endpoints::deprecations::index, endpoints::deprecations::store, endpoints::deprecations::update, @@ -72,6 +81,12 @@ use crate::{endpoints, types}; types::models::mod_link::ModLinks, types::models::loader_version::LoaderVersion, types::models::gd_version_alias::GDVersionAlias, + types::models::mod_version_submission::ModVersionSubmission, + types::models::mod_version_submission::ModVersionSubmissionComment, + types::models::mod_version_submission::UpdateSubmissionPayload, + types::models::mod_version_submission::CreateCommentPayload, + types::models::mod_version_submission::UpdateCommentPayload, + types::models::mod_version_submission::ModVersionSubmissionAttachment, endpoints::mods::IndexSortType, endpoints::developers::SimpleDevMod, endpoints::developers::SimpleDevModVersion, @@ -80,6 +95,7 @@ use crate::{endpoints, types}; tags( (name = "mods", description = "Mod management endpoints"), (name = "mod_versions", description = "Mod version management endpoints"), + (name = "mod_version_submissions", description = "Mod version submission and review endpoints"), (name = "deprecations", description = "Mod deprecation management endpoints"), (name = "developers", description = "Developer management endpoints"), (name = "tags", description = "Tag management endpoints"), diff --git a/src/storage.rs b/src/storage.rs index a6c223de..cd2a3c35 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -3,14 +3,14 @@ use std::path::{Path, PathBuf}; #[derive(Clone, Debug)] pub struct StaticStorage { base_path: PathBuf, - app_url: String + app_url: String, } impl StaticStorage { pub fn new(app_url: String) -> Self { Self { - base_path: PathBuf::from("storage/static"), - app_url + base_path: PathBuf::from("static"), + app_url, } } @@ -32,14 +32,14 @@ impl StorageDisk for StaticStorage { #[derive(Clone, Debug)] pub struct PublicStorage { base_path: PathBuf, - app_url: String + app_url: String, } impl PublicStorage { pub fn new(app_url: String) -> Self { Self { base_path: PathBuf::from("storage/public"), - app_url + app_url, } } @@ -95,7 +95,7 @@ pub trait StorageDisk { tokio::fs::write(path, data).await } /// Store data at a path calculated from the hash of the data. Uses content-addressable storage with 2 levels - async fn store_hashed(&self, relative_path: &str, data: &[u8]) -> std::io::Result<()> { + async fn store_hashed(&self, relative_path: &str, data: &[u8]) -> std::io::Result { self.store_hashed_with_extension(relative_path, data, None) .await } @@ -106,7 +106,7 @@ pub trait StorageDisk { relative_path: &str, data: &[u8], extension: Option<&str>, - ) -> std::io::Result<()> { + ) -> std::io::Result { let hash = sha256::digest(data); let hashed_path = format!( @@ -119,7 +119,8 @@ pub trait StorageDisk { ext.trim_start_matches('.') )) ); - self.store(&hashed_path, data).await + self.store(&hashed_path, data).await?; + Ok(hashed_path) } async fn read(&self, relative_path: &str) -> std::io::Result> { match tokio::fs::read(self.path(relative_path)).await { diff --git a/src/types/mod_json.rs b/src/types/mod_json.rs index 26cc02f9..fc994571 100644 --- a/src/types/mod_json.rs +++ b/src/types/mod_json.rs @@ -217,7 +217,7 @@ impl ModJson { } let mut json = serde_json::from_reader::>, ModJson>(json_file) - .inspect_err(|e| log::error!("Failed to parse mod.json: {e}"))?; + .inspect_err(|e| tracing::error!("Failed to parse mod.json: {e}"))?; json.version = json.version.trim_start_matches('v').to_string(); json.hash = hash; @@ -247,7 +247,9 @@ impl ModJson { json.about = Some( parse_zip_entry_to_str(&mut file) - .inspect_err(|e| log::error!("Failed to parse about.md for mod: {e}")) + .inspect_err(|e| { + tracing::error!("Failed to parse about.md for mod: {e}") + }) .map_err(|e| { ModZipError::InvalidModJson(format!("Failed to read about.md: {e}")) })?, @@ -262,7 +264,7 @@ impl ModJson { json.changelog = Some( parse_zip_entry_to_str(&mut file) - .inspect_err(|e| log::error!("Failed to parse changelog.md: {e}")) + .inspect_err(|e| tracing::error!("Failed to parse changelog.md: {e}")) .map_err(|e| { ModZipError::InvalidModJson(format!( "Failed to read changelog.md: {e}" @@ -306,7 +308,8 @@ impl ModJson { continue; } let (dependency_ver, compare) = split_version_and_compare(&(i.version)) - .map_err(|_| { + .map_err(|e| { + tracing::error!(error = ?e, "invalid semver in dependency version"); ModZipError::InvalidModJson(format!("Invalid semver {}", i.version)) })?; ret.push(DependencyCreate { @@ -329,7 +332,11 @@ impl ModJson { match dep { ModJsonDependencyType::Version(version) => { let (dependency_ver, compare) = split_version_and_compare(version) - .map_err(|_| { + .map_err(|e| { + tracing::error!( + error = ?e, + "invalid semver in dependency version" + ); ModZipError::InvalidModJson(format!( "Invalid semver {}", version @@ -359,7 +366,11 @@ impl ModJson { } }; let (dependency_ver, compare) = split_version_and_compare(dep_ver) - .map_err(|_| { + .map_err(|e| { + tracing::error!( + error = ?e, + "invalid semver in dependency version" + ); ModZipError::InvalidModJson(format!( "Invalid semver {}", dep_ver @@ -406,7 +417,8 @@ impl ModJson { continue; } - let (ver, compare) = split_version_and_compare(&(i.version)).map_err(|_| { + let (ver, compare) = split_version_and_compare(&(i.version)).map_err(|e| { + tracing::error!(error = ?e, "invalid semver in incompatibility version"); ModZipError::InvalidModJson(format!("Invalid semver: {}", i.version)) })?; ret.push(IncompatibilityCreate { @@ -430,7 +442,11 @@ impl ModJson { match item { ModJsonIncompatibilityType::Version(version) => { let (ver, compare) = - split_version_and_compare(version).map_err(|_| { + split_version_and_compare(version).map_err(|e| { + tracing::error!( + error = ?e, + "invalid semver in incompatibility version" + ); ModZipError::InvalidModJson(format!( "Invalid semver {}", version @@ -460,7 +476,11 @@ impl ModJson { } }; let (ver, compare) = - split_version_and_compare(inc_ver).map_err(|_| { + split_version_and_compare(inc_ver).map_err(|e| { + tracing::error!( + error = ?e, + "invalid semver in incompatibility version" + ); ModZipError::InvalidModJson(format!( "Invalid semver {}", inc_ver @@ -483,7 +503,7 @@ impl ModJson { pub fn validate(&self) -> Result<(), ModZipError> { if let Err(e) = ::validate(self) { - log::warn!("mod.json validation error: {e}"); + tracing::warn!("mod.json validation error: {e}"); let useful_error = extract_validation_error(&e); return Err(ModZipError::InvalidModJson(format!( "validation error: {useful_error}" @@ -532,8 +552,10 @@ impl ModJson { } let v5: bool = { - let geode = Version::parse(self.geode.trim_start_matches('v')) - .map_err(|_| ModZipError::InvalidModJson("Invalid geode version".into()))?; + let geode = Version::parse(self.geode.trim_start_matches('v')).map_err(|e| { + tracing::error!(error = ?e, "invalid semver in geode version"); + ModZipError::InvalidModJson("Invalid geode version".into()) + })?; geode.major >= 5 }; @@ -580,9 +602,15 @@ impl ModJson { } } - if !self.windows && !self.ios && !self.android32 && !self.android64 && !self.mac_intel && !self.mac_arm { + if !self.windows + && !self.ios + && !self.android32 + && !self.android64 + && !self.mac_intel + && !self.mac_arm + { return Err(ModZipError::InvalidModJson( - "Mod has no binaries".to_string() + "Mod has no binaries".to_string(), )); } @@ -595,7 +623,7 @@ fn parse_zip_entry_to_str(file: &mut ZipFile>) -> Result Ok(string), Err(e) => { - log::error!("{}", e); + tracing::error!("{}", e); Err(format!("Failed to parse {}", file.name())) } } @@ -636,7 +664,7 @@ fn check_mac_binary(file: &mut ZipFile>) -> Result<(bool, bool), M // 12 bytes is all we need let mut bytes: Vec = vec![0; 12]; file.read_exact(&mut bytes).map_err(|e| { - log::error!("Failed to read MacOS binary: {}", e); + tracing::error!("Failed to read MacOS binary: {}", e); ModZipError::InvalidBinaries(format!("Failed to read macOS binary: {e}")) })?; diff --git a/src/types/models/audit_actions.rs b/src/types/models/audit_actions.rs new file mode 100644 index 00000000..48ed3b99 --- /dev/null +++ b/src/types/models/audit_actions.rs @@ -0,0 +1,37 @@ +use crate::types::serde::chrono_dt_secs; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use std::str::FromStr; +use utoipa::ToSchema; + +#[derive(sqlx::Type, Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Hash, ToSchema)] +#[sqlx(type_name = "audit_action", rename_all = "lowercase")] +#[serde(rename_all = "lowercase")] +pub enum AuditAction { + Created, + Updated, + Deleted, + Restored, +} + +impl FromStr for AuditAction { + type Err = (); + fn from_str(s: &str) -> Result { + match s { + "created" => Ok(AuditAction::Created), + "updated" => Ok(AuditAction::Updated), + "deleted" => Ok(AuditAction::Deleted), + "restored" => Ok(AuditAction::Restored), + _ => Err(()), + } + } +} + +#[derive(Serialize, Debug, Clone, ToSchema)] +pub struct AuditActionRow { + pub action: AuditAction, + pub details: Option, + pub performed_by: Option, + #[serde(with = "chrono_dt_secs")] + pub performed_at: DateTime, +} diff --git a/src/types/models/dependency.rs b/src/types/models/dependency.rs index e00030c6..0c37d79d 100644 --- a/src/types/models/dependency.rs +++ b/src/types/models/dependency.rs @@ -1,9 +1,9 @@ use std::{collections::HashMap, fmt::Display}; +use crate::database::DatabaseError; use serde::{Deserialize, Serialize}; use sqlx::PgConnection; use utoipa::ToSchema; -use crate::database::DatabaseError; use super::mod_gd_version::{GDVersionEnum, VerPlatform}; @@ -46,7 +46,7 @@ impl FetchedDependency { } }, importance: self.importance, - required: self.importance == DependencyImportance::Required + required: self.importance == DependencyImportance::Required, } } pub fn to_response(&self) -> ResponseDependency { @@ -60,7 +60,7 @@ impl FetchedDependency { } }, importance: self.importance, - required: self.importance == DependencyImportance::Required + required: self.importance == DependencyImportance::Required, } } } @@ -108,6 +108,7 @@ pub enum DependencyImportance { } impl Dependency { + #[tracing::instrument(skip_all, fields(mod_version_ids = ?ids))] pub async fn get_for_mod_versions( ids: &Vec, platform: Option, @@ -191,7 +192,7 @@ impl Dependency { .bind(geode_pre) .fetch_all(&mut *pool) .await - .inspect_err(|x| log::error!("Failed to fetch dependencies: {x}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; let mut ret: HashMap> = HashMap::new(); for i in result { diff --git a/src/types/models/developer.rs b/src/types/models/developer.rs index 29ac26af..4190e4f8 100644 --- a/src/types/models/developer.rs +++ b/src/types/models/developer.rs @@ -18,3 +18,28 @@ pub struct Developer { pub admin: bool, pub github_id: i64, } + +#[derive(sqlx::FromRow, Serialize, Clone, Debug, ToSchema)] +pub struct SelfDeveloper { + pub id: i32, + pub username: String, + pub display_name: String, + pub verified: bool, + pub admin: bool, + pub github_id: i64, + pub has_accepted_mod: bool, +} + +impl Developer { + pub fn to_self_developer(&self, has_accepted_mod: bool) -> SelfDeveloper { + SelfDeveloper { + id: self.id, + username: self.username.clone(), + display_name: self.display_name.clone(), + verified: self.verified, + admin: self.admin, + github_id: self.github_id, + has_accepted_mod, + } + } +} diff --git a/src/types/models/gd_version_alias.rs b/src/types/models/gd_version_alias.rs index de6545dd..a1ab0bbf 100644 --- a/src/types/models/gd_version_alias.rs +++ b/src/types/models/gd_version_alias.rs @@ -6,7 +6,7 @@ use crate::{ use serde::Serialize; use utoipa::ToSchema; -use sqlx::{types::Uuid, PgConnection, Postgres, QueryBuilder}; +use sqlx::{PgConnection, Postgres, QueryBuilder, types::Uuid}; #[derive(Serialize, ToSchema)] pub struct GDVersionAlias { @@ -20,6 +20,7 @@ pub struct GDVersionAlias { } impl GDVersionAlias { + #[tracing::instrument(skip_all, fields(platform = ?platform, identifier = %identifier))] pub async fn find( platform: VerPlatform, identifier: &str, @@ -85,6 +86,7 @@ impl GDVersionAlias { .build_query_scalar::() .fetch_optional(&mut *pool) .await + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) } } diff --git a/src/types/models/incompatibility.rs b/src/types/models/incompatibility.rs index 6ef49126..f196935b 100644 --- a/src/types/models/incompatibility.rs +++ b/src/types/models/incompatibility.rs @@ -91,6 +91,7 @@ impl FetchedIncompatibility { } impl Incompatibility { + #[tracing::instrument(skip_all, fields(mod_version_id = %id))] pub async fn get_for_mod_version( id: i32, pool: &mut PgConnection, @@ -106,10 +107,11 @@ impl Incompatibility { ) .fetch_all(&mut *pool) .await - .inspect_err(|e| log::error!("Failed to fetch incompatibilities for mod_version {id}: {e}")) + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) } + #[tracing::instrument(skip_all, fields(mod_version_ids = ?ids))] pub async fn get_for_mod_versions( ids: &Vec, platform: Option, @@ -161,9 +163,10 @@ impl Incompatibility { .bind(geode.map(|x| i64::try_from(x.patch).ok())) .bind(geode_pre); - let result = q.fetch_all(&mut *pool).await.inspect_err(|e| { - log::error!("Failed to fetch incompatibilities for mod_versions: {e}") - })?; + let result = q + .fetch_all(&mut *pool) + .await + .inspect_err(|e| tracing::error!("{:?}", e))?; let mut ret: HashMap> = HashMap::new(); diff --git a/src/types/models/loader_version.rs b/src/types/models/loader_version.rs index 7d1d855e..0879cf98 100644 --- a/src/types/models/loader_version.rs +++ b/src/types/models/loader_version.rs @@ -77,6 +77,7 @@ impl LoaderVersionGetOne { } impl LoaderVersion { + #[tracing::instrument(skip_all, fields(gd = ?gd, platform = ?platform, accept_prereleases = %accept_prereleases))] pub async fn get_latest( gd: Option, platform: Option, @@ -164,10 +165,12 @@ impl LoaderVersion { .build_query_as::() .fetch_optional(&mut *pool) .await + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) .map(|x| x.map(|y| y.into_loader_version())) } + #[tracing::instrument(skip_all, fields(tag = %tag))] pub async fn get_one( tag: &str, pool: &mut PgConnection, @@ -183,10 +186,12 @@ impl LoaderVersion { ) .fetch_optional(&mut *pool) .await + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) .map(|x| x.map(|y| y.into_loader_version())) } + #[tracing::instrument(skip_all, fields(tag = %version.tag))] pub async fn create_version( version: LoaderVersionCreate, pool: &mut PgConnection, @@ -206,10 +211,12 @@ impl LoaderVersion { ) .execute(&mut *pool) .await + .inspect_err(|e| tracing::error!("{:?}", e)) .map(|_| ()) .map_err(|e| e.into()) } + #[tracing::instrument(skip_all, fields(page = %page, per_page = %per_page))] pub async fn get_many( query: GetVersionsQuery, per_page: i64, @@ -287,6 +294,7 @@ impl LoaderVersion { .build_query_as::() .fetch_all(&mut *pool) .await + .inspect_err(|e| tracing::error!("{:?}", e)) .map(|x| x.into_iter().map(|y| y.into_loader_version()).collect()) .map_err(|e| e.into()) } diff --git a/src/types/models/mod.rs b/src/types/models/mod.rs index e3f46125..1fa103e2 100644 --- a/src/types/models/mod.rs +++ b/src/types/models/mod.rs @@ -1,15 +1,17 @@ +pub mod audit_actions; pub mod dependency; -pub mod download_count; +pub mod deprecations; pub mod developer; +pub mod download_count; +pub mod gd_version_alias; pub mod github_login_attempt; pub mod incompatibility; +pub mod loader_version; pub mod mod_entity; pub mod mod_gd_version; pub mod mod_link; pub mod mod_version; pub mod mod_version_status; +pub mod mod_version_submission; pub mod stats; pub mod tag; -pub mod loader_version; -pub mod gd_version_alias; -pub mod deprecations; diff --git a/src/types/models/mod_entity.rs b/src/types/models/mod_entity.rs index 30bb58ad..4a4a5582 100644 --- a/src/types/models/mod_entity.rs +++ b/src/types/models/mod_entity.rs @@ -116,6 +116,7 @@ impl Mod { } } + #[tracing::instrument(skip_all)] pub async fn get_stats(pool: &mut PgConnection) -> Result { let result = sqlx::query!( " @@ -132,7 +133,7 @@ impl Mod { ) .fetch_optional(&mut *pool) .await - .inspect_err(|e| log::error!("failed to get mod stats: {}", e))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; if let Some((Some(total_count), Some(total_downloads))) = result.map(|o| (o.id_count, o.download_sum)) @@ -149,6 +150,7 @@ impl Mod { } } + #[tracing::instrument(skip_all, fields(query = ?query.query, page = ?query.page, per_page = ?query.per_page))] pub async fn get_index( pool: &mut PgConnection, query: &IndexQueryParams, @@ -185,7 +187,7 @@ impl Mod { }; let order = match query.sort { - IndexSortType::Downloads => "q.download_count DESC", + IndexSortType::Downloads => "q.download_acount DESC", IndexSortType::RecentlyUpdated => "q.updated_at DESC", IndexSortType::RecentlyPublished => "q.created_at DESC", IndexSortType::Oldest => "q.created_at ASC", @@ -356,13 +358,13 @@ impl Mod { records_builder.push(" LIMIT ").push_bind(limit); records_builder.push(" OFFSET ").push_bind(offset); - // log::debug!("sql: {}", records_builder.sql()); + // tracing::debug!("sql: {}", records_builder.sql()); let records: Vec = records_builder .build_query_as() .fetch_all(&mut *pool) .await - .inspect_err(|e| log::error!("Failed to fetch mod index: {}", e))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; let mut count_builder = sqlx::QueryBuilder::new("SELECT COUNT(DISTINCT m.id) "); @@ -372,7 +374,7 @@ impl Mod { .build_query_scalar() .fetch_optional(&mut *pool) .await - .inspect_err(|e| log::error!("Failed to fetch mod index count: {}", e))? + .inspect_err(|e| tracing::error!("{:?}", e))? .unwrap_or_default(); if records.is_empty() { @@ -399,8 +401,8 @@ impl Mod { let mut developers = developers::get_all_for_mods(&ids, pool).await?; let links = ModLinks::fetch_for_mods(&ids, pool).await?; let mod_version_ids: Vec = versions - .iter() - .map(|(_, mod_version)| mod_version.id) + .values() + .map(|mod_version| mod_version.id) .collect(); let mut gd_versions = ModGDVersion::get_for_mod_versions(&mod_version_ids, pool).await?; @@ -486,6 +488,7 @@ impl Mod { }) } + #[tracing::instrument(skip_all, fields(developer_id = %id, status = ?status, only_owner = %only_owner))] pub async fn get_all_for_dev( id: i32, status: ModVersionStatusEnum, @@ -529,7 +532,7 @@ impl Mod { ) .fetch_all(&mut *pool) .await - .inspect_err(|x| log::error!("Failed to fetch developer mods: {}", x))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; if records.is_empty() { return Ok(vec![]); @@ -572,6 +575,7 @@ impl Mod { Ok(mods) } + #[tracing::instrument(skip_all, fields(mod_id = %id, only_accepted = %only_accepted))] pub async fn get_one( id: &str, only_accepted: bool, @@ -597,7 +601,7 @@ impl Mod { ) .fetch_all(&mut *pool) .await - .inspect_err(|e| log::error!("{}", e))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; if records.is_empty() { return Ok(None); @@ -670,6 +674,7 @@ impl Mod { /// At the moment this is only used to set the mod to featured. /// DOES NOT check if the mod exists + #[tracing::instrument(skip_all, fields(mod_id = %id, featured = %featured))] pub async fn update_mod( id: &str, featured: bool, @@ -678,11 +683,12 @@ impl Mod { sqlx::query!("UPDATE mods SET featured = $1 WHERE id = $2", featured, id) .execute(&mut *pool) .await - .inspect_err(|e| log::error!("Failed to update mod {id}: {e}")) + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) .map(|_| ()) } + #[tracing::instrument(skip_all, fields(mod_ids = ?ids, platform = ?platforms, gd = ?gd))] pub async fn get_updates( ids: &[String], platforms: VerPlatform, @@ -749,7 +755,7 @@ impl Mod { ) .fetch_all(&mut *pool) .await - .inspect_err(|x| log::error!("Failed to fetch mod updates: {}", x))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; if result.is_empty() { return Ok(vec![]); diff --git a/src/types/models/mod_gd_version.rs b/src/types/models/mod_gd_version.rs index ded0b28d..67d36e56 100644 --- a/src/types/models/mod_gd_version.rs +++ b/src/types/models/mod_gd_version.rs @@ -231,6 +231,7 @@ impl DetailedGDVersion { impl ModGDVersion { // to be used for GET mods/{id}/version/{version} + #[tracing::instrument(skip_all, fields(mod_version_id = %id))] pub async fn get_for_mod_version( id: i32, pool: &mut PgConnection, @@ -246,9 +247,7 @@ impl ModGDVersion { ) .fetch_all(&mut *pool) .await - .inspect_err(|e| { - log::error!("Failed to fetch mod_gd_versions for mod_version {id}: {e}") - })?; + .inspect_err(|e| tracing::error!("{:?}", e))?; let mut ret = DetailedGDVersion { win: None, mac: None, @@ -282,6 +281,7 @@ impl ModGDVersion { } // hello + #[tracing::instrument(skip_all, fields(mod_version_ids = ?versions))] pub async fn get_for_mod_versions( versions: &[i32], pool: &mut PgConnection, @@ -300,7 +300,7 @@ impl ModGDVersion { ) .fetch_all(&mut *pool) .await - .inspect_err(|e| log::error!("Failed to fetch mod_gd_versions: {}", e))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; let mut ret: HashMap = HashMap::new(); for i in result { diff --git a/src/types/models/mod_link.rs b/src/types/models/mod_link.rs index eeed5f56..9fa7ea20 100644 --- a/src/types/models/mod_link.rs +++ b/src/types/models/mod_link.rs @@ -14,6 +14,7 @@ pub struct ModLinks { } impl ModLinks { + #[tracing::instrument(skip_all, fields(mod_id = %mod_id))] pub async fn fetch( mod_id: &str, pool: &mut PgConnection, @@ -28,10 +29,11 @@ impl ModLinks { ) .fetch_optional(pool) .await - .inspect_err(|e| log::error!("Failed to fetch mod links for mod {}. Error: {}", mod_id, e)) + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) } + #[tracing::instrument(skip_all, fields(mod_ids = ?mod_ids))] pub async fn fetch_for_mods( mod_ids: &Vec, pool: &mut PgConnection, @@ -50,7 +52,7 @@ impl ModLinks { ) .fetch_all(pool) .await - .inspect_err(|e| log::error!("Failed to fetch mod links for multiple mods. Error: {}", e)) + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) } } diff --git a/src/types/models/mod_version.rs b/src/types/models/mod_version.rs index b66c7b77..8ad4f3f8 100644 --- a/src/types/models/mod_version.rs +++ b/src/types/models/mod_version.rs @@ -1,7 +1,7 @@ use super::{ dependency::{Dependency, ModVersionCompare, ResponseDependency}, - download_count::DownloadCount, developer::ModDeveloper, + download_count::DownloadCount, incompatibility::{Incompatibility, ResponseIncompatibility}, mod_gd_version::{DetailedGDVersion, GDVersionEnum, ModGDVersion, VerPlatform}, mod_version_status::ModVersionStatusEnum, @@ -15,12 +15,12 @@ use crate::types::{ }; use semver::Version; use serde::Serialize; -use utoipa::ToSchema; use sqlx::{ PgConnection, Postgres, QueryBuilder, types::chrono::{DateTime, Utc}, }; use std::collections::HashMap; +use utoipa::ToSchema; #[derive(Serialize, Debug, sqlx::FromRow, Clone, ToSchema)] pub struct ModVersion { @@ -149,6 +149,7 @@ impl ModVersion { self.modify_download_link(app_url) } + #[tracing::instrument(skip_all, fields(mod_id = %query.mod_id, page = %query.page, per_page = %query.per_page))] pub async fn get_index( query: IndexQuery, pool: &mut PgConnection, @@ -249,13 +250,13 @@ impl ModVersion { .build_query_as::() .fetch_all(&mut *pool) .await - .inspect_err(|e| log::error!("Failed to fetch index: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; let count: i64 = counter_q .build_query_scalar() .fetch_one(&mut *pool) .await - .inspect_err(|e| log::error!("Failed to fetch index count: {e}"))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; if records.is_empty() { return Ok(PaginatedData { @@ -299,6 +300,7 @@ impl ModVersion { Ok(PaginatedData { data: ret, count }) } + #[tracing::instrument(skip_all, fields(mod_ids = ?ids, gd = ?gd))] pub async fn get_latest_for_mods( pool: &mut PgConnection, ids: &[String], @@ -371,7 +373,7 @@ impl ModVersion { .bind(requires_patching) .fetch_all(&mut *pool) .await - .inspect_err(|x| log::error!("Failed to fetch latest versions for mods: {}", x)) + .inspect_err(|e| tracing::error!("{:?}", e)) .map_err(|e| e.into()) .map(|result: Vec| { result.into_iter() @@ -380,6 +382,7 @@ impl ModVersion { }) } + #[tracing::instrument(skip_all, fields(mod_ids = ?ids))] pub async fn get_pending_for_mods( ids: &[String], pool: &mut PgConnection, @@ -403,7 +406,7 @@ impl ModVersion { ids ).fetch_all(&mut *pool) .await - .inspect_err(|e| log::error!("Failed to fetch pending mod versions: {}", e))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; let mut ret: HashMap> = HashMap::new(); @@ -416,6 +419,7 @@ impl ModVersion { Ok(ret) } + #[tracing::instrument(skip_all, fields(mod_id = %id, gd = ?gd))] pub async fn get_latest_for_mod( id: &str, gd: Option, @@ -472,7 +476,7 @@ impl ModVersion { .build_query_as::() .fetch_optional(&mut *pool) .await - .inspect_err(|e| log::error!("Failed to fetch latest mod_version for mod {id}: {e}"))? + .inspect_err(|e| tracing::error!("{:?}", e))? .map(|v| v.into_mod_version()); let Some(mut version) = version else { @@ -505,6 +509,7 @@ impl ModVersion { Ok(Some(version)) } + #[tracing::instrument(skip_all, fields(mod_id = %id, version = %version))] pub async fn get_one( id: &str, version: &str, @@ -532,7 +537,7 @@ impl ModVersion { ) .fetch_optional(&mut *pool) .await - .inspect_err(|e| log::error!("ModVersion::get_one failed: {e}"))? + .inspect_err(|e| tracing::error!("{:?}", e))? .map(|x| x.into_mod_version()); let Some(mut version) = result else { @@ -562,6 +567,7 @@ impl ModVersion { Ok(Some(version)) } + #[tracing::instrument(skip_all, fields(mod_id = %mod_id))] pub async fn get_accepted_count( mod_id: &str, pool: &mut PgConnection, @@ -576,6 +582,7 @@ impl ModVersion { ) .fetch_one(&mut *pool) .await + .inspect_err(|e| tracing::error!("{:?}", e)) .map(|x| x.unwrap_or_default()) .map_err(|e| e.into()) } diff --git a/src/types/models/mod_version_submission.rs b/src/types/models/mod_version_submission.rs new file mode 100644 index 00000000..8212c5e4 --- /dev/null +++ b/src/types/models/mod_version_submission.rs @@ -0,0 +1,143 @@ +use crate::types::models::developer::Developer; +use crate::types::serde::chrono_dt_secs; +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(sqlx::Type, Debug, Deserialize, Serialize, Clone, Copy, PartialEq, Eq, Hash, ToSchema)] +#[sqlx(type_name = "submission_lock", rename_all = "lowercase")] +#[serde(rename_all = "lowercase")] +pub enum ModVersionSubmissionLock { + None, + Internal, + Locked, +} + +#[derive(Serialize, ToSchema, Debug, Clone)] +pub struct ModVersionSubmission { + pub mod_version_id: i32, + pub lock: ModVersionSubmissionLock, + pub locked_by: Option, + #[serde(with = "chrono_dt_secs")] + pub created_at: DateTime, + #[serde(with = "chrono_dt_secs")] + pub updated_at: DateTime, +} + +pub struct ModVersionSubmissionRow { + pub mod_version_id: i32, + pub lock: ModVersionSubmissionLock, + pub locked_by: Option, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +impl ModVersionSubmissionRow { + pub fn into_submission(self, locked_by: Option) -> ModVersionSubmission { + ModVersionSubmission { + mod_version_id: self.mod_version_id, + lock: self.lock, + locked_by, + created_at: self.created_at, + updated_at: self.updated_at, + } + } +} + +#[derive(Serialize, ToSchema, Debug, Clone)] +pub struct ModVersionSubmissionComment { + pub id: i64, + pub submission_id: i32, + pub comment: String, + pub author: Developer, + pub attachments: Vec, + #[serde(with = "chrono_dt_secs")] + pub created_at: DateTime, + #[serde(with = "chrono_dt_secs::option")] + pub updated_at: Option>, +} + +#[derive(Serialize, ToSchema, Debug, Clone)] +pub struct ModVersionSubmissionCommentAttachment { + pub id: i64, + pub url: String, +} + +pub struct ModVersionSubmissionCommentRow { + pub id: i64, + pub submission_id: i32, + pub comment: String, + pub author_id: i32, + pub created_at: DateTime, + pub updated_at: Option>, +} + +impl ModVersionSubmissionCommentRow { + pub fn into_comment( + self, + author: Developer, + attachments: Vec<(i64, String)>, + ) -> ModVersionSubmissionComment { + ModVersionSubmissionComment { + id: self.id, + submission_id: self.submission_id, + comment: self.comment, + author, + attachments: attachments + .into_iter() + .map(|(id, url)| ModVersionSubmissionCommentAttachment { id, url }) + .collect(), + created_at: self.created_at, + updated_at: self.updated_at, + } + } +} + +#[derive(Serialize, ToSchema, Debug, Clone)] +pub struct ModVersionSubmissionAttachment { + pub id: i64, + pub comment_id: i64, + pub url: String, + #[serde(with = "chrono_dt_secs")] + pub created_at: DateTime, +} + +pub struct ModVersionSubmissionAttachmentRow { + pub id: i64, + pub comment_id: i64, + pub filename: String, + pub created_at: DateTime, +} + +impl ModVersionSubmissionAttachmentRow { + pub fn into_attachment(self, app_url: &str) -> ModVersionSubmissionAttachment { + ModVersionSubmissionAttachment { + id: self.id, + comment_id: self.comment_id, + url: format!( + "{}/storage/submission-attachments/{}", + app_url, self.filename + ), + created_at: self.created_at, + } + } +} + +#[derive(Deserialize, ToSchema)] +pub struct UpdateSubmissionPayload { + pub lock: ModVersionSubmissionLock, +} + +#[derive(Deserialize, ToSchema)] +pub struct CreateCommentPayload { + #[schema(max_length = 1000)] + /// Plain text comment; HTML tags are stripped + pub comment: String, +} + +#[derive(Deserialize, ToSchema)] +pub struct UpdateCommentPayload { + #[schema(max_length = 1000)] + /// Plain text comment; HTML tags are stripped + pub comment: String, +} diff --git a/src/types/models/stats.rs b/src/types/models/stats.rs index 64b2593e..d99ed60a 100644 --- a/src/types/models/stats.rs +++ b/src/types/models/stats.rs @@ -1,111 +1,114 @@ -use crate::{database::repository::developers, endpoints::ApiError}; -use chrono::Utc; -use reqwest::{Client, header::HeaderValue}; -use serde::{Deserialize, Serialize}; -use sqlx::PgConnection; -use utoipa::ToSchema; - -use super::mod_entity::Mod; - -#[derive(Deserialize)] -struct GithubReleaseAsset { - // Github says in its API specs it's an integer, so theoretically it might - // return a download count of -1 or something - download_count: i64, -} - -#[derive(Deserialize)] -struct GithubReleaseWithAssets { - tag_name: String, - assets: Vec, -} - -#[derive(Deserialize, Serialize, Clone, Debug, ToSchema)] -pub struct Stats { - pub total_geode_downloads: i64, - pub total_mod_count: i64, - pub total_mod_downloads: i64, - pub total_registered_developers: i64, -} - -impl Stats { - pub async fn get_cached(pool: &mut PgConnection) -> Result { - let mod_stats = Mod::get_stats(&mut *pool).await?; - Ok(Stats { - total_mod_count: mod_stats.total_count, - total_mod_downloads: mod_stats.total_downloads, - total_registered_developers: developers::index_count(None, &mut *pool).await?, - total_geode_downloads: Self::get_latest_github_release_download_count(&mut *pool) - .await?, - }) - } - - async fn get_latest_github_release_download_count( - pool: &mut PgConnection, - ) -> Result { - // If release stats were fetched less than a day ago, just use cached stats - if let Ok((cache_time, total_download_count)) = sqlx::query!( - "SELECT s.checked_at, s.total_download_count - FROM github_loader_release_stats s - ORDER BY s.checked_at DESC" - ) - .fetch_one(&mut *pool) - .await - .map(|d| (d.checked_at, d.total_download_count)) - { - if Utc::now().signed_duration_since(cache_time).num_days() < 1 { - return Ok(total_download_count); - } - } - - // Fetch latest stats - let new = Self::fetch_github_release_stats().await?; - sqlx::query!( - "INSERT INTO github_loader_release_stats (total_download_count, latest_loader_version) - VALUES ($1, $2)", - new.0, - new.1 - ) - .execute(&mut *pool) - .await - .inspect_err(|e| log::error!("{}", e))?; - Ok(new.0) - } - - async fn fetch_github_release_stats() -> Result<(i64, String), ApiError> { - let client = Client::new(); - let resp = client - .get("https://api.github.com/repos/geode-sdk/geode/releases") - .header("Accept", HeaderValue::from_str("application/json").unwrap()) - .header("User-Agent", "geode_index") - .query(&[("per_page", "100")]) - .send() - .await - .inspect_err(|e| { - log::error!("Failed to request Geode release stats from GitHub: {}", e) - })?; - - if !resp.status().is_success() { - return Err(ApiError::InternalError( - "Failed to retrieve Geode release stats from GitHub".into(), - )); - } - - let releases: Vec = resp.json().await?; - let latest_release_tag = releases - .iter() - .find(|r| r.tag_name != "nightly") - .ok_or(ApiError::InternalError( - "No latest version detected on Geode repository".into(), - ))? - .tag_name - .clone(); - Ok(( - releases - .into_iter() - .map(|r| r.assets.into_iter().map(|a| a.download_count).sum::()) - .sum(), - latest_release_tag, - )) - } -} +use crate::{database::repository::developers, endpoints::ApiError}; +use chrono::Utc; +use reqwest::{Client, header::HeaderValue}; +use serde::{Deserialize, Serialize}; +use sqlx::PgConnection; +use utoipa::ToSchema; + +use super::mod_entity::Mod; + +#[derive(Deserialize)] +struct GithubReleaseAsset { + // Github says in its API specs it's an integer, so theoretically it might + // return a download count of -1 or something + download_count: i64, +} + +#[derive(Deserialize)] +struct GithubReleaseWithAssets { + tag_name: String, + assets: Vec, +} + +#[derive(Deserialize, Serialize, Clone, Debug, ToSchema)] +pub struct Stats { + pub total_geode_downloads: i64, + pub total_mod_count: i64, + pub total_mod_downloads: i64, + pub total_registered_developers: i64, +} + +impl Stats { + #[tracing::instrument(skip_all)] + pub async fn get_cached(pool: &mut PgConnection) -> Result { + let mod_stats = Mod::get_stats(&mut *pool).await?; + Ok(Stats { + total_mod_count: mod_stats.total_count, + total_mod_downloads: mod_stats.total_downloads, + total_registered_developers: developers::index_count(None, &mut *pool).await?, + total_geode_downloads: Self::get_latest_github_release_download_count(&mut *pool) + .await?, + }) + } + + #[tracing::instrument(skip_all)] + async fn get_latest_github_release_download_count( + pool: &mut PgConnection, + ) -> Result { + // If release stats were fetched less than a day ago, just use cached stats + if let Ok((cache_time, total_download_count)) = sqlx::query!( + "SELECT s.checked_at, s.total_download_count + FROM github_loader_release_stats s + ORDER BY s.checked_at DESC" + ) + .fetch_one(&mut *pool) + .await + .inspect_err(|e| tracing::error!("{:?}", e)) + .map(|d| (d.checked_at, d.total_download_count)) + { + if Utc::now().signed_duration_since(cache_time).num_days() < 1 { + return Ok(total_download_count); + } + } + + // Fetch latest stats + let new = Self::fetch_github_release_stats().await?; + sqlx::query!( + "INSERT INTO github_loader_release_stats (total_download_count, latest_loader_version) + VALUES ($1, $2)", + new.0, + new.1 + ) + .execute(&mut *pool) + .await + .inspect_err(|e| tracing::error!("{:?}", e))?; + Ok(new.0) + } + + async fn fetch_github_release_stats() -> Result<(i64, String), ApiError> { + let client = Client::new(); + let resp = client + .get("https://api.github.com/repos/geode-sdk/geode/releases") + .header("Accept", HeaderValue::from_str("application/json").unwrap()) + .header("User-Agent", "geode_index") + .query(&[("per_page", "100")]) + .send() + .await + .inspect_err(|e| { + tracing::error!("Failed to request Geode release stats from GitHub: {}", e) + })?; + + if !resp.status().is_success() { + return Err(ApiError::InternalError( + "Failed to retrieve Geode release stats from GitHub".into(), + )); + } + + let releases: Vec = resp.json().await?; + let latest_release_tag = releases + .iter() + .find(|r| r.tag_name != "nightly") + .ok_or(ApiError::InternalError( + "No latest version detected on Geode repository".into(), + ))? + .tag_name + .clone(); + Ok(( + releases + .into_iter() + .map(|r| r.assets.into_iter().map(|a| a.download_count).sum::()) + .sum(), + latest_release_tag, + )) + } +} diff --git a/src/types/models/tag.rs b/src/types/models/tag.rs index 0904270c..fffea949 100644 --- a/src/types/models/tag.rs +++ b/src/types/models/tag.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use crate::database::{repository::mod_tags, DatabaseError}; +use crate::database::{DatabaseError, repository::mod_tags}; use crate::endpoints::ApiError; use sqlx::PgConnection; use utoipa::ToSchema; @@ -14,6 +14,7 @@ pub struct Tag { } impl Tag { + #[tracing::instrument(skip_all, fields(mod_id = %mod_id))] pub async fn get_tags_for_mod( mod_id: &str, pool: &mut PgConnection, @@ -26,11 +27,12 @@ impl Tag { ) .fetch_all(&mut *pool) .await - .inspect_err(|e| log::error!("{}", e)) + .inspect_err(|e| tracing::error!("{:?}", e)) .map(|tags| tags.into_iter().map(|t| t.name).collect::>()) .map_err(|e| e.into()) } + #[tracing::instrument(skip_all, fields(mod_ids = ?ids))] pub async fn get_tags_for_mods( ids: &Vec, pool: &mut PgConnection, @@ -43,7 +45,7 @@ impl Tag { ) .fetch_all(&mut *pool) .await - .inspect_err(|e| log::error!("{}", e))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; let mut ret: HashMap> = HashMap::new(); for tag in tags { @@ -57,6 +59,7 @@ impl Tag { Ok(ret) } + #[tracing::instrument(skip_all, fields(tags = %tags))] pub async fn parse_tags(tags: &str, pool: &mut PgConnection) -> Result, ApiError> { let tags = tags .split(',') @@ -69,7 +72,7 @@ impl Tag { ) .fetch_all(&mut *pool) .await - .inspect_err(|e| log::error!("Failed to fetch tags: {}", e))?; + .inspect_err(|e| tracing::error!("{:?}", e))?; let fetched_ids = fetched.iter().map(|t| t.id).collect::>(); let fetched_names = fetched diff --git a/src/types/serde/mod.rs b/src/types/serde/mod.rs index 84893805..c2309256 100644 --- a/src/types/serde/mod.rs +++ b/src/types/serde/mod.rs @@ -1 +1 @@ -pub mod chrono_dt_secs; \ No newline at end of file +pub mod chrono_dt_secs; diff --git a/src/webhook/discord.rs b/src/webhook/discord.rs index c5294581..de3bd160 100644 --- a/src/webhook/discord.rs +++ b/src/webhook/discord.rs @@ -23,7 +23,7 @@ impl DiscordMessage { pub fn content(self, content: &str) -> Self { DiscordMessage { embeds: self.embeds, - content: Some(content.into()) + content: Some(content.into()), } } @@ -36,7 +36,7 @@ impl DiscordMessage { if self.embeds.len() == 10 { return DiscordMessage { content: self.content, - embeds: self.embeds + embeds: self.embeds, }; } @@ -61,29 +61,23 @@ impl DiscordMessage { DiscordMessage { content: self.content, - embeds + embeds, } } pub fn send(&self, url: &str) { if url.is_empty() { - log::error!("Not sending webhook since URL is empty"); + tracing::error!("Not sending webhook since URL is empty"); return; } - log::debug!("Sending {:?} to webhook url {}", self, url); + tracing::debug!("Sending {:?} to webhook url {}", self, url); let url = String::from(url); let copy = self.clone(); tokio::spawn(async move { - - if let Err(e) = reqwest::Client::new() - .post(&url) - .json(©) - .send() - .await - { - log::error!("Failed to broadcast Discord webhook {}: {}", url, e); + if let Err(e) = reqwest::Client::new().post(&url).json(©).send().await { + tracing::error!("Failed to broadcast Discord webhook {}: {}", url, e); } }); } diff --git a/src/webhook/mod.rs b/src/webhook/mod.rs index d598d52f..3228a62a 100644 --- a/src/webhook/mod.rs +++ b/src/webhook/mod.rs @@ -1 +1 @@ -pub mod discord; \ No newline at end of file +pub mod discord; diff --git a/storage/static/shields/mod_downloads.svg b/static/shields/mod_downloads.svg similarity index 100% rename from storage/static/shields/mod_downloads.svg rename to static/shields/mod_downloads.svg diff --git a/storage/static/shields/mod_gd_version.svg b/static/shields/mod_gd_version.svg similarity index 100% rename from storage/static/shields/mod_gd_version.svg rename to static/shields/mod_gd_version.svg diff --git a/storage/static/shields/mod_geode_version.svg b/static/shields/mod_geode_version.svg similarity index 100% rename from storage/static/shields/mod_geode_version.svg rename to static/shields/mod_geode_version.svg diff --git a/storage/static/shields/mod_version.svg b/static/shields/mod_version.svg similarity index 100% rename from storage/static/shields/mod_version.svg rename to static/shields/mod_version.svg